@@ -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 type { ThemeProp } from '../../types' ;
14+ import useAnimatedValue from '../../utils/useAnimatedValue' ;
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,14 +51,15 @@ 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 ;
5561
5662/**
57- * Checkboxes allow the selection of multiple options from a set.
58- *
59- * ## Usage
60- * ```js
61- * import * as React from 'react';
6263 * import { Checkbox } from 'react-native-paper';
6364 *
6465 * const MyComponent = () => {
@@ -84,121 +85,193 @@ const Checkbox = ({
8485 onPress,
8586 testID,
8687 error,
87- ...rest
88+ color,
89+ uncheckedColor,
8890} : Props ) => {
8991 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 ) ;
92+
93+ const [ hovered , setHovered ] = React . useState ( false ) ;
94+ const [ pressed , setPressed ] = React . useState ( false ) ;
95+
96+ const selected = status === 'checked' || status === 'indeterminate' ;
9497
9598 const {
9699 animation : { scale } ,
97100 } = theme ;
98101
102+ // 0 = unselected (outline only), 1 = selected (filled + drawn icon).
103+ const fillAnim = useAnimatedValue ( selected ? 1 : 0 ) ;
104+ const checkAnim = useAnimatedValue ( selected ? 1 : 0 ) ;
105+ const firstRender = React . useRef ( true ) ;
106+
99107 React . useEffect ( ( ) => {
100- // Do not run animation on very first rendering
101- if ( isFirstRendering . current ) {
102- isFirstRendering . current = false ;
108+ if ( firstRender . current ) {
109+ firstRender . current = false ;
103110 return ;
104111 }
112+ Animated . timing ( fillAnim , {
113+ toValue : selected ? 1 : 0 ,
114+ useNativeDriver : true ,
115+ } ) . start ( ) ;
116+ Animated . timing ( checkAnim , {
117+ toValue : selected ? 1 : 0 ,
118+ useNativeDriver : false ,
119+ } ) . start ( ) ;
120+ } , [ selected , fillAnim , checkAnim , scale ] ) ;
105121
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 ] ,
122+ const visual = getSelectionVisualState ( {
123+ theme,
124+ selected,
125+ disabled,
126+ hovered,
127+ pressed,
128+ error,
129+ customColor : color ,
130+ customUncheckedColor : uncheckedColor ,
140131 } ) ;
141132
142- const icon = indeterminate
143- ? 'minus-box'
144- : checked
145- ? 'checkbox-marked'
146- : 'checkbox-blank-outline' ;
133+ // Outline fades out as fill fades in (and vice versa).
134+ const outlineOpacity = fillAnim . interpolate ( {
135+ inputRange : [ 0 , 1 ] ,
136+ outputRange : [ 1 , 0 ] ,
137+ } ) ;
138+
139+ // Remember which glyph to render so the reveal-mask can still collapse
140+ // when transitioning back to 'unchecked' (selected becomes false, but
141+ // we keep showing the previous glyph until checkAnim hits 0).
142+ const lastGlyph = React . useRef < 'check' | 'indeterminate' > ( 'check' ) ;
143+ if ( status === 'checked' ) lastGlyph . current = 'check' ;
144+ else if ( status === 'indeterminate' ) lastGlyph . current = 'indeterminate' ;
145+ const showIndeterminate = lastGlyph . current === 'indeterminate' ;
147146
148147 return (
149- < TouchableRipple
150- { ...rest }
151- borderless
148+ < Pressable
152149 onPress = { onPress }
150+ onHoverIn = { ( ) => setHovered ( true ) }
151+ onHoverOut = { ( ) => setHovered ( false ) }
152+ onPressIn = { ( ) => setPressed ( true ) }
153+ onPressOut = { ( ) => setPressed ( false ) }
153154 disabled = { disabled }
154155 accessibilityRole = "checkbox"
155- accessibilityState = { { disabled, checked } }
156+ accessibilityState = { {
157+ disabled,
158+ checked : status === 'indeterminate' ? 'mixed' : status === 'checked' ,
159+ } }
156160 accessibilityLiveRegion = "polite"
157- style = { styles . container }
158161 testID = { testID }
159- theme = { theme }
162+ style = { styles . tapTarget }
160163 >
161- < Animated . View
162- style = { {
163- transform : [ { scale : scaleAnim } ] ,
164- opacity : selectionControlOpacity ,
165- } }
164+ < View
165+ pointerEvents = "none"
166+ style = { [
167+ styles . stateLayer ,
168+ {
169+ backgroundColor : visual . stateLayerColor ,
170+ opacity : visual . stateLayerOpacity ,
171+ } ,
172+ ] }
173+ />
174+
175+ < View
176+ pointerEvents = "none"
177+ style = { [ styles . container , { opacity : visual . containerOpacity } ] }
166178 >
167- < MaterialCommunityIcon
168- allowFontScaling = { false }
169- name = { icon }
170- size = { 24 }
171- color = { selectionControlColor }
172- direction = "ltr"
179+ < Animated . View
180+ style = { [
181+ styles . outline ,
182+ { borderColor : visual . outlineColor , opacity : outlineOpacity } ,
183+ ] }
184+ />
185+ < Animated . View
186+ style = { [
187+ styles . fill ,
188+ { backgroundColor : visual . containerColor , opacity : fillAnim } ,
189+ ] }
173190 />
174- < View style = { [ StyleSheet . absoluteFill , styles . fillContainer ] } >
175- < Animated . View
176- style = { [
177- styles . fill ,
178- { borderColor : selectionControlColor } ,
179- { borderWidth } ,
180- ] }
181- />
182- </ View >
183- </ Animated . View >
184- </ TouchableRipple >
191+ < RevealMask progress = { checkAnim } >
192+ { showIndeterminate ? (
193+ < View
194+ style = { [ styles . dash , { backgroundColor : visual . iconColor } ] }
195+ />
196+ ) : (
197+ < View
198+ style = { [ styles . checkmarkGlyph , { borderColor : visual . iconColor } ] }
199+ />
200+ ) }
201+ </ RevealMask >
202+ </ View >
203+ </ Pressable >
204+ ) ;
205+ } ;
206+
207+ /**
208+ * Reveal-mask wrapper: animates its width from 0 -> containerSize so the
209+ * child glyph "draws in" left-to-right, approximating Compose Material3's
210+ * stroke-fraction animation without an SVG dependency.
211+ */
212+ const RevealMask = ( {
213+ progress,
214+ children,
215+ } : {
216+ progress : Animated . Value ;
217+ children : React . ReactNode ;
218+ } ) => {
219+ const maskWidth = progress . interpolate ( {
220+ inputRange : [ 0 , 1 ] ,
221+ outputRange : [ 0 , CONTAINER_SIZE ] ,
222+ } ) ;
223+ return (
224+ < Animated . View
225+ style = { [ styles . checkmarkMask , { width : maskWidth , opacity : progress } ] }
226+ >
227+ < View style = { styles . checkmarkContent } > { children } </ View >
228+ </ Animated . View >
185229 ) ;
186230} ;
187231
188232const styles = StyleSheet . create ( {
189- container : {
190- borderRadius : 18 ,
191- width : 36 ,
192- height : 36 ,
193- padding : 6 ,
233+ tapTarget : {
234+ alignItems : 'center' ,
235+ justifyContent : 'center' ,
194236 } ,
195- fillContainer : {
237+ stateLayer : {
238+ position : 'absolute' ,
239+ top : 0 ,
240+ left : 0 ,
241+ } ,
242+ container : {
196243 alignItems : 'center' ,
197244 justifyContent : 'center' ,
245+ overflow : 'hidden' ,
198246 } ,
199247 fill : {
200- height : 14 ,
201- width : 14 ,
248+ position : 'absolute' ,
249+ top : 0 ,
250+ left : 0 ,
251+ right : 0 ,
252+ bottom : 0 ,
253+ } ,
254+ outline : {
255+ position : 'absolute' ,
256+ top : 0 ,
257+ left : 0 ,
258+ right : 0 ,
259+ bottom : 0 ,
260+ } ,
261+ dash : {
262+ } ,
263+ checkmarkMask : {
264+ position : 'absolute' ,
265+ left : 0 ,
266+ top : 0 ,
267+ overflow : 'hidden' ,
268+ } ,
269+ checkmarkContent : {
270+ alignItems : 'center' ,
271+ justifyContent : 'center' ,
272+ } ,
273+ checkmarkGlyph : {
274+ transform : [ { rotate : '-45deg' } , { translateY : - 1 } , { translateX : 1 } ] ,
202275 } ,
203276} ) ;
204277
0 commit comments