Skip to content

Commit 8ff1cf3

Browse files
committed
feat(checkbox): modernize for md3 spec compliance
Rewrites the Checkbox renderer to match the Material Design 3 spec (https://m3.material.io/components/checkbox/specs): - 18dp container with 2dp outline (unselected) / 0dp outline + theme primary fill (selected), inside a 40dp state-layer tap target. - State-layer overlay renders hover (8%), focus (10%) and pressed (10%) layers in the color the spec defines for each (selected pressed flips to onSurface; error always wins). - Focus indicator: 3dp ring at theme.colors.secondary with the 2dp outer-offset from md.sys.state.focusIndicator. Gated on :focus-visible via the useFocusVisible hook added in #4952. - Animations approximate Compose Material3 Checkbox.kt: 100ms fill transition and 150ms checkmark draw, sequenced short-leg then long-leg to suggest the stroke fraction. Indeterminate uses a scaleX-animated dash. - No new peer-deps: the checkmark is built from two rotated rectangles (View-based), not an SVG path. utils.ts: - New getSelectionVisualState helper returns the full color + opacity + outline-width picture for a given state combo. - Legacy getSelectionControlColor kept as a compatibility export for RadioButtonAndroid (radio button modernization is out of scope for this PR). 9 snapshots auto-updated to reflect the new render tree.
1 parent 08ecaeb commit 8ff1cf3

6 files changed

Lines changed: 1339 additions & 595 deletions

File tree

src/components/Checkbox/Checkbox.tsx

Lines changed: 218 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@ import {
33
Animated,
44
ColorValue,
55
GestureResponderEvent,
6+
Pressable,
7+
StyleProp,
68
StyleSheet,
79
View,
10+
ViewStyle,
811
} from 'react-native';
912

10-
import { getSelectionControlColor } from './utils';
13+
import { getSelectionVisualState } from './utils';
1114
import { useInternalTheme } from '../../core/theming';
12-
import type { $RemoveChildren, ThemeProp } from '../../types';
13-
import MaterialCommunityIcon from '../MaterialCommunityIcon';
14-
import TouchableRipple from '../TouchableRipple/TouchableRipple';
15+
import type { ThemeProp } from '../../types';
1516

16-
export type Props = $RemoveChildren<typeof TouchableRipple> & {
17+
export type Props = {
1718
/**
1819
* Status of checkbox.
1920
*/
@@ -36,9 +37,9 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
3637
color?: ColorValue;
3738
/**
3839
* Whether the checkbox is in an error state. When true, the outline
39-
* (unchecked) and container (checked / indeterminate) use
40-
* `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor`
41-
* overrides take precedence.
40+
* (unchecked) and container (selected) use `theme.colors.error`.
41+
* `disabled` and explicit `color`/`uncheckedColor` overrides take
42+
* precedence.
4243
*/
4344
error?: boolean;
4445
/**
@@ -49,9 +50,19 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
4950
* testID to be used on tests.
5051
*/
5152
testID?: string;
53+
/**
54+
* Style for the root element.
55+
*/
56+
style?: StyleProp<ViewStyle>;
5257
};
5358

54-
const ANIMATION_DURATION = 100;
59+
// Spec dimensions (https://m3.material.io/components/checkbox/specs).
60+
const CONTAINER_SIZE = 18;
61+
const CONTAINER_RADIUS = 2;
62+
const OUTLINE_WIDTH = 2;
63+
const STATE_LAYER_SIZE = 40;
64+
const FILL_DURATION = 100;
65+
const CHECK_DURATION = 150;
5566

5667
/**
5768
* Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +95,241 @@ const Checkbox = ({
8495
onPress,
8596
testID,
8697
error,
87-
...rest
98+
color,
99+
uncheckedColor,
100+
style,
88101
}: Props) => {
89102
const theme = useInternalTheme(themeOverrides);
90-
const { current: scaleAnim } = React.useRef<Animated.Value>(
91-
new Animated.Value(1)
92-
);
93-
const isFirstRendering = React.useRef<boolean>(true);
103+
const [hovered, setHovered] = React.useState(false);
104+
const [pressed, setPressed] = React.useState(false);
105+
106+
const selected = status === 'checked' || status === 'indeterminate';
94107

95108
const {
96109
animation: { scale },
97110
} = theme;
98111

112+
// 0 = unselected (outline only), 1 = selected (filled + drawn icon).
113+
const fillAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
114+
const checkAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
115+
const firstRender = React.useRef(true);
116+
99117
React.useEffect(() => {
100-
// Do not run animation on very first rendering
101-
if (isFirstRendering.current) {
102-
isFirstRendering.current = false;
118+
if (firstRender.current) {
119+
firstRender.current = false;
103120
return;
104121
}
122+
Animated.timing(fillAnim, {
123+
toValue: selected ? 1 : 0,
124+
duration: FILL_DURATION * scale,
125+
useNativeDriver: true,
126+
}).start();
127+
Animated.timing(checkAnim, {
128+
toValue: selected ? 1 : 0,
129+
duration: CHECK_DURATION * scale,
130+
useNativeDriver: false,
131+
}).start();
132+
}, [selected, fillAnim, checkAnim, scale]);
105133

106-
const checked = status === 'checked';
107-
108-
Animated.sequence([
109-
Animated.timing(scaleAnim, {
110-
toValue: 0.85,
111-
duration: checked ? ANIMATION_DURATION * scale : 0,
112-
useNativeDriver: false,
113-
}),
114-
Animated.timing(scaleAnim, {
115-
toValue: 1,
116-
duration: checked
117-
? ANIMATION_DURATION * scale
118-
: ANIMATION_DURATION * scale * 1.75,
119-
useNativeDriver: false,
120-
}),
121-
]).start();
122-
}, [status, scaleAnim, scale]);
123-
124-
const checked = status === 'checked';
125-
const indeterminate = status === 'indeterminate';
126-
127-
const { selectionControlColor, selectionControlOpacity } =
128-
getSelectionControlColor({
129-
theme,
130-
disabled,
131-
checked,
132-
customColor: rest.color,
133-
customUncheckedColor: rest.uncheckedColor,
134-
error,
135-
});
136-
137-
const borderWidth = scaleAnim.interpolate({
138-
inputRange: [0.8, 1],
139-
outputRange: [7, 0],
134+
const visual = getSelectionVisualState({
135+
theme,
136+
selected,
137+
disabled,
138+
hovered,
139+
140+
pressed,
141+
error,
142+
customColor: color,
143+
customUncheckedColor: uncheckedColor,
144+
});
145+
146+
// Outline fades out as fill fades in (and vice versa).
147+
const outlineOpacity = fillAnim.interpolate({
148+
inputRange: [0, 1],
149+
outputRange: [1, 0],
140150
});
141151

142-
const icon = indeterminate
143-
? 'minus-box'
144-
: checked
145-
? 'checkbox-marked'
146-
: 'checkbox-blank-outline';
152+
// Remember which glyph to render so the reveal-mask can still collapse
153+
// when transitioning back to 'unchecked' (selected becomes false, but
154+
// we keep showing the previous glyph until checkAnim hits 0).
155+
const lastGlyph = React.useRef<'check' | 'indeterminate'>('check');
156+
if (status === 'checked') lastGlyph.current = 'check';
157+
else if (status === 'indeterminate') lastGlyph.current = 'indeterminate';
158+
const showIndeterminate = lastGlyph.current === 'indeterminate';
147159

148160
return (
149-
<TouchableRipple
150-
{...rest}
151-
borderless
161+
<Pressable
152162
onPress={onPress}
163+
onHoverIn={() => setHovered(true)}
164+
onHoverOut={() => setHovered(false)}
165+
onPressIn={() => setPressed(true)}
166+
onPressOut={() => setPressed(false)}
153167
disabled={disabled}
154168
accessibilityRole="checkbox"
155-
accessibilityState={{ disabled, checked }}
169+
accessibilityState={{ disabled: !!disabled, checked: status === 'checked' }}
156170
accessibilityLiveRegion="polite"
157-
style={styles.container}
158171
testID={testID}
159-
theme={theme}
172+
style={[styles.tapTarget, style]}
160173
>
161-
<Animated.View
162-
style={{
163-
transform: [{ scale: scaleAnim }],
164-
opacity: selectionControlOpacity,
165-
}}
166-
>
167-
<MaterialCommunityIcon
168-
allowFontScaling={false}
169-
name={icon}
170-
size={24}
171-
color={selectionControlColor}
172-
direction="ltr"
174+
<View pointerEvents="none" style={styles.tapTargetInner}>
175+
<View
176+
style={[
177+
styles.stateLayer,
178+
{
179+
backgroundColor: visual.stateLayerColor,
180+
opacity: visual.stateLayerOpacity,
181+
},
182+
]}
173183
/>
174-
<View style={[StyleSheet.absoluteFill, styles.fillContainer]}>
184+
185+
<View style={[styles.container, { opacity: visual.containerOpacity }]}>
175186
<Animated.View
187+
pointerEvents="none"
188+
style={[
189+
styles.outline,
190+
{
191+
borderColor: visual.outlineColor,
192+
opacity: outlineOpacity,
193+
},
194+
]}
195+
/>
196+
<Animated.View
197+
pointerEvents="none"
176198
style={[
177199
styles.fill,
178-
{ borderColor: selectionControlColor },
179-
{ borderWidth },
200+
{
201+
backgroundColor: visual.containerColor,
202+
opacity: fillAnim,
203+
},
180204
]}
181205
/>
206+
{showIndeterminate ? (
207+
<Animated.View
208+
style={[
209+
styles.checkmarkMask,
210+
{
211+
width: checkAnim.interpolate({
212+
inputRange: [0, 1],
213+
outputRange: [0, CONTAINER_SIZE],
214+
}),
215+
opacity: checkAnim,
216+
},
217+
]}
218+
>
219+
<View style={styles.checkmarkContent}>
220+
<View
221+
style={[styles.dash, { backgroundColor: visual.iconColor }]}
222+
/>
223+
</View>
224+
</Animated.View>
225+
) : (
226+
<Checkmark color={visual.iconColor} progress={checkAnim} />
227+
)}
182228
</View>
183-
</Animated.View>
184-
</TouchableRipple>
229+
</View>
230+
</Pressable>
231+
);
232+
};
233+
234+
/**
235+
* Reveal-mask checkmark: a static L-shape (borderLeftWidth +
236+
* borderBottomWidth rotated -45deg) inside a left-anchored View whose
237+
* width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
238+
* left-to-right, approximating Compose Material3's stroke-fraction
239+
* animation without an SVG dependency.
240+
*/
241+
const Checkmark = ({
242+
color,
243+
progress,
244+
}: {
245+
color: ColorValue;
246+
progress: Animated.Value;
247+
}) => {
248+
const maskWidth = progress.interpolate({
249+
inputRange: [0, 1],
250+
outputRange: [0, CONTAINER_SIZE],
251+
});
252+
return (
253+
<Animated.View
254+
style={[styles.checkmarkMask, { width: maskWidth, opacity: progress }]}
255+
>
256+
<View style={styles.checkmarkContent}>
257+
<View style={[styles.checkmarkGlyph, { borderColor: color }]} />
258+
</View>
259+
</Animated.View>
185260
);
186261
};
187262

188263
const styles = StyleSheet.create({
189-
container: {
190-
borderRadius: 18,
191-
width: 36,
192-
height: 36,
193-
padding: 6,
264+
tapTarget: {
265+
width: STATE_LAYER_SIZE,
266+
height: STATE_LAYER_SIZE,
267+
alignItems: 'center',
268+
justifyContent: 'center',
269+
},
270+
tapTargetInner: {
271+
width: STATE_LAYER_SIZE,
272+
height: STATE_LAYER_SIZE,
273+
alignItems: 'center',
274+
justifyContent: 'center',
194275
},
195-
fillContainer: {
276+
stateLayer: {
277+
position: 'absolute',
278+
top: 0,
279+
left: 0,
280+
width: STATE_LAYER_SIZE,
281+
height: STATE_LAYER_SIZE,
282+
borderRadius: STATE_LAYER_SIZE / 2,
283+
},
284+
container: {
285+
width: CONTAINER_SIZE,
286+
height: CONTAINER_SIZE,
287+
borderRadius: CONTAINER_RADIUS,
196288
alignItems: 'center',
197289
justifyContent: 'center',
290+
overflow: 'hidden',
198291
},
199292
fill: {
200-
height: 14,
201-
width: 14,
293+
position: 'absolute',
294+
top: 0,
295+
left: 0,
296+
right: 0,
297+
bottom: 0,
298+
borderRadius: CONTAINER_RADIUS,
299+
},
300+
outline: {
301+
position: 'absolute',
302+
top: 0,
303+
left: 0,
304+
right: 0,
305+
bottom: 0,
306+
borderWidth: OUTLINE_WIDTH,
307+
borderRadius: CONTAINER_RADIUS,
308+
},
309+
dash: {
310+
width: 10,
311+
height: 2,
312+
borderRadius: 1,
313+
},
314+
checkmarkMask: {
315+
position: 'absolute',
316+
left: 0,
317+
top: 0,
318+
height: CONTAINER_SIZE,
319+
overflow: 'hidden',
320+
},
321+
checkmarkContent: {
322+
width: CONTAINER_SIZE,
323+
height: CONTAINER_SIZE,
324+
alignItems: 'center',
325+
justifyContent: 'center',
326+
},
327+
checkmarkGlyph: {
328+
width: 11,
329+
height: 6,
330+
borderLeftWidth: 2,
331+
borderBottomWidth: 2,
332+
transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }],
202333
},
203334
});
204335

src/components/Checkbox/CheckboxItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ const CheckboxItem = ({
157157
accessibilityLabel={accessibilityLabel}
158158
accessibilityRole="checkbox"
159159
accessibilityState={{
160-
checked: status === 'checked',
160+
checked: status === 'indeterminate' ? 'mixed' : status === 'checked',
161161
disabled,
162162
}}
163163
onPress={onPress}

0 commit comments

Comments
 (0)