Skip to content

Commit 7a31da9

Browse files
feat(buttons): implemented segmented button
1 parent 4a24ae0 commit 7a31da9

File tree

5 files changed

+259
-0
lines changed

5 files changed

+259
-0
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {View, type ViewProps, type ColorValue, type StyleProp, type TextStyle} from 'react-native';
2+
import React, {useCallback, type ReactElement} from 'react';
3+
4+
import {styles} from './segmented-button.styles';
5+
import {useTheme} from '../../theme/useTheme.hook';
6+
import {type IconProps} from '../../icons/icon-props';
7+
import {ButtonSegment as ButtonSegmentComponent} from './button-segment/ButtonSegment.component';
8+
9+
export interface ButtonSegment<T> {
10+
value: T;
11+
12+
label?: string;
13+
Icon?: React.FC<IconProps>;
14+
}
15+
16+
export interface SegmentedButtonProps<T> extends ViewProps {
17+
segments: ButtonSegment<T>[];
18+
selected: T[];
19+
20+
multiSelectionEnabled?: boolean;
21+
withCheckmark?: boolean;
22+
iconSize?: number;
23+
iconColor?: ColorValue;
24+
rippleColor?: ColorValue;
25+
labelStyle?: StyleProp<TextStyle>;
26+
27+
onSegmentPress: (value: T[] | ((currValues: T[]) => T[])) => void;
28+
}
29+
30+
export const SegmentedButton: <T extends any>(props: SegmentedButtonProps<T>) => ReactElement = ({
31+
segments,
32+
selected,
33+
multiSelectionEnabled = false,
34+
onSegmentPress,
35+
style,
36+
withCheckmark,
37+
labelStyle,
38+
iconSize,
39+
iconColor,
40+
rippleColor,
41+
...props
42+
}) => {
43+
const {outline} = useTheme();
44+
45+
const renderButtonSegment = useCallback(
46+
(segment, index) => (
47+
<ButtonSegmentComponent
48+
key={segment.value}
49+
style={{borderLeftWidth: Number(Boolean(index))}}
50+
selected={selected.includes(segment.value)}
51+
onSegmentPress={onSegmentPress}
52+
multiSelectionEnabled={multiSelectionEnabled}
53+
withCheckmark={withCheckmark}
54+
labelStyle={labelStyle}
55+
iconSize={iconSize}
56+
iconColor={iconColor}
57+
rippleColor={rippleColor}
58+
{...segment}
59+
/>
60+
),
61+
[selected, withCheckmark, labelStyle, iconColor, iconSize, rippleColor, multiSelectionEnabled, onSegmentPress]
62+
);
63+
64+
return (
65+
<View style={[styles.container, {borderColor: outline}, style]} {...props}>
66+
{segments.map(renderButtonSegment)}
67+
</View>
68+
);
69+
};
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import React, {useEffect} from 'react';
2+
import {Pressable, type PressableProps, type StyleProp, type ViewStyle, type TextStyle, type ColorValue} from 'react-native';
3+
import Animated, {
4+
FadeIn,
5+
FadeOut,
6+
interpolate,
7+
interpolateColor,
8+
LinearTransition,
9+
useAnimatedStyle,
10+
useSharedValue,
11+
withTiming,
12+
} from 'react-native-reanimated';
13+
14+
import {styles} from './button-segment.styles';
15+
import {useTheme} from '../../../theme/useTheme.hook';
16+
import {type IconProps} from '../../../icons/icon-props';
17+
import {useTypography} from '../../../typography/useTypography.component';
18+
import {AnimatedSelectedIcon} from './animated-selected-icon/AnimatedSelectedIcon.component';
19+
20+
interface ButtonSegmentProps<T> extends Omit<PressableProps, 'onPress'> {
21+
value: T;
22+
selected: boolean;
23+
multiSelectionEnabled: boolean;
24+
25+
label?: string;
26+
withCheckmark?: boolean;
27+
Icon?: React.FC<IconProps>;
28+
style?: StyleProp<ViewStyle>;
29+
labelStyle?: StyleProp<TextStyle>;
30+
iconSize?: number;
31+
iconColor?: ColorValue;
32+
rippleColor?: ColorValue;
33+
34+
onSegmentPress: (value: T[] | ((prevValues: T[]) => T[])) => void;
35+
}
36+
37+
const DEFAULT_ICON_SIZE = 18;
38+
39+
export const ButtonSegment = React.memo(
40+
<T extends any>({
41+
value,
42+
selected,
43+
Icon,
44+
label,
45+
multiSelectionEnabled,
46+
withCheckmark = true,
47+
onSegmentPress,
48+
iconSize = DEFAULT_ICON_SIZE,
49+
labelStyle,
50+
iconColor,
51+
rippleColor,
52+
style,
53+
...props
54+
}: ButtonSegmentProps<T>) => {
55+
const {labelLarge} = useTypography();
56+
const {surface, secondaryContainer} = useTheme();
57+
58+
const fill = useSharedValue(Number(selected));
59+
60+
useEffect(() => {
61+
fill.value = withTiming(Number(selected));
62+
}, [selected]);
63+
64+
const animatedLabelStyle = useAnimatedStyle(
65+
() => ({
66+
color: interpolateColor(fill.value, [0, 1], [surface.text as string, secondaryContainer.text as string]),
67+
}),
68+
[]
69+
);
70+
71+
const circleAnimatedStyle = useAnimatedStyle(() => {
72+
return {
73+
opacity: fill.value,
74+
width: `${interpolate(fill.value, [0, 1], [0, 100])}%`,
75+
};
76+
}, []);
77+
78+
const handleSegmentPress = () => {
79+
if (multiSelectionEnabled) {
80+
handleMultiplyChoosing();
81+
} else {
82+
onSegmentPress([value]);
83+
}
84+
};
85+
86+
const handleMultiplyChoosing = () => {
87+
onSegmentPress((prevValues) => {
88+
const filteredCurrValuesDependsOnLength = prevValues.length === 1 ? prevValues : prevValues.filter((prevValue) => prevValue !== value);
89+
90+
return selected ? filteredCurrValuesDependsOnLength : [...prevValues, value];
91+
});
92+
};
93+
94+
const renderIconConditionally = () => {
95+
const defaultIconColor = selected ? secondaryContainer.text : surface.text;
96+
97+
return Icon ? (
98+
<Animated.View layout={LinearTransition} entering={FadeIn} exiting={FadeOut}>
99+
<Icon size={iconSize} color={iconColor ?? defaultIconColor} />
100+
</Animated.View>
101+
) : null;
102+
};
103+
104+
return (
105+
<Pressable style={[styles.container, style]} {...props} onPress={handleSegmentPress}>
106+
<Animated.View style={[styles.ripple, {backgroundColor: rippleColor ?? secondaryContainer.background}, circleAnimatedStyle]} />
107+
{withCheckmark && selected ? (
108+
<Animated.View layout={LinearTransition} entering={FadeIn}>
109+
<AnimatedSelectedIcon width={iconSize} height={iconSize} strokeWidth={2} stroke={secondaryContainer.text} />
110+
</Animated.View>
111+
) : null}
112+
{Icon && withCheckmark && selected ? null : renderIconConditionally()}
113+
{label ? (
114+
<Animated.Text layout={LinearTransition} style={[labelLarge, animatedLabelStyle, labelStyle]}>
115+
{label}
116+
</Animated.Text>
117+
) : null}
118+
</Pressable>
119+
);
120+
}
121+
);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React, {useCallback} from 'react';
2+
import Svg, {type SvgProps, Path} from 'react-native-svg';
3+
import Animated, {interpolate, useAnimatedProps, useSharedValue, withTiming} from 'react-native-reanimated';
4+
5+
const AnimatedPath = Animated.createAnimatedComponent(Path);
6+
7+
export const AnimatedSelectedIcon: React.FC<SvgProps> = (props) => {
8+
const pathRef = React.useRef<Path>(null);
9+
const progress = useSharedValue(0);
10+
const [length, setLength] = React.useState(0);
11+
12+
const onLayout = useCallback(() => setLength(pathRef.current?.getTotalLength() ?? 0), [pathRef.current]);
13+
14+
React.useEffect(() => {
15+
progress.value = withTiming(1, {duration: 330});
16+
}, []);
17+
18+
const animatedProps = useAnimatedProps(() => ({
19+
strokeDashoffset: interpolate(progress.value, [0, 1], [length, 0]),
20+
}));
21+
22+
return (
23+
<Svg width={props.width} height={props.height} viewBox="0 0 21 24" fill="none" {...props}>
24+
<AnimatedPath
25+
ref={pathRef}
26+
onLayout={onLayout}
27+
stroke={props.stroke}
28+
strokeLinecap="round"
29+
strokeLinejoin="round"
30+
strokeWidth={props.strokeWidth}
31+
d="M4 12.611 8.923 17.5 20 6.5"
32+
animatedProps={animatedProps}
33+
strokeDasharray={length}
34+
/>
35+
</Svg>
36+
);
37+
};
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import {StyleSheet} from 'react-native';
2+
3+
export const styles = StyleSheet.create({
4+
container: {
5+
flex: 1,
6+
gap: 8,
7+
flexDirection: 'row',
8+
alignItems: 'center',
9+
justifyContent: 'center',
10+
11+
paddingVertical: 10,
12+
13+
overflow: 'hidden',
14+
},
15+
ripple: {
16+
position: 'absolute',
17+
top: 0,
18+
bottom: 0,
19+
},
20+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {StyleSheet} from 'react-native';
2+
3+
export const styles = StyleSheet.create({
4+
container: {
5+
flexDirection: 'row',
6+
7+
borderWidth: 1,
8+
borderRadius: 100,
9+
10+
overflow: 'hidden',
11+
},
12+
});

0 commit comments

Comments
 (0)