@@ -3,17 +3,16 @@ 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 type { ThemeProp } from '../../types' ;
1514
16- export type Props = $RemoveChildren < typeof TouchableRipple > & {
15+ export type Props = {
1716 /**
1817 * Status of checkbox.
1918 */
@@ -36,9 +35,9 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
3635 color ?: ColorValue ;
3736 /**
3837 * 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.
38+ * (unchecked) and container (selected ) use `theme.colors.error`.
39+ * `disabled` and explicit `color`/`uncheckedColor` overrides take
40+ * precedence.
4241 */
4342 error ?: boolean ;
4443 /**
@@ -51,7 +50,13 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
5150 testID ?: string ;
5251} ;
5352
54- const ANIMATION_DURATION = 100 ;
53+ // Spec dimensions (https://m3.material.io/components/checkbox/specs).
54+ const CONTAINER_SIZE = 18 ;
55+ const CONTAINER_RADIUS = 2 ;
56+ const OUTLINE_WIDTH = 2 ;
57+ const STATE_LAYER_SIZE = 40 ;
58+ const FILL_DURATION = 100 ;
59+ const CHECK_DURATION = 150 ;
5560
5661/**
5762 * Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +89,240 @@ const Checkbox = ({
8489 onPress,
8590 testID,
8691 error,
87- ...rest
92+ color,
93+ uncheckedColor,
8894} : Props ) => {
8995 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 ) ;
96+ const [ hovered , setHovered ] = React . useState ( false ) ;
97+ const [ pressed , setPressed ] = React . useState ( false ) ;
98+
99+ const selected = status === 'checked' || status === 'indeterminate' ;
94100
95101 const {
96102 animation : { scale } ,
97103 } = theme ;
98104
105+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
106+ const fillAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
107+ const checkAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
108+ const firstRender = React . useRef ( true ) ;
109+
99110 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
111+ if ( firstRender . current ) {
112+ firstRender . current = false ;
103113 return ;
104114 }
115+ Animated . timing ( fillAnim , {
116+ toValue : selected ? 1 : 0 ,
117+ duration : FILL_DURATION * scale ,
118+ useNativeDriver : true ,
119+ } ) . start ( ) ;
120+ Animated . timing ( checkAnim , {
121+ toValue : selected ? 1 : 0 ,
122+ duration : CHECK_DURATION * scale ,
123+ useNativeDriver : false ,
124+ } ) . start ( ) ;
125+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
105126
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 ] ,
127+ const visual = getSelectionVisualState ( {
128+ theme,
129+ selected,
130+ disabled,
131+ hovered,
132+
133+ pressed,
134+ error,
135+ customColor : color ,
136+ customUncheckedColor : uncheckedColor ,
140137 } ) ;
141138
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
139+ // Outline fades out as fill fades in (and vice versa).
140+ const outlineOpacity = fillAnim . interpolate ( {
141+ inputRange : [ 0 , 1 ] ,
142+ outputRange : [ 1 , 0 ] ,
143+ } ) ;
144+
145+ // Remember which glyph to render so the reveal-mask can still collapse
146+ // when transitioning back to 'unchecked' (selected becomes false, but
147+ // we keep showing the previous glyph until checkAnim hits 0).
148+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
149+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
150+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
151+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147152
148153 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
154+ < Pressable
152155 onPress = { onPress }
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 = { { disabled, checked : status === 'checked' } }
156163 accessibilityLiveRegion = "polite"
157- style = { styles . container }
158164 testID = { testID }
159- theme = { theme }
165+ style = { styles . tapTarget }
160166 >
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"
167+ < View pointerEvents = "none" style = { styles . tapTargetInner } >
168+ < View
169+ style = { [
170+ styles . stateLayer ,
171+ {
172+ backgroundColor : visual . stateLayerColor ,
173+ opacity : visual . stateLayerOpacity ,
174+ } ,
175+ ] }
173176 />
174- < View style = { [ StyleSheet . absoluteFill , styles . fillContainer ] } >
177+
178+ < View style = { [ styles . container , { opacity : visual . containerOpacity } ] } >
175179 < Animated . View
180+ pointerEvents = "none"
181+ style = { [
182+ styles . outline ,
183+ {
184+ borderColor : visual . outlineColor ,
185+ opacity : outlineOpacity ,
186+ } ,
187+ ] }
188+ />
189+ < Animated . View
190+ pointerEvents = "none"
176191 style = { [
177192 styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
193+ {
194+ backgroundColor : visual . containerColor ,
195+ opacity : fillAnim ,
196+ } ,
180197 ] }
181198 />
199+ { showIndeterminate ? (
200+ < Animated . View
201+ style = { [
202+ styles . checkmarkMask ,
203+ {
204+ width : checkAnim . interpolate ( {
205+ inputRange : [ 0 , 1 ] ,
206+ outputRange : [ 0 , CONTAINER_SIZE ] ,
207+ } ) ,
208+ opacity : checkAnim ,
209+ } ,
210+ ] }
211+ >
212+ < View style = { styles . checkmarkContent } >
213+ < View
214+ style = { [ styles . dash , { backgroundColor : visual . iconColor } ] }
215+ />
216+ </ View >
217+ </ Animated . View >
218+ ) : (
219+ < Checkmark color = { visual . iconColor } progress = { checkAnim } />
220+ ) }
182221 </ View >
183- </ Animated . View >
184- </ TouchableRipple >
222+ </ View >
223+ </ Pressable >
224+ ) ;
225+ } ;
226+
227+ /**
228+ * Reveal-mask checkmark: a static L-shape (borderLeftWidth +
229+ * borderBottomWidth rotated -45deg) inside a left-anchored View whose
230+ * width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
231+ * left-to-right, approximating Compose Material3's stroke-fraction
232+ * animation without an SVG dependency.
233+ */
234+ const Checkmark = ( {
235+ color,
236+ progress,
237+ } : {
238+ color : ColorValue ;
239+ progress : Animated . Value ;
240+ } ) => {
241+ const maskWidth = progress . interpolate ( {
242+ inputRange : [ 0 , 1 ] ,
243+ outputRange : [ 0 , CONTAINER_SIZE ] ,
244+ } ) ;
245+ return (
246+ < Animated . View
247+ style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] }
248+ >
249+ < View style = { styles . checkmarkContent } >
250+ < View style = { [ styles . checkmarkGlyph , { borderColor : color } ] } />
251+ </ View >
252+ </ Animated . View >
185253 ) ;
186254} ;
187255
188256const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
257+ tapTarget : {
258+ width : STATE_LAYER_SIZE ,
259+ height : STATE_LAYER_SIZE ,
260+ alignItems : 'center' ,
261+ justifyContent : 'center' ,
194262 } ,
195- fillContainer : {
263+ tapTargetInner : {
264+ width : STATE_LAYER_SIZE ,
265+ height : STATE_LAYER_SIZE ,
196266 alignItems : 'center' ,
197267 justifyContent : 'center' ,
198268 } ,
269+ stateLayer : {
270+ position : 'absolute' ,
271+ top : 0 ,
272+ left : 0 ,
273+ width : STATE_LAYER_SIZE ,
274+ height : STATE_LAYER_SIZE ,
275+ borderRadius : STATE_LAYER_SIZE / 2 ,
276+ } ,
277+ container : {
278+ width : CONTAINER_SIZE ,
279+ height : CONTAINER_SIZE ,
280+ borderRadius : CONTAINER_RADIUS ,
281+ alignItems : 'center' ,
282+ justifyContent : 'center' ,
283+ overflow : 'hidden' ,
284+ } ,
199285 fill : {
200- height : 14 ,
201- width : 14 ,
286+ position : 'absolute' ,
287+ top : 0 ,
288+ left : 0 ,
289+ right : 0 ,
290+ bottom : 0 ,
291+ borderRadius : CONTAINER_RADIUS ,
292+ } ,
293+ outline : {
294+ position : 'absolute' ,
295+ top : 0 ,
296+ left : 0 ,
297+ right : 0 ,
298+ bottom : 0 ,
299+ borderWidth : OUTLINE_WIDTH ,
300+ borderRadius : CONTAINER_RADIUS ,
301+ } ,
302+ dash : {
303+ width : 10 ,
304+ height : 2 ,
305+ borderRadius : 1 ,
306+ } ,
307+ checkmarkMask : {
308+ position : 'absolute' ,
309+ left : 0 ,
310+ top : 0 ,
311+ height : CONTAINER_SIZE ,
312+ overflow : 'hidden' ,
313+ } ,
314+ checkmarkContent : {
315+ width : CONTAINER_SIZE ,
316+ height : CONTAINER_SIZE ,
317+ alignItems : 'center' ,
318+ justifyContent : 'center' ,
319+ } ,
320+ checkmarkGlyph : {
321+ width : 11 ,
322+ height : 6 ,
323+ borderLeftWidth : 2 ,
324+ borderBottomWidth : 2 ,
325+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202326 } ,
203327} ) ;
204328
0 commit comments