@@ -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' ;
1113import { 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
188249const 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