Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 205 additions & 87 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,16 @@
Animated,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some general comments:

  • Missing focus handling
  • No RTL handling. The checkmark mask animates width anchored at left: 0; on web that won't draw right-to-left in RTL. check useLocale().direction
  • No outline: none on web Pressable: same default-outline problem we fixed in Switch
  • See useReduceMotion hook for motion scaling

ColorValue,
GestureResponderEvent,
Pressable,
StyleSheet,
View,
} from 'react-native';

import { getSelectionControlColor } from './utils';
import { getSelectionVisualState } from './utils';
import { useInternalTheme } from '../../core/theming';
import type { $RemoveChildren, ThemeProp } from '../../types';
import MaterialCommunityIcon from '../MaterialCommunityIcon';
import TouchableRipple from '../TouchableRipple/TouchableRipple';
import type { ThemeProp } from '../../types';

export type Props = $RemoveChildren<typeof TouchableRipple> & {
export type Props = {
/**
* Status of checkbox.
*/
Expand All @@ -36,9 +35,9 @@
color?: ColorValue;
/**
* Whether the checkbox is in an error state. When true, the outline
* (unchecked) and container (checked / indeterminate) use
* `theme.colors.error`. `disabled` and explicit `color`/`uncheckedColor`
* overrides take precedence.
* (unchecked) and container (selected) use `theme.colors.error`.
* `disabled` and explicit `color`/`uncheckedColor` overrides take
* precedence.
*/
error?: boolean;
/**
Expand All @@ -51,8 +50,15 @@
testID?: string;
};

const ANIMATION_DURATION = 100;
// Spec dimensions (https://m3.material.io/components/checkbox/specs).
const CONTAINER_SIZE = 18;
const CONTAINER_RADIUS = 2;
const OUTLINE_WIDTH = 2;
const STATE_LAYER_SIZE = 40;
const FILL_DURATION = 100;
const CHECK_DURATION = 150;


Check failure on line 61 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `⏎`
/**
* Checkboxes allow the selection of multiple options from a set.
*
Expand Down Expand Up @@ -84,121 +90,233 @@
onPress,
testID,
error,
...rest
color,
uncheckedColor,
}: Props) => {
const theme = useInternalTheme(themeOverrides);
const { current: scaleAnim } = React.useRef<Animated.Value>(
new Animated.Value(1)
);
const isFirstRendering = React.useRef<boolean>(true);
const [hovered, setHovered] = React.useState(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@satya164 Might request the same approach with shared value as here + Reanimated instead.

const [pressed, setPressed] = React.useState(false);

const selected = status === 'checked' || status === 'indeterminate';

const {
animation: { scale },
} = theme;

// 0 = unselected (outline only), 1 = selected (filled + drawn icon).
const fillAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
const checkAnim = React.useRef(new Animated.Value(selected ? 1 : 0)).current;
const firstRender = React.useRef(true);

React.useEffect(() => {
// Do not run animation on very first rendering
if (isFirstRendering.current) {
isFirstRendering.current = false;
if (firstRender.current) {
firstRender.current = false;
return;
}
Animated.timing(fillAnim, {
toValue: selected ? 1 : 0,
duration: FILL_DURATION * scale,
useNativeDriver: true,
}).start();
Animated.timing(checkAnim, {
toValue: selected ? 1 : 0,
duration: CHECK_DURATION * scale,
useNativeDriver: false,
}).start();
}, [selected, fillAnim, checkAnim, scale]);

const checked = status === 'checked';

Animated.sequence([
Animated.timing(scaleAnim, {
toValue: 0.85,
duration: checked ? ANIMATION_DURATION * scale : 0,
useNativeDriver: false,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: checked
? ANIMATION_DURATION * scale
: ANIMATION_DURATION * scale * 1.75,
useNativeDriver: false,
}),
]).start();
}, [status, scaleAnim, scale]);

const checked = status === 'checked';
const indeterminate = status === 'indeterminate';

const { selectionControlColor, selectionControlOpacity } =
getSelectionControlColor({
theme,
disabled,
checked,
customColor: rest.color,
customUncheckedColor: rest.uncheckedColor,
error,
});

const borderWidth = scaleAnim.interpolate({
inputRange: [0.8, 1],
outputRange: [7, 0],
const visual = getSelectionVisualState({
theme,
selected,
disabled,
hovered,

Check failure on line 133 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `····`
pressed,
error,
customColor: color,
customUncheckedColor: uncheckedColor,
});

const icon = indeterminate
? 'minus-box'
: checked
? 'checkbox-marked'
: 'checkbox-blank-outline';
// Outline fades out as fill fades in (and vice versa).
const outlineOpacity = fillAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 0],
});

// Remember which glyph to render so the reveal-mask can still collapse
// when transitioning back to 'unchecked' (selected becomes false, but
// we keep showing the previous glyph until checkAnim hits 0).
const lastGlyph = React.useRef<'check' | 'indeterminate'>('check');
if (status === 'checked') lastGlyph.current = 'check';
else if (status === 'indeterminate') lastGlyph.current = 'indeterminate';
const showIndeterminate = lastGlyph.current === 'indeterminate';

return (
<TouchableRipple
{...rest}
borderless
<Pressable
onPress={onPress}
onHoverIn={() => setHovered(true)}
onHoverOut={() => setHovered(false)}
onPressIn={() => setPressed(true)}
onPressOut={() => setPressed(false)}
disabled={disabled}
accessibilityRole="checkbox"
accessibilityState={{ disabled, checked }}
accessibilityState={{ disabled, checked: status === 'checked' }}
accessibilityLiveRegion="polite"
style={styles.container}
testID={testID}
theme={theme}
style={styles.tapTarget}
>
Comment on lines +155 to 167
<Animated.View
style={{
transform: [{ scale: scaleAnim }],
opacity: selectionControlOpacity,
}}
>
<MaterialCommunityIcon
allowFontScaling={false}
name={icon}
size={24}
color={selectionControlColor}
direction="ltr"
<View pointerEvents="none" style={styles.tapTargetInner}>
<View
style={[
styles.stateLayer,
{
backgroundColor: visual.stateLayerColor,
opacity: visual.stateLayerOpacity,
},
]}
/>
<View style={[StyleSheet.absoluteFill, styles.fillContainer]}>

Check failure on line 178 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `········`
<View style={[styles.container, { opacity: visual.containerOpacity }]}>
<Animated.View
pointerEvents="none"
style={[
styles.outline,
{
borderColor: visual.outlineColor,
opacity: outlineOpacity,
},
]}
/>
<Animated.View
pointerEvents="none"
style={[
styles.fill,
{ borderColor: selectionControlColor },
{ borderWidth },
{
backgroundColor: visual.containerColor,
opacity: fillAnim,
},
]}
/>
{showIndeterminate ? (
<Animated.View
style={[
styles.checkmarkMask,
{
width: checkAnim.interpolate({ inputRange: [0, 1], outputRange: [0, CONTAINER_SIZE] }),

Check failure on line 205 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `·inputRange:·[0,·1],·outputRange:·[0,·CONTAINER_SIZE]` with `⏎····················inputRange:·[0,·1],⏎····················outputRange:·[0,·CONTAINER_SIZE],⏎·················`
opacity: checkAnim,
},
]}
>
<View style={styles.checkmarkContent}>
<View style={[styles.dash, { backgroundColor: visual.iconColor }]} />

Check failure on line 211 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `·style={[styles.dash,·{·backgroundColor:·visual.iconColor·}]}` with `⏎··················style={[styles.dash,·{·backgroundColor:·visual.iconColor·}]}⏎···············`
</View>
</Animated.View>
) : (
<Checkmark color={visual.iconColor} progress={checkAnim} />
)}
</View>
</Animated.View>
</TouchableRipple>
</View>
</Pressable>
);
};

/**
* Reveal-mask checkmark: a static L-shape (borderLeftWidth +
* borderBottomWidth rotated -45deg) inside a left-anchored View whose
* width animates 0 -> CONTAINER_SIZE. The checkmark "draws in"
* left-to-right, approximating Compose Material3's stroke-fraction
* animation without an SVG dependency.
*/
const Checkmark = ({
color,
progress,
}: {
color: ColorValue;
progress: Animated.Value;
}) => {
const maskWidth = progress.interpolate({
inputRange: [0, 1],
outputRange: [0, CONTAINER_SIZE],
});
return (
<Animated.View style={[styles.checkmarkMask, { width: maskWidth, opacity: progress }]}>

Check failure on line 242 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Replace `·style={[styles.checkmarkMask,·{·width:·maskWidth,·opacity:·progress·}]}` with `⏎······style={[styles.checkmarkMask,·{·width:·maskWidth,·opacity:·progress·}]}⏎····`
<View style={styles.checkmarkContent}>
<View style={[styles.checkmarkGlyph, { borderColor: color }]} />
</View>
</Animated.View>
);
};

const styles = StyleSheet.create({
container: {
borderRadius: 18,
width: 36,
height: 36,
padding: 6,
tapTarget: {
width: STATE_LAYER_SIZE,
height: STATE_LAYER_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
fillContainer: {
tapTargetInner: {
width: STATE_LAYER_SIZE,
height: STATE_LAYER_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
stateLayer: {
position: 'absolute',
top: 0,
left: 0,
width: STATE_LAYER_SIZE,
height: STATE_LAYER_SIZE,
borderRadius: STATE_LAYER_SIZE / 2,
},

Check failure on line 270 in src/components/Checkbox/Checkbox.tsx

View workflow job for this annotation

GitHub Actions / Lint

Delete `··`
container: {
width: CONTAINER_SIZE,
height: CONTAINER_SIZE,
borderRadius: CONTAINER_RADIUS,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
},
fill: {
height: 14,
width: 14,
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: CONTAINER_RADIUS,
},
outline: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderWidth: OUTLINE_WIDTH,
borderRadius: CONTAINER_RADIUS,
},
dash: {
width: 10,
height: 2,
borderRadius: 1,
},
checkmarkMask: {
position: 'absolute',
left: 0,
top: 0,
height: CONTAINER_SIZE,
overflow: 'hidden',
},
checkmarkContent: {
width: CONTAINER_SIZE,
height: CONTAINER_SIZE,
alignItems: 'center',
justifyContent: 'center',
},
checkmarkGlyph: {
width: 11,
height: 6,
borderLeftWidth: 2,
borderBottomWidth: 2,
transform: [{ rotate: '-45deg' }, { translateY: -1 }, { translateX: 1 }],
},
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/Checkbox/CheckboxItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const CheckboxItem = ({
accessibilityLabel={accessibilityLabel}
accessibilityRole="checkbox"
accessibilityState={{
checked: status === 'checked',
checked: status === 'indeterminate' ? 'mixed' : status === 'checked',
disabled,
}}
onPress={onPress}
Expand Down
Loading
Loading