@@ -3,17 +3,17 @@ 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' ;
1515
16- export type Props = $RemoveChildren < typeof TouchableRipple > & {
16+ export type Props = {
1717 /**
1818 * Status of checkbox.
1919 */
@@ -36,9 +36,9 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
3636 color ?: ColorValue ;
3737 /**
3838 * 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.
39+ * (unchecked) and container (selected ) use `theme.colors.error`.
40+ * `disabled` and explicit `color`/`uncheckedColor` overrides take
41+ * precedence.
4242 */
4343 error ?: boolean ;
4444 /**
@@ -51,7 +51,14 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
5151 testID ?: string ;
5252} ;
5353
54- const ANIMATION_DURATION = 100 ;
54+ // Spec dimensions (https://m3.material.io/components/checkbox/specs).
55+ const CONTAINER_SIZE = 18 ;
56+ const CONTAINER_RADIUS = 2 ;
57+ const OUTLINE_WIDTH = 2 ;
58+ const STATE_LAYER_SIZE = 40 ;
59+ const FILL_DURATION = 100 ;
60+ const CHECK_DURATION = 150 ;
61+
5562
5663/**
5764 * Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +91,234 @@ const Checkbox = ({
8491 onPress,
8592 testID,
8693 error,
87- ...rest
94+ color,
95+ uncheckedColor,
8896} : Props ) => {
8997 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 ) ;
98+ const focusVisible = false ;
99+ const [ hovered , setHovered ] = React . useState ( false ) ;
100+ const [ pressed , setPressed ] = React . useState ( false ) ;
101+
102+ const selected = status === 'checked' || status === 'indeterminate' ;
94103
95104 const {
96105 animation : { scale } ,
97106 } = theme ;
98107
108+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
109+ const fillAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
110+ const checkAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
111+ const firstRender = React . useRef ( true ) ;
112+
99113 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
114+ if ( firstRender . current ) {
115+ firstRender . current = false ;
103116 return ;
104117 }
118+ Animated . timing ( fillAnim , {
119+ toValue : selected ? 1 : 0 ,
120+ duration : FILL_DURATION * scale ,
121+ useNativeDriver : true ,
122+ } ) . start ( ) ;
123+ Animated . timing ( checkAnim , {
124+ toValue : selected ? 1 : 0 ,
125+ duration : CHECK_DURATION * scale ,
126+ useNativeDriver : false ,
127+ } ) . start ( ) ;
128+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
105129
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 ] ,
130+ const visual = getSelectionVisualState ( {
131+ theme,
132+ selected,
133+ disabled,
134+ hovered,
135+ focused : false ,
136+ pressed,
137+ error,
138+ customColor : color ,
139+ customUncheckedColor : uncheckedColor ,
140140 } ) ;
141141
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
142+ // Outline fades out as fill fades in (and vice versa).
143+ const outlineOpacity = fillAnim . interpolate ( {
144+ inputRange : [ 0 , 1 ] ,
145+ outputRange : [ 1 , 0 ] ,
146+ } ) ;
147+
148+ // Remember which glyph to render so the reveal-mask can still collapse
149+ // when transitioning back to 'unchecked' (selected becomes false, but
150+ // we keep showing the previous glyph until checkAnim hits 0).
151+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
152+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
153+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
154+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147155
148156 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
157+ < Pressable
152158 onPress = { onPress }
159+ onHoverIn = { ( ) => setHovered ( true ) }
160+ onHoverOut = { ( ) => setHovered ( false ) }
161+ onPressIn = { ( ) => setPressed ( true ) }
162+ onPressOut = { ( ) => setPressed ( false ) }
153163 disabled = { disabled }
154164 accessibilityRole = "checkbox"
155- accessibilityState = { { disabled, checked } }
165+ accessibilityState = { { disabled, checked : status === 'checked' } }
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- } }
166- >
167- < MaterialCommunityIcon
168- allowFontScaling = { false }
169- name = { icon }
170- size = { 24 }
171- color = { selectionControlColor }
172- direction = "ltr"
170+ < View pointerEvents = "none" style = { styles . tapTargetInner } >
171+ < View
172+ style = { [
173+ styles . stateLayer ,
174+ {
175+ backgroundColor : visual . stateLayerColor ,
176+ opacity : visual . stateLayerOpacity ,
177+ } ,
178+ ] }
173179 />
174- < View style = { [ StyleSheet . absoluteFill , styles . fillContainer ] } >
180+
181+ < View style = { [ styles . container , { opacity : visual . containerOpacity } ] } >
182+ < Animated . View
183+ pointerEvents = "none"
184+ style = { [
185+ styles . outline ,
186+ {
187+ borderColor : visual . outlineColor ,
188+ opacity : outlineOpacity ,
189+ } ,
190+ ] }
191+ />
175192 < Animated . View
193+ pointerEvents = "none"
176194 style = { [
177195 styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
196+ {
197+ backgroundColor : visual . containerColor ,
198+ opacity : fillAnim ,
199+ } ,
180200 ] }
181201 />
202+ { showIndeterminate ? (
203+ < Animated . View
204+ style = { [
205+ styles . checkmarkMask ,
206+ {
207+ width : checkAnim . interpolate ( { inputRange : [ 0 , 1 ] , outputRange : [ 0 , CONTAINER_SIZE ] } ) ,
208+ opacity : checkAnim ,
209+ } ,
210+ ] }
211+ >
212+ < View style = { styles . checkmarkContent } >
213+ < View style = { [ styles . dash , { backgroundColor : visual . iconColor } ] } />
214+ </ View >
215+ </ Animated . View >
216+ ) : (
217+ < Checkmark color = { visual . iconColor } progress = { checkAnim } />
218+ ) }
182219 </ View >
183- </ Animated . View >
184- </ TouchableRipple >
220+ </ View >
221+ </ Pressable >
222+ ) ;
223+ } ;
224+
225+ /**
226+ * Reveal-mask checkmark: a static L-shape (borderLeftWidth +
227+ * borderBottomWidth rotated -45deg) inside a left-anchored View whose
228+ * width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
229+ * left-to-right, approximating Compose Material3's stroke-fraction
230+ * animation without an SVG dependency.
231+ */
232+ const Checkmark = ( {
233+ color,
234+ progress,
235+ } : {
236+ color : ColorValue ;
237+ progress : Animated . Value ;
238+ } ) => {
239+ const maskWidth = progress . interpolate ( {
240+ inputRange : [ 0 , 1 ] ,
241+ outputRange : [ 0 , CONTAINER_SIZE ] ,
242+ } ) ;
243+ return (
244+ < Animated . View style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] } >
245+ < View style = { styles . checkmarkContent } >
246+ < View style = { [ styles . checkmarkGlyph , { borderColor : color } ] } />
247+ </ View >
248+ </ Animated . View >
185249 ) ;
186250} ;
187251
188252const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
253+ tapTarget : {
254+ width : STATE_LAYER_SIZE ,
255+ height : STATE_LAYER_SIZE ,
256+ alignItems : 'center' ,
257+ justifyContent : 'center' ,
194258 } ,
195- fillContainer : {
259+ tapTargetInner : {
260+ width : STATE_LAYER_SIZE ,
261+ height : STATE_LAYER_SIZE ,
262+ alignItems : 'center' ,
263+ justifyContent : 'center' ,
264+ } ,
265+ stateLayer : {
266+ position : 'absolute' ,
267+ top : 0 ,
268+ left : 0 ,
269+ width : STATE_LAYER_SIZE ,
270+ height : STATE_LAYER_SIZE ,
271+ borderRadius : STATE_LAYER_SIZE / 2 ,
272+ } ,
273+ container : {
274+ width : CONTAINER_SIZE ,
275+ height : CONTAINER_SIZE ,
276+ borderRadius : CONTAINER_RADIUS ,
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 : CONTAINER_RADIUS ,
288+ } ,
289+ outline : {
290+ position : 'absolute' ,
291+ top : 0 ,
292+ left : 0 ,
293+ right : 0 ,
294+ bottom : 0 ,
295+ borderWidth : OUTLINE_WIDTH ,
296+ borderRadius : CONTAINER_RADIUS ,
297+ } ,
298+ dash : {
299+ width : 10 ,
300+ height : 2 ,
301+ borderRadius : 1 ,
302+ } ,
303+ checkmarkMask : {
304+ position : 'absolute' ,
305+ left : 0 ,
306+ top : 0 ,
307+ height : CONTAINER_SIZE ,
308+ overflow : 'hidden' ,
309+ } ,
310+ checkmarkContent : {
311+ width : CONTAINER_SIZE ,
312+ height : CONTAINER_SIZE ,
313+ alignItems : 'center' ,
314+ justifyContent : 'center' ,
315+ } ,
316+ checkmarkGlyph : {
317+ width : 11 ,
318+ height : 6 ,
319+ borderLeftWidth : 2 ,
320+ borderBottomWidth : 2 ,
321+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202322 } ,
203323} ) ;
204324
0 commit comments