@@ -3,17 +3,18 @@ import {
33 Animated ,
44 ColorValue ,
55 GestureResponderEvent ,
6+ Pressable ,
7+ StyleProp ,
68 StyleSheet ,
79 View ,
10+ ViewStyle ,
811} from 'react-native' ;
912
10- import { getSelectionControlColor } from './utils' ;
13+ import { getSelectionVisualState } from './utils' ;
1114import { useInternalTheme } from '../../core/theming' ;
12- import type { $RemoveChildren , ThemeProp } from '../../types' ;
13- import MaterialCommunityIcon from '../MaterialCommunityIcon' ;
14- import TouchableRipple from '../TouchableRipple/TouchableRipple' ;
15+ import type { ThemeProp } from '../../types' ;
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 /**
@@ -49,9 +50,19 @@ export type Props = $RemoveChildren<typeof TouchableRipple> & {
4950 * testID to be used on tests.
5051 */
5152 testID ?: string ;
53+ /**
54+ * Style for the root element.
55+ */
56+ style ?: StyleProp < ViewStyle > ;
5257} ;
5358
54- const ANIMATION_DURATION = 100 ;
59+ // Spec dimensions (https://m3.material.io/components/checkbox/specs).
60+ const CONTAINER_SIZE = 18 ;
61+ const CONTAINER_RADIUS = 2 ;
62+ const OUTLINE_WIDTH = 2 ;
63+ const STATE_LAYER_SIZE = 40 ;
64+ const FILL_DURATION = 100 ;
65+ const CHECK_DURATION = 150 ;
5566
5667/**
5768 * Checkboxes allow the selection of multiple options from a set.
@@ -84,121 +95,235 @@ const Checkbox = ({
8495 onPress,
8596 testID,
8697 error,
87- ...rest
98+ color,
99+ uncheckedColor,
100+ style,
88101} : Props ) => {
89102 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 ) ;
103+ const [ hovered , setHovered ] = React . useState ( false ) ;
104+ const [ pressed , setPressed ] = React . useState ( false ) ;
105+
106+ const selected = status === 'checked' || status === 'indeterminate' ;
94107
95108 const {
96109 animation : { scale } ,
97110 } = theme ;
98111
112+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
113+ const fillAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
114+ const checkAnim = React . useRef ( new Animated . Value ( selected ? 1 : 0 ) ) . current ;
115+ const firstRender = React . useRef ( true ) ;
116+
99117 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
118+ if ( firstRender . current ) {
119+ firstRender . current = false ;
103120 return ;
104121 }
122+ Animated . timing ( fillAnim , {
123+ toValue : selected ? 1 : 0 ,
124+ duration : FILL_DURATION * scale ,
125+ useNativeDriver : true ,
126+ } ) . start ( ) ;
127+ Animated . timing ( checkAnim , {
128+ toValue : selected ? 1 : 0 ,
129+ duration : CHECK_DURATION * scale ,
130+ useNativeDriver : false ,
131+ } ) . start ( ) ;
132+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
133+
134+ const visual = getSelectionVisualState ( {
135+ theme,
136+ selected,
137+ disabled,
138+ hovered,
139+
140+ pressed,
141+ error,
142+ customColor : color ,
143+ customUncheckedColor : uncheckedColor ,
144+ } ) ;
105145
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 ] ,
146+ // Outline fades out as fill fades in (and vice versa).
147+ const outlineOpacity = fillAnim . interpolate ( {
148+ inputRange : [ 0 , 1 ] ,
149+ outputRange : [ 1 , 0 ] ,
140150 } ) ;
141151
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
152+ // Remember which glyph to render so the reveal-mask can still collapse
153+ // when transitioning back to 'unchecked' (selected becomes false, but
154+ // we keep showing the previous glyph until checkAnim hits 0).
155+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
156+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
157+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
158+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147159
148160 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
161+ < Pressable
152162 onPress = { onPress }
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 = { {
170+ disabled : ! ! disabled ,
171+ checked : status === 'checked' ,
172+ } }
156173 accessibilityLiveRegion = "polite"
157- style = { styles . container }
158174 testID = { testID }
159- theme = { theme }
175+ style = { [ styles . tapTarget , style ] }
160176 >
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"
177+ < View
178+ style = { [
179+ styles . stateLayer ,
180+ {
181+ backgroundColor : visual . stateLayerColor ,
182+ opacity : visual . stateLayerOpacity ,
183+ } ,
184+ ] }
185+ />
186+ < View style = { [ styles . container , { opacity : visual . containerOpacity } ] } >
187+ < Animated . View
188+ style = { [
189+ styles . outline ,
190+ {
191+ borderColor : visual . outlineColor ,
192+ opacity : outlineOpacity ,
193+ pointerEvents : 'none' ,
194+ } ,
195+ ] }
173196 />
174- < View style = { [ StyleSheet . absoluteFill , styles . fillContainer ] } >
197+ < Animated . View
198+ style = { [
199+ styles . fill ,
200+ {
201+ backgroundColor : visual . containerColor ,
202+ opacity : fillAnim ,
203+ pointerEvents : 'none' ,
204+ } ,
205+ ] }
206+ />
207+ { showIndeterminate ? (
175208 < Animated . View
176209 style = { [
177- styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
210+ styles . checkmarkMask ,
211+ {
212+ width : checkAnim . interpolate ( {
213+ inputRange : [ 0 , 1 ] ,
214+ outputRange : [ 0 , CONTAINER_SIZE ] ,
215+ } ) ,
216+ opacity : checkAnim ,
217+ } ,
180218 ] }
181- />
182- </ View >
183- </ Animated . View >
184- </ TouchableRipple >
219+ >
220+ < View style = { styles . checkmarkContent } >
221+ < View
222+ style = { [ styles . dash , { backgroundColor : visual . iconColor } ] }
223+ />
224+ </ View >
225+ </ Animated . View >
226+ ) : (
227+ < Checkmark color = { visual . iconColor } progress = { checkAnim } />
228+ ) }
229+ </ View >
230+ </ Pressable >
231+ ) ;
232+ } ;
233+
234+ /**
235+ * Reveal-mask checkmark: a static L-shape (borderLeftWidth +
236+ * borderBottomWidth rotated -45deg) inside a left-anchored View whose
237+ * width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
238+ * left-to-right, approximating Compose Material3's stroke-fraction
239+ * animation without an SVG dependency.
240+ */
241+ const Checkmark = ( {
242+ color,
243+ progress,
244+ } : {
245+ color : ColorValue ;
246+ progress : Animated . Value ;
247+ } ) => {
248+ const maskWidth = progress . interpolate ( {
249+ inputRange : [ 0 , 1 ] ,
250+ outputRange : [ 0 , CONTAINER_SIZE ] ,
251+ } ) ;
252+ return (
253+ < Animated . View
254+ style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] }
255+ >
256+ < View style = { styles . checkmarkContent } >
257+ < View style = { [ styles . checkmarkGlyph , { borderColor : color } ] } />
258+ </ View >
259+ </ Animated . View >
185260 ) ;
186261} ;
187262
188263const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
264+ tapTarget : {
265+ width : STATE_LAYER_SIZE ,
266+ height : STATE_LAYER_SIZE ,
267+ alignItems : 'center' ,
268+ justifyContent : 'center' ,
269+ } ,
270+ stateLayer : {
271+ position : 'absolute' ,
272+ top : 0 ,
273+ left : 0 ,
274+ width : STATE_LAYER_SIZE ,
275+ height : STATE_LAYER_SIZE ,
276+ borderRadius : STATE_LAYER_SIZE / 2 ,
194277 } ,
195- fillContainer : {
278+ container : {
279+ width : CONTAINER_SIZE ,
280+ height : CONTAINER_SIZE ,
281+ borderRadius : CONTAINER_RADIUS ,
196282 alignItems : 'center' ,
197283 justifyContent : 'center' ,
284+ overflow : 'hidden' ,
198285 } ,
199286 fill : {
200- height : 14 ,
201- width : 14 ,
287+ position : 'absolute' ,
288+ top : 0 ,
289+ left : 0 ,
290+ right : 0 ,
291+ bottom : 0 ,
292+ borderRadius : CONTAINER_RADIUS ,
293+ } ,
294+ outline : {
295+ position : 'absolute' ,
296+ top : 0 ,
297+ left : 0 ,
298+ right : 0 ,
299+ bottom : 0 ,
300+ borderWidth : OUTLINE_WIDTH ,
301+ borderRadius : CONTAINER_RADIUS ,
302+ } ,
303+ dash : {
304+ width : 10 ,
305+ height : 2 ,
306+ borderRadius : 1 ,
307+ } ,
308+ checkmarkMask : {
309+ position : 'absolute' ,
310+ left : 0 ,
311+ top : 0 ,
312+ height : CONTAINER_SIZE ,
313+ overflow : 'hidden' ,
314+ } ,
315+ checkmarkContent : {
316+ width : CONTAINER_SIZE ,
317+ height : CONTAINER_SIZE ,
318+ alignItems : 'center' ,
319+ justifyContent : 'center' ,
320+ } ,
321+ checkmarkGlyph : {
322+ width : 11 ,
323+ height : 6 ,
324+ borderLeftWidth : 2 ,
325+ borderBottomWidth : 2 ,
326+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202327 } ,
203328} ) ;
204329
0 commit comments