@@ -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,14 @@ 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 ;
60+
5561
5662/**
5763 * Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +90,233 @@ const Checkbox = ({
8490 onPress,
8591 testID,
8692 error,
87- ...rest
93+ color,
94+ uncheckedColor,
8895} : Props ) => {
8996 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 ) ;
97+ const [ hovered , setHovered ] = React . useState ( false ) ;
98+ const [ pressed , setPressed ] = React . useState ( false ) ;
99+
100+ const selected = status === 'checked' || status === 'indeterminate' ;
94101
95102 const {
96103 animation : { scale } ,
97104 } = theme ;
98105
106+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
107+ const fillAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
108+ const checkAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
109+ const firstRender = React . useRef ( true ) ;
110+
99111 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
112+ if ( firstRender . current ) {
113+ firstRender . current = false ;
103114 return ;
104115 }
116+ Animated . timing ( fillAnim , {
117+ toValue : selected ? 1 : 0 ,
118+ duration : FILL_DURATION * scale ,
119+ useNativeDriver : true ,
120+ } ) . start ( ) ;
121+ Animated . timing ( checkAnim , {
122+ toValue : selected ? 1 : 0 ,
123+ duration : CHECK_DURATION * scale ,
124+ useNativeDriver : false ,
125+ } ) . start ( ) ;
126+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
105127
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 ] ,
128+ const visual = getSelectionVisualState ( {
129+ theme,
130+ selected,
131+ disabled,
132+ hovered,
133+
134+ pressed,
135+ error,
136+ customColor : color ,
137+ customUncheckedColor : uncheckedColor ,
140138 } ) ;
141139
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
140+ // Outline fades out as fill fades in (and vice versa).
141+ const outlineOpacity = fillAnim . interpolate ( {
142+ inputRange : [ 0 , 1 ] ,
143+ outputRange : [ 1 , 0 ] ,
144+ } ) ;
145+
146+ // Remember which glyph to render so the reveal-mask can still collapse
147+ // when transitioning back to 'unchecked' (selected becomes false, but
148+ // we keep showing the previous glyph until checkAnim hits 0).
149+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
150+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
151+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
152+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147153
148154 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
155+ < Pressable
152156 onPress = { onPress }
157+ onHoverIn = { ( ) => setHovered ( true ) }
158+ onHoverOut = { ( ) => setHovered ( false ) }
159+ onPressIn = { ( ) => setPressed ( true ) }
160+ onPressOut = { ( ) => setPressed ( false ) }
153161 disabled = { disabled }
154162 accessibilityRole = "checkbox"
155- accessibilityState = { { disabled, checked } }
163+ accessibilityState = { { disabled, checked : status === 'checked' } }
156164 accessibilityLiveRegion = "polite"
157- style = { styles . container }
158165 testID = { testID }
159- theme = { theme }
166+ style = { styles . tapTarget }
160167 >
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"
168+ < View pointerEvents = "none" style = { styles . tapTargetInner } >
169+ < View
170+ style = { [
171+ styles . stateLayer ,
172+ {
173+ backgroundColor : visual . stateLayerColor ,
174+ opacity : visual . stateLayerOpacity ,
175+ } ,
176+ ] }
173177 />
174- < View style = { [ StyleSheet . absoluteFill , styles . fillContainer ] } >
178+
179+ < View style = { [ styles . container , { opacity : visual . containerOpacity } ] } >
180+ < Animated . View
181+ pointerEvents = "none"
182+ style = { [
183+ styles . outline ,
184+ {
185+ borderColor : visual . outlineColor ,
186+ opacity : outlineOpacity ,
187+ } ,
188+ ] }
189+ />
175190 < Animated . View
191+ pointerEvents = "none"
176192 style = { [
177193 styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
194+ {
195+ backgroundColor : visual . containerColor ,
196+ opacity : fillAnim ,
197+ } ,
180198 ] }
181199 />
200+ { showIndeterminate ? (
201+ < Animated . View
202+ style = { [
203+ styles . checkmarkMask ,
204+ {
205+ width : checkAnim . interpolate ( { inputRange : [ 0 , 1 ] , outputRange : [ 0 , CONTAINER_SIZE ] } ) ,
206+ opacity : checkAnim ,
207+ } ,
208+ ] }
209+ >
210+ < View style = { styles . checkmarkContent } >
211+ < View style = { [ styles . dash , { backgroundColor : visual . iconColor } ] } />
212+ </ View >
213+ </ Animated . View >
214+ ) : (
215+ < Checkmark color = { visual . iconColor } progress = { checkAnim } />
216+ ) }
182217 </ View >
183- </ Animated . View >
184- </ TouchableRipple >
218+ </ View >
219+ </ Pressable >
220+ ) ;
221+ } ;
222+
223+ /**
224+ * Reveal-mask checkmark: a static L-shape (borderLeftWidth +
225+ * borderBottomWidth rotated -45deg) inside a left-anchored View whose
226+ * width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
227+ * left-to-right, approximating Compose Material3's stroke-fraction
228+ * animation without an SVG dependency.
229+ */
230+ const Checkmark = ( {
231+ color,
232+ progress,
233+ } : {
234+ color : ColorValue ;
235+ progress : Animated . Value ;
236+ } ) => {
237+ const maskWidth = progress . interpolate ( {
238+ inputRange : [ 0 , 1 ] ,
239+ outputRange : [ 0 , CONTAINER_SIZE ] ,
240+ } ) ;
241+ return (
242+ < Animated . View style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] } >
243+ < View style = { styles . checkmarkContent } >
244+ < View style = { [ styles . checkmarkGlyph , { borderColor : color } ] } />
245+ </ View >
246+ </ Animated . View >
185247 ) ;
186248} ;
187249
188250const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
251+ tapTarget : {
252+ width : STATE_LAYER_SIZE ,
253+ height : STATE_LAYER_SIZE ,
254+ alignItems : 'center' ,
255+ justifyContent : 'center' ,
194256 } ,
195- fillContainer : {
257+ tapTargetInner : {
258+ width : STATE_LAYER_SIZE ,
259+ height : STATE_LAYER_SIZE ,
260+ alignItems : 'center' ,
261+ justifyContent : 'center' ,
262+ } ,
263+ stateLayer : {
264+ position : 'absolute' ,
265+ top : 0 ,
266+ left : 0 ,
267+ width : STATE_LAYER_SIZE ,
268+ height : STATE_LAYER_SIZE ,
269+ borderRadius : STATE_LAYER_SIZE / 2 ,
270+ } ,
271+ container : {
272+ width : CONTAINER_SIZE ,
273+ height : CONTAINER_SIZE ,
274+ borderRadius : CONTAINER_RADIUS ,
196275 alignItems : 'center' ,
197276 justifyContent : 'center' ,
277+ overflow : 'hidden' ,
198278 } ,
199279 fill : {
200- height : 14 ,
201- width : 14 ,
280+ position : 'absolute' ,
281+ top : 0 ,
282+ left : 0 ,
283+ right : 0 ,
284+ bottom : 0 ,
285+ borderRadius : CONTAINER_RADIUS ,
286+ } ,
287+ outline : {
288+ position : 'absolute' ,
289+ top : 0 ,
290+ left : 0 ,
291+ right : 0 ,
292+ bottom : 0 ,
293+ borderWidth : OUTLINE_WIDTH ,
294+ borderRadius : CONTAINER_RADIUS ,
295+ } ,
296+ dash : {
297+ width : 10 ,
298+ height : 2 ,
299+ borderRadius : 1 ,
300+ } ,
301+ checkmarkMask : {
302+ position : 'absolute' ,
303+ left : 0 ,
304+ top : 0 ,
305+ height : CONTAINER_SIZE ,
306+ overflow : 'hidden' ,
307+ } ,
308+ checkmarkContent : {
309+ width : CONTAINER_SIZE ,
310+ height : CONTAINER_SIZE ,
311+ alignItems : 'center' ,
312+ justifyContent : 'center' ,
313+ } ,
314+ checkmarkGlyph : {
315+ width : 11 ,
316+ height : 6 ,
317+ borderLeftWidth : 2 ,
318+ borderBottomWidth : 2 ,
319+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202320 } ,
203321} ) ;
204322
0 commit comments