Skip to content

Commit 2288d5d

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 2288d5d

5 files changed

Lines changed: 1355 additions & 771 deletions

File tree

‎src/components/Checkbox/Checkbox.tsx‎

Lines changed: 212 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,20 @@ 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 { CheckboxTokens } from './tokens';
12+
import { getSelectionVisualState } from './utils';
1113
import { useInternalTheme } from '../../core/theming';
12-
import type { $RemoveChildren, ThemeProp } from '../../types';
13-
import MaterialCommunityIcon from '../MaterialCommunityIcon';
14-
import TouchableRipple from '../TouchableRipple/TouchableRipple';
14+
import { tokens } from '../../theme/tokens';
15+
import type { ThemeProp } from '../../types';
16+
import useAnimatedValue from '../../utils/useAnimatedValue';
17+
import { useFocusVisible } from '../../utils/useFocusVisible';
1518

16-
export type Props = $RemoveChildren<typeof TouchableRipple> & {
19+
export type Props = {
1720
/**
1821
* Status of checkbox.
1922
*/
@@ -36,9 +39,9 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
3639
color?: ColorValue;
3740
/**
3841
* 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.
42+
* (unchecked) and container (selected) use `theme.colors.error`.
43+
* `disabled` and explicit `color`/`uncheckedColor` overrides take
44+
* precedence.
4245
*/
4346
error?: boolean;
4447
/**
@@ -51,7 +54,7 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
5154
testID?: string;
5255
};
5356

54-
const ANIMATION_DURATION = 100;
57+
const { focusIndicator } = tokens.md.sys.state;
5558

5659
/**
5760
* Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +87,238 @@ const Checkbox = ({
8487
onPress,
8588
testID,
8689
error,
87-
...rest
90+
color,
91+
uncheckedColor,
8892
}: Props) => {
8993
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);
94+
const { focusVisible, onFocus, onBlur } = useFocusVisible();
95+
const [hovered, setHovered] = React.useState(false);
96+
const [pressed, setPressed] = React.useState(false);
97+
98+
const selected = status === 'checked' || status === 'indeterminate';
9499

95100
const {
96101
animation: { scale },
97102
} = theme;
98103

104+
// 0 = unselected (outline only), 1 = selected (filled + drawn icon).
105+
const fillAnim = useAnimatedValue(selected ? 1 : 0);
106+
const checkAnim = useAnimatedValue(selected ? 1 : 0);
107+
const firstRender = React.useRef(true);
108+
99109
React.useEffect(() => {
100-
// Do not run animation on very first rendering
101-
if (isFirstRendering.current) {
102-
isFirstRendering.current = false;
110+
if (firstRender.current) {
111+
firstRender.current = false;
103112
return;
104113
}
114+
Animated.timing(fillAnim, {
115+
toValue: selected ? 1 : 0,
116+
duration: CheckboxTokens.fillDuration * scale,
117+
useNativeDriver: true,
118+
}).start();
119+
Animated.timing(checkAnim, {
120+
toValue: selected ? 1 : 0,
121+
duration: CheckboxTokens.checkDuration * scale,
122+
useNativeDriver: false,
123+
}).start();
124+
}, [selected, fillAnim, checkAnim, scale]);
105125

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],
126+
const visual = getSelectionVisualState({
127+
theme,
128+
selected,
129+
disabled,
130+
hovered,
131+
pressed,
132+
error,
133+
customColor: color,
134+
customUncheckedColor: uncheckedColor,
140135
});
141136

142-
const icon = indeterminate
143-
? 'minus-box'
144-
: checked
145-
? 'checkbox-marked'
146-
: 'checkbox-blank-outline';
137+
// Outline fades out as fill fades in (and vice versa).
138+
const outlineOpacity = fillAnim.interpolate({
139+
inputRange: [0, 1],
140+
outputRange: [1, 0],
141+
});
142+
143+
// Remember which glyph to render so the reveal-mask can still collapse
144+
// when transitioning back to 'unchecked' (selected becomes false, but
145+
// we keep showing the previous glyph until checkAnim hits 0).
146+
const lastGlyph = React.useRef<'check' | 'indeterminate'>('check');
147+
if (status === 'checked') lastGlyph.current = 'check';
148+
else if (status === 'indeterminate') lastGlyph.current = 'indeterminate';
149+
const showIndeterminate = lastGlyph.current === 'indeterminate';
147150

148151
return (
149-
<TouchableRipple
150-
{...rest}
151-
borderless
152+
<Pressable
152153
onPress={onPress}
154+
onFocus={onFocus}
155+
onBlur={onBlur}
156+
onHoverIn={() => setHovered(true)}
157+
onHoverOut={() => setHovered(false)}
158+
onPressIn={() => setPressed(true)}
159+
onPressOut={() => setPressed(false)}
153160
disabled={disabled}
154161
accessibilityRole="checkbox"
155-
accessibilityState={{ disabled, checked }}
162+
accessibilityState={{
163+
disabled,
164+
checked: status === 'indeterminate' ? 'mixed' : status === 'checked',
165+
}}
156166
accessibilityLiveRegion="polite"
157-
style={styles.container}
158167
testID={testID}
159-
theme={theme}
168+
style={styles.tapTarget}
160169
>
161-
<Animated.View
162-
style={{
163-
transform: [{ scale: scaleAnim }],
164-
opacity: selectionControlOpacity,
165-
}}
170+
<View
171+
pointerEvents="none"
172+
style={[
173+
styles.stateLayer,
174+
{
175+
backgroundColor: visual.stateLayerColor,
176+
opacity: visual.stateLayerOpacity,
177+
},
178+
]}
179+
/>
180+
{focusVisible && !disabled ? (
181+
<View
182+
pointerEvents="none"
183+
style={[
184+
styles.focusRing,
185+
{
186+
borderColor: theme.colors[CheckboxTokens.focusIndicatorColor],
187+
borderWidth: focusIndicator.thickness,
188+
},
189+
]}
190+
/>
191+
) : null}
192+
<View
193+
pointerEvents="none"
194+
style={[styles.container, { opacity: visual.containerOpacity }]}
166195
>
167-
<MaterialCommunityIcon
168-
allowFontScaling={false}
169-
name={icon}
170-
size={24}
171-
color={selectionControlColor}
172-
direction="ltr"
196+
<Animated.View
197+
style={[
198+
styles.outline,
199+
{ borderColor: visual.outlineColor, opacity: outlineOpacity },
200+
]}
201+
/>
202+
<Animated.View
203+
style={[
204+
styles.fill,
205+
{ backgroundColor: visual.containerColor, opacity: fillAnim },
206+
]}
173207
/>
174-
<View style={[StyleSheet.absoluteFill, styles.fillContainer]}>
175-
<Animated.View
176-
style={[
177-
styles.fill,
178-
{ borderColor: selectionControlColor },
179-
{ borderWidth },
180-
]}
181-
/>
182-
</View>
183-
</Animated.View>
184-
</TouchableRipple>
208+
<RevealMask progress={checkAnim}>
209+
{showIndeterminate ? (
210+
<View
211+
style={[styles.dash, { backgroundColor: visual.iconColor }]}
212+
/>
213+
) : (
214+
<View
215+
style={[styles.checkmarkGlyph, { borderColor: visual.iconColor }]}
216+
/>
217+
)}
218+
</RevealMask>
219+
</View>
220+
</Pressable>
221+
);
222+
};
223+
224+
/**
225+
* Reveal-mask wrapper: animates its width from 0 -> containerSize so the
226+
* child glyph "draws in" left-to-right, approximating Compose Material3's
227+
* stroke-fraction animation without an SVG dependency.
228+
*/
229+
const RevealMask = ({
230+
progress,
231+
children,
232+
}: {
233+
progress: Animated.Value;
234+
children: React.ReactNode;
235+
}) => {
236+
const maskWidth = progress.interpolate({
237+
inputRange: [0, 1],
238+
outputRange: [0, CheckboxTokens.containerSize],
239+
});
240+
return (
241+
<Animated.View
242+
style={[styles.checkmarkMask, { width: maskWidth, opacity: progress }]}
243+
>
244+
<View style={styles.checkmarkContent}>{children}</View>
245+
</Animated.View>
185246
);
186247
};
187248

188249
const styles = StyleSheet.create({
189-
container: {
190-
borderRadius: 18,
191-
width: 36,
192-
height: 36,
193-
padding: 6,
250+
tapTarget: {
251+
width: CheckboxTokens.stateLayerSize,
252+
height: CheckboxTokens.stateLayerSize,
253+
alignItems: 'center',
254+
justifyContent: 'center',
194255
},
195-
fillContainer: {
256+
stateLayer: {
257+
position: 'absolute',
258+
top: 0,
259+
left: 0,
260+
width: CheckboxTokens.stateLayerSize,
261+
height: CheckboxTokens.stateLayerSize,
262+
borderRadius: CheckboxTokens.stateLayerSize / 2,
263+
},
264+
focusRing: {
265+
position: 'absolute',
266+
top: -focusIndicator.outerOffset,
267+
left: -focusIndicator.outerOffset,
268+
width: CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2,
269+
height: CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2,
270+
borderRadius:
271+
(CheckboxTokens.stateLayerSize + focusIndicator.outerOffset * 2) / 2,
272+
},
273+
container: {
274+
width: CheckboxTokens.containerSize,
275+
height: CheckboxTokens.containerSize,
276+
borderRadius: CheckboxTokens.containerRadius,
196277
alignItems: 'center',
197278
justifyContent: 'center',
279+
overflow: 'hidden',
198280
},
199281
fill: {
200-
height: 14,
201-
width: 14,
282+
position: 'absolute',
283+
top: 0,
284+
left: 0,
285+
right: 0,
286+
bottom: 0,
287+
borderRadius: CheckboxTokens.containerRadius,
288+
},
289+
outline: {
290+
position: 'absolute',
291+
top: 0,
292+
left: 0,
293+
right: 0,
294+
bottom: 0,
295+
borderWidth: CheckboxTokens.outlineWidth,
296+
borderRadius: CheckboxTokens.containerRadius,
297+
},
298+
dash: {
299+
width: CheckboxTokens.indeterminateWidth,
300+
height: CheckboxTokens.indeterminateHeight,
301+
borderRadius: CheckboxTokens.indeterminateRadius,
302+
},
303+
checkmarkMask: {
304+
position: 'absolute',
305+
left: 0,
306+
top: 0,
307+
height: CheckboxTokens.containerSize,
308+
overflow: 'hidden',
309+
},
310+
checkmarkContent: {
311+
width: CheckboxTokens.containerSize,
312+
height: CheckboxTokens.containerSize,
313+
alignItems: 'center',
314+
justifyContent: 'center',
315+
},
316+
checkmarkGlyph: {
317+
width: CheckboxTokens.checkmarkWidth,
318+
height: CheckboxTokens.checkmarkHeight,
319+
borderLeftWidth: CheckboxTokens.checkmarkStrokeWidth,
320+
borderBottomWidth: CheckboxTokens.checkmarkStrokeWidth,
321+
transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }],
202322
},
203323
});
204324

0 commit comments

Comments
 (0)