Skip to content

Commit 90c056e

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 ee81030 commit 90c056e

4 files changed

Lines changed: 1067 additions & 626 deletions

File tree

src/components/Checkbox/Checkbox.tsx

Lines changed: 201 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@ import {
33
Animated,
44
ColorValue,
55
GestureResponderEvent,
6+
Pressable,
67
StyleSheet,
78
View,
89
} from 'react-native';
910

10-
import { getSelectionControlColor } from './utils';
11+
import { getSelectionVisualState } from './utils';
1112
import { useInternalTheme } from '../../core/theming';
12-
import type { $RemoveChildren, ThemeProp } from '../../types';
13-
import MaterialCommunityIcon from '../MaterialCommunityIcon';
14-
import TouchableRipple from '../TouchableRipple/TouchableRipple';
13+
import { tokens } from '../../theme/tokens';
14+
import type { ThemeProp } from '../../types';
15+
import { useFocusVisible } from '../../utils/useFocusVisible';
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
/**
@@ -51,7 +52,15 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
5152
testID?: string;
5253
};
5354

54-
const ANIMATION_DURATION = 100;
55+
// Spec dimensions (https://m3.material.io/components/checkbox/specs).
56+
const CONTAINER_SIZE = 18;
57+
const CONTAINER_RADIUS = 2;
58+
const OUTLINE_WIDTH = 2;
59+
const STATE_LAYER_SIZE = 40;
60+
const FILL_DURATION = 100;
61+
const CHECK_DURATION = 150;
62+
63+
const { focusIndicator } = tokens.md.sys.state;
5564

5665
/**
5766
* Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +93,230 @@ const Checkbox = ({
8493
onPress,
8594
testID,
8695
error,
87-
...rest
96+
color,
97+
uncheckedColor,
8898
}: Props) => {
8999
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);
100+
const { focusVisible, onFocus, onBlur } = useFocusVisible();
101+
const [hovered, setHovered] = React.useState(false);
102+
const [pressed, setPressed] = React.useState(false);
103+
104+
const selected = status === 'checked' || status === 'indeterminate';
105+
const indeterminate = status === 'indeterminate';
94106

95107
const {
96108
animation: { scale },
97109
} = theme;
98110

111+
// 0 = unselected (outline only), 1 = selected (filled + drawn icon).
112+
const fillAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
113+
const checkAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
114+
const firstRender = React.useRef(true);
115+
99116
React.useEffect(() => {
100-
// Do not run animation on very first rendering
101-
if (isFirstRendering.current) {
102-
isFirstRendering.current = false;
117+
if (firstRender.current) {
118+
firstRender.current = false;
103119
return;
104120
}
105-
106-
const checked = status === 'checked';
107-
108-
Animated.sequence([
109-
Animated.timing(scaleAnim, {
110-
toValue: 0.85,
111-
duration: checked ? ANIMATION_DURATION * scale : 0,
121+
Animated.parallel([
122+
Animated.timing(fillAnim, {
123+
toValue: selected ? 1 : 0,
124+
duration: FILL_DURATION * scale,
112125
useNativeDriver: false,
113126
}),
114-
Animated.timing(scaleAnim, {
115-
toValue: 1,
116-
duration: checked
117-
? ANIMATION_DURATION * scale
118-
: ANIMATION_DURATION * scale * 1.75,
127+
Animated.timing(checkAnim, {
128+
toValue: selected ? 1 : 0,
129+
duration: CHECK_DURATION * scale,
119130
useNativeDriver: false,
120131
}),
121132
]).start();
122-
}, [status, scaleAnim, scale]);
123-
124-
const checked = status === 'checked';
125-
const indeterminate = status === 'indeterminate';
133+
}, [selected, fillAnim, checkAnim, scale]);
126134

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],
135+
const visual = getSelectionVisualState({
136+
theme,
137+
selected,
138+
disabled,
139+
hovered,
140+
focused: focusVisible,
141+
pressed,
142+
error,
143+
customColor: color,
144+
customUncheckedColor: uncheckedColor,
140145
});
141146

142-
const icon = indeterminate
143-
? 'minus-box'
144-
: checked
145-
? 'checkbox-marked'
146-
: 'checkbox-blank-outline';
147-
148147
return (
149-
<TouchableRipple
150-
{...rest}
151-
borderless
148+
<Pressable
152149
onPress={onPress}
150+
onFocus={onFocus}
151+
onBlur={onBlur}
152+
onHoverIn={() => setHovered(true)}
153+
onHoverOut={() => setHovered(false)}
154+
onPressIn={() => setPressed(true)}
155+
onPressOut={() => setPressed(false)}
153156
disabled={disabled}
154157
accessibilityRole="checkbox"
155-
accessibilityState={{ disabled, checked }}
158+
accessibilityState={{ disabled, checked: status === 'checked' }}
156159
accessibilityLiveRegion="polite"
157-
style={styles.container}
158160
testID={testID}
159-
theme={theme}
161+
style={styles.tapTarget}
160162
>
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"
163+
<View pointerEvents="none" style={styles.tapTargetInner}>
164+
<View
165+
style={[
166+
styles.stateLayer,
167+
{
168+
backgroundColor: visual.stateLayerColor,
169+
opacity: visual.stateLayerOpacity,
170+
},
171+
]}
173172
/>
174-
<View style={[StyleSheet.absoluteFill, styles.fillContainer]}>
173+
{focusVisible && !disabled ? (
174+
<View
175+
style={[
176+
styles.focusRing,
177+
{
178+
borderColor: theme.colors.secondary,
179+
borderWidth: focusIndicator.thickness,
180+
},
181+
]}
182+
/>
183+
) : null}
184+
<View
185+
style={[
186+
styles.container,
187+
{
188+
borderColor: visual.outlineColor,
189+
borderWidth: selected ? 0 : OUTLINE_WIDTH,
190+
opacity: visual.containerOpacity,
191+
},
192+
]}
193+
>
175194
<Animated.View
195+
pointerEvents="none"
176196
style={[
177197
styles.fill,
178-
{ borderColor: selectionControlColor },
179-
{ borderWidth },
198+
{
199+
backgroundColor: visual.containerColor,
200+
opacity: fillAnim,
201+
},
180202
]}
181203
/>
204+
{indeterminate ? (
205+
<Animated.View
206+
style={[
207+
styles.checkmarkMask,
208+
{ width: checkAnim.interpolate({ inputRange: [0, 1], outputRange: [0, CONTAINER_SIZE] }) },
209+
]}
210+
>
211+
<View style={styles.checkmarkContent}>
212+
<View style={[styles.dash, { backgroundColor: visual.iconColor }]} />
213+
</View>
214+
</Animated.View>
215+
) : selected ? (
216+
<Checkmark color={visual.iconColor} progress={checkAnim} />
217+
) : null}
182218
</View>
183-
</Animated.View>
184-
</TouchableRipple>
219+
</View>
220+
</Pressable>
221+
);
222+
};
223+
224+
/**
225+
* Reveal-mask checkmark: a static L-shape (borderLeftWidth +
226+
* borderBottomWidth rotated -45deg) inside a left-anchored View whose
227+
* width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
228+
* left-to-right, approximating Compose Material3's stroke-fraction
229+
* animation without an SVG dependency.
230+
*/
231+
const Checkmark = ({
232+
color,
233+
progress,
234+
}: {
235+
color: ColorValue;
236+
progress: Animated.Value;
237+
}) => {
238+
const maskWidth = progress.interpolate({
239+
inputRange: [0, 1],
240+
outputRange: [0, CONTAINER_SIZE],
241+
});
242+
return (
243+
<Animated.View style={[styles.checkmarkMask, { width: maskWidth }]}>
244+
<View style={styles.checkmarkContent}>
245+
<View style={[styles.checkmarkGlyph, { borderColor: color }]} />
246+
</View>
247+
</Animated.View>
185248
);
186249
};
187250

188251
const styles = StyleSheet.create({
189-
container: {
190-
borderRadius: 18,
191-
width: 36,
192-
height: 36,
193-
padding: 6,
252+
tapTarget: {
253+
width: STATE_LAYER_SIZE,
254+
height: STATE_LAYER_SIZE,
255+
alignItems: 'center',
256+
justifyContent: 'center',
257+
},
258+
tapTargetInner: {
259+
width: STATE_LAYER_SIZE,
260+
height: STATE_LAYER_SIZE,
261+
alignItems: 'center',
262+
justifyContent: 'center',
194263
},
195-
fillContainer: {
264+
stateLayer: {
265+
position: 'absolute',
266+
top: 0,
267+
left: 0,
268+
width: STATE_LAYER_SIZE,
269+
height: STATE_LAYER_SIZE,
270+
borderRadius: STATE_LAYER_SIZE / 2,
271+
},
272+
focusRing: {
273+
position: 'absolute',
274+
top: -focusIndicator.outerOffset,
275+
left: -focusIndicator.outerOffset,
276+
width: STATE_LAYER_SIZE + focusIndicator.outerOffset * 2,
277+
height: STATE_LAYER_SIZE + focusIndicator.outerOffset * 2,
278+
borderRadius: (STATE_LAYER_SIZE + focusIndicator.outerOffset * 2) / 2,
279+
},
280+
container: {
281+
width: CONTAINER_SIZE,
282+
height: CONTAINER_SIZE,
283+
borderRadius: CONTAINER_RADIUS,
196284
alignItems: 'center',
197285
justifyContent: 'center',
286+
overflow: 'hidden',
198287
},
199288
fill: {
200-
height: 14,
201-
width: 14,
289+
position: 'absolute',
290+
top: 0,
291+
left: 0,
292+
right: 0,
293+
bottom: 0,
294+
borderRadius: CONTAINER_RADIUS,
295+
},
296+
dash: {
297+
width: 10,
298+
height: 2,
299+
borderRadius: 1,
300+
},
301+
checkmarkMask: {
302+
position: 'absolute',
303+
left: 0,
304+
top: 0,
305+
height: CONTAINER_SIZE,
306+
overflow: 'hidden',
307+
},
308+
checkmarkContent: {
309+
width: CONTAINER_SIZE,
310+
height: CONTAINER_SIZE,
311+
alignItems: 'center',
312+
justifyContent: 'center',
313+
},
314+
checkmarkGlyph: {
315+
width: 8,
316+
height: 4,
317+
borderLeftWidth: 2,
318+
borderBottomWidth: 2,
319+
transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }],
202320
},
203321
});
204322

0 commit comments

Comments
 (0)