@@ -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' ;
1112import { 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,261 @@ 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' ;
94105
95106 const {
96107 animation : { scale } ,
97108 } = theme ;
98109
110+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
111+ const fillAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
112+ const checkAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
113+ const firstRender = React . useRef ( true ) ;
114+
99115 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
116+ if ( firstRender . current ) {
117+ firstRender . current = false ;
103118 return ;
104119 }
120+ Animated . timing ( fillAnim , {
121+ toValue : selected ? 1 : 0 ,
122+ duration : FILL_DURATION * scale ,
123+ useNativeDriver : true ,
124+ } ) . start ( ) ;
125+ Animated . timing ( checkAnim , {
126+ toValue : selected ? 1 : 0 ,
127+ duration : CHECK_DURATION * scale ,
128+ useNativeDriver : false ,
129+ } ) . start ( ) ;
130+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
131+
132+ const visual = getSelectionVisualState ( {
133+ theme,
134+ selected,
135+ disabled,
136+ hovered,
137+ focused : focusVisible ,
138+ pressed,
139+ error,
140+ customColor : color ,
141+ customUncheckedColor : uncheckedColor ,
142+ } ) ;
105143
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 ] ,
144+ // Outline fades out as fill fades in (and vice versa).
145+ const outlineOpacity = fillAnim . interpolate ( {
146+ inputRange : [ 0 , 1 ] ,
147+ outputRange : [ 1 , 0 ] ,
140148 } ) ;
141149
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
150+ // Remember which glyph to render so the reveal-mask can still collapse
151+ // when transitioning back to 'unchecked' (selected becomes false, but
152+ // we keep showing the previous glyph until checkAnim hits 0).
153+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
154+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
155+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
156+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147157
148158 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
159+ < Pressable
152160 onPress = { onPress }
161+ onFocus = { onFocus }
162+ onBlur = { onBlur }
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, checked : indeterminate ? 'mixed' : status === 'checked' } }
156170 accessibilityLiveRegion = "polite"
157- style = { styles . container }
158171 testID = { testID }
159- theme = { theme }
172+ style = { styles . tapTarget }
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+ { focusVisible && ! disabled ? (
185+ < View
186+ style = { [
187+ styles . focusRing ,
188+ {
189+ borderColor : theme . colors . secondary ,
190+ borderWidth : focusIndicator . thickness ,
191+ } ,
192+ ] }
193+ />
194+ ) : null }
195+ < View style = { [ styles . container , { opacity : visual . containerOpacity } ] } >
175196 < Animated . View
197+ pointerEvents = "none"
198+ style = { [
199+ styles . outline ,
200+ {
201+ borderColor : visual . outlineColor ,
202+ opacity : outlineOpacity ,
203+ } ,
204+ ] }
205+ />
206+ < Animated . View
207+ pointerEvents = "none"
176208 style = { [
177209 styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
210+ {
211+ backgroundColor : visual . containerColor ,
212+ opacity : fillAnim ,
213+ } ,
180214 ] }
181215 />
216+ { showIndeterminate ? (
217+ < Animated . View
218+ style = { [
219+ styles . checkmarkMask ,
220+ {
221+ width : checkAnim . interpolate ( {
222+ inputRange : [ 0 , 1 ] ,
223+ outputRange : [ 0 , CONTAINER_SIZE ] ,
224+ } ) ,
225+ opacity : checkAnim ,
226+ } ,
227+ ] }
228+ >
229+ < View style = { styles . checkmarkContent } >
230+ < View
231+ style = { [ styles . dash , { backgroundColor : visual . iconColor } ] }
232+ />
233+ </ View >
234+ </ Animated . View >
235+ ) : (
236+ < Checkmark color = { visual . iconColor } progress = { checkAnim } />
237+ ) }
182238 </ View >
183- </ Animated . View >
184- </ TouchableRipple >
239+ </ View >
240+ </ Pressable >
241+ ) ;
242+ } ;
243+
244+ /**
245+ * Reveal-mask checkmark: a static L-shape (borderLeftWidth +
246+ * borderBottomWidth rotated -45deg) inside a left-anchored View whose
247+ * width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
248+ * left-to-right, approximating Compose Material3's stroke-fraction
249+ * animation without an SVG dependency.
250+ */
251+ const Checkmark = ( {
252+ color,
253+ progress,
254+ } : {
255+ color : ColorValue ;
256+ progress : Animated . Value ;
257+ } ) => {
258+ const maskWidth = progress . interpolate ( {
259+ inputRange : [ 0 , 1 ] ,
260+ outputRange : [ 0 , CONTAINER_SIZE ] ,
261+ } ) ;
262+ return (
263+ < Animated . View
264+ style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] }
265+ >
266+ < View style = { styles . checkmarkContent } >
267+ < View style = { [ styles . checkmarkGlyph , { borderColor : color } ] } />
268+ </ View >
269+ </ Animated . View >
185270 ) ;
186271} ;
187272
188273const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
274+ tapTarget : {
275+ width : STATE_LAYER_SIZE ,
276+ height : STATE_LAYER_SIZE ,
277+ alignItems : 'center' ,
278+ justifyContent : 'center' ,
279+ } ,
280+ tapTargetInner : {
281+ width : STATE_LAYER_SIZE ,
282+ height : STATE_LAYER_SIZE ,
283+ alignItems : 'center' ,
284+ justifyContent : 'center' ,
285+ } ,
286+ stateLayer : {
287+ position : 'absolute' ,
288+ top : 0 ,
289+ left : 0 ,
290+ width : STATE_LAYER_SIZE ,
291+ height : STATE_LAYER_SIZE ,
292+ borderRadius : STATE_LAYER_SIZE / 2 ,
194293 } ,
195- fillContainer : {
294+ focusRing : {
295+ position : 'absolute' ,
296+ top : - focusIndicator . outerOffset ,
297+ left : - focusIndicator . outerOffset ,
298+ width : STATE_LAYER_SIZE + focusIndicator . outerOffset * 2 ,
299+ height : STATE_LAYER_SIZE + focusIndicator . outerOffset * 2 ,
300+ borderRadius : ( STATE_LAYER_SIZE + focusIndicator . outerOffset * 2 ) / 2 ,
301+ } ,
302+ container : {
303+ width : CONTAINER_SIZE ,
304+ height : CONTAINER_SIZE ,
305+ borderRadius : CONTAINER_RADIUS ,
196306 alignItems : 'center' ,
197307 justifyContent : 'center' ,
308+ overflow : 'hidden' ,
198309 } ,
199310 fill : {
200- height : 14 ,
201- width : 14 ,
311+ position : 'absolute' ,
312+ top : 0 ,
313+ left : 0 ,
314+ right : 0 ,
315+ bottom : 0 ,
316+ borderRadius : CONTAINER_RADIUS ,
317+ } ,
318+ outline : {
319+ position : 'absolute' ,
320+ top : 0 ,
321+ left : 0 ,
322+ right : 0 ,
323+ bottom : 0 ,
324+ borderWidth : OUTLINE_WIDTH ,
325+ borderRadius : CONTAINER_RADIUS ,
326+ } ,
327+ dash : {
328+ width : 10 ,
329+ height : 2 ,
330+ borderRadius : 1 ,
331+ } ,
332+ checkmarkMask : {
333+ position : 'absolute' ,
334+ left : 0 ,
335+ top : 0 ,
336+ height : CONTAINER_SIZE ,
337+ overflow : 'hidden' ,
338+ } ,
339+ checkmarkContent : {
340+ width : CONTAINER_SIZE ,
341+ height : CONTAINER_SIZE ,
342+ alignItems : 'center' ,
343+ justifyContent : 'center' ,
344+ } ,
345+ checkmarkGlyph : {
346+ width : 11 ,
347+ height : 6 ,
348+ borderLeftWidth : 2 ,
349+ borderBottomWidth : 2 ,
350+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202351 } ,
203352} ) ;
204353
0 commit comments