Skip to content

Commit 23394c5

Browse files
authored
Infra/ migrate SegmentedControl to reanimated v2 (#1567)
* migrate to reanimated2 * throttleTime prop * pass static onPress * remove indexRef * remove redundant const * refactor
1 parent 1fc4fbb commit 23394c5

File tree

5 files changed

+100
-79
lines changed

5 files changed

+100
-79
lines changed

demo/src/screens/componentScreens/SectionsWheelPickerScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ const SectionsWheelPickerScreen = () => {
102102
<SegmentedControl
103103
segments={[{label: '1 section'}, {label: '2 sections'}, {label: '3 sections'}]}
104104
onChangeIndex={onChangeIndex}
105+
throttleTime={400}
105106
/>
106107
<Text text50 marginV-20>
107108
Pick a duration

generatedTypes/src/components/segmentedControl/index.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ export declare type SegmentedControlProps = {
4747
* Should the icon be on right of the label
4848
*/
4949
iconOnRight?: boolean;
50+
/**
51+
* Trailing throttle time of changing index in ms.
52+
*/
53+
throttleTime?: number;
5054
/**
5155
* Additional spacing styles for the container
5256
*/

generatedTypes/src/components/segmentedControl/segment.d.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from 'react';
22
import { LayoutChangeEvent, ImageSourcePropType, ImageStyle, StyleProp } from 'react-native';
3+
import Reanimated from 'react-native-reanimated';
34
export declare type SegmentedControlItemProps = {
45
/**
56
* The label of the segment.
@@ -20,9 +21,9 @@ export declare type SegmentedControlItemProps = {
2021
};
2122
export declare type SegmentProps = SegmentedControlItemProps & {
2223
/**
23-
* Is the item selected.
24+
* Shared value of the current selected index.
2425
*/
25-
isSelected?: boolean;
26+
selectedIndex?: Reanimated.SharedValue<number>;
2627
/**
2728
* The color of the active segment (label and outline).
2829
*/
@@ -34,7 +35,7 @@ export declare type SegmentProps = SegmentedControlItemProps & {
3435
/**
3536
* Callback for when segment has pressed.
3637
*/
37-
onPress: (index: number) => void;
38+
onPress?: (index: number) => void;
3839
/**
3940
* The index of the segment.
4041
*/
@@ -47,9 +48,9 @@ export declare type SegmentProps = SegmentedControlItemProps & {
4748
};
4849
declare const _default: React.ComponentClass<SegmentedControlItemProps & {
4950
/**
50-
* Is the item selected.
51+
* Shared value of the current selected index.
5152
*/
52-
isSelected?: boolean | undefined;
53+
selectedIndex?: Reanimated.SharedValue<number> | undefined;
5354
/**
5455
* The color of the active segment (label and outline).
5556
*/
@@ -61,7 +62,7 @@ declare const _default: React.ComponentClass<SegmentedControlItemProps & {
6162
/**
6263
* Callback for when segment has pressed.
6364
*/
64-
onPress: (index: number) => void;
65+
onPress?: ((index: number) => void) | undefined;
6566
/**
6667
* The index of the segment.
6768
*/

src/components/segmentedControl/index.tsx

Lines changed: 66 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import _ from 'lodash';
2-
import React, {useRef, useState, useCallback, useMemo} from 'react';
2+
import React, {useRef, useCallback} from 'react';
33
import {StyleSheet, StyleProp, ViewStyle, LayoutChangeEvent} from 'react-native';
4-
import Reanimated, {EasingNode, Easing as _Easing} from 'react-native-reanimated';
4+
import Reanimated, {
5+
Easing,
6+
useAnimatedReaction,
7+
useAnimatedStyle,
8+
useSharedValue,
9+
withTiming,
10+
runOnJS
11+
} from 'react-native-reanimated';
512
import {Colors, BorderRadiuses, Spacings} from '../../style';
613
import {asBaseComponent} from '../../commons/new';
714
import View from '../view';
815
import Segment, {SegmentedControlItemProps as SegmentProps} from './segment';
916
import {Constants} from 'helpers';
1017

11-
const {interpolate: _interpolate, interpolateNode} = Reanimated;
12-
const interpolate = interpolateNode || _interpolate;
13-
const Easing = EasingNode || _Easing;
1418
const BORDER_WIDTH = 1;
19+
const TIMING_CONFIG = {
20+
duration: 300,
21+
easing: Easing.bezier(0.33, 1, 0.68, 1)
22+
};
1523

1624
export type SegmentedControlItemProps = SegmentProps;
1725
export type SegmentedControlProps = {
@@ -59,6 +67,10 @@ export type SegmentedControlProps = {
5967
* Should the icon be on right of the label
6068
*/
6169
iconOnRight?: boolean;
70+
/**
71+
* Trailing throttle time of changing index in ms.
72+
*/
73+
throttleTime?: number;
6274
/**
6375
* Additional spacing styles for the container
6476
*/
@@ -85,63 +97,61 @@ const SegmentedControl = (props: SegmentedControlProps) => {
8597
inactiveColor = Colors.grey20,
8698
outlineColor = activeColor,
8799
outlineWidth = BORDER_WIDTH,
100+
throttleTime = 0,
88101
testID
89102
} = props;
90-
const [selectedSegment, setSelectedSegment] = useState(-1);
91-
92-
const segmentsStyle = useRef([] as {x: number; width: number}[]);
93-
const segmentedControlHeight = useRef(0);
94-
const indexRef = useRef(0);
103+
const animatedSelectedIndex = useSharedValue(initialIndex);
104+
const segmentsStyle = useSharedValue([] as {x: number; width: number}[]);
105+
const segmentedControlHeight = useSharedValue(0);
95106
const segmentsCounter = useRef(0);
96-
const animatedValue = useRef(new Reanimated.Value(initialIndex));
97107

108+
// eslint-disable-next-line react-hooks/exhaustive-deps
98109
const changeIndex = useCallback(_.throttle(() => {
99-
onChangeIndex?.(indexRef.current);
110+
onChangeIndex?.(animatedSelectedIndex.value);
100111
},
101-
400,
112+
throttleTime,
102113
{trailing: true, leading: false}),
103-
[]);
114+
[throttleTime]);
104115

105-
const onSegmentPress = useCallback((index: number) => {
106-
if (selectedSegment !== index) {
107-
setSelectedSegment(index);
108-
indexRef.current = index;
109-
110-
Reanimated.timing(animatedValue.current, {
111-
toValue: index,
112-
duration: 300,
113-
easing: Easing.bezier(0.33, 1, 0.68, 1)
114-
}).start(changeIndex);
116+
useAnimatedReaction(() => {
117+
return animatedSelectedIndex.value;
118+
},
119+
(selected, previous) => {
120+
if (selected !== -1 && previous !== null && selected !== previous) {
121+
onChangeIndex && runOnJS(changeIndex)();
115122
}
116123
},
117-
[onChangeIndex, selectedSegment]);
124+
[]);
125+
126+
const onSegmentPress = useCallback((index: number) => {
127+
animatedSelectedIndex.value = index;
128+
// eslint-disable-next-line react-hooks/exhaustive-deps
129+
}, []);
118130

119131
const onLayout = useCallback((index: number, event: LayoutChangeEvent) => {
120132
const {x, width, height} = event.nativeEvent.layout;
121-
segmentsStyle.current[index] = {x, width};
122-
segmentedControlHeight.current = height - 2 * BORDER_WIDTH;
133+
segmentsStyle.value[index] = {x, width};
134+
segmentedControlHeight.value = height - 2 * BORDER_WIDTH;
123135
segmentsCounter.current++;
124136

125-
return segmentsCounter.current === segments?.length && setSelectedSegment(initialIndex);
137+
if (segmentsCounter.current === segments?.length) {
138+
animatedSelectedIndex.value = initialIndex;
139+
segmentsStyle.value = [...segmentsStyle.value];
140+
}
126141
},
142+
// eslint-disable-next-line react-hooks/exhaustive-deps
127143
[initialIndex, segments?.length]);
128144

129-
const animatedStyle = useMemo(() => {
130-
if (segmentsCounter.current === segments?.length) {
131-
const inset = interpolate(animatedValue.current, {
132-
inputRange: _.times(segmentsCounter.current),
133-
outputRange: _.map(segmentsStyle.current, segment => segment.x)
134-
});
135-
136-
const width = interpolate(animatedValue.current, {
137-
inputRange: _.times(segmentsCounter.current),
138-
outputRange: _.map(segmentsStyle.current, segment => segment.width - 2 * BORDER_WIDTH)
139-
});
140-
141-
return [{width}, Constants.isRTL ? {right: inset} : {left: inset}];
145+
const animatedStyle = useAnimatedStyle(() => {
146+
if (segmentsStyle.value.length !== 0) {
147+
const inset = withTiming(segmentsStyle.value[animatedSelectedIndex.value].x, TIMING_CONFIG);
148+
const width = withTiming(segmentsStyle.value[animatedSelectedIndex.value].width - 2 * BORDER_WIDTH,
149+
TIMING_CONFIG);
150+
const height = segmentedControlHeight.value;
151+
return Constants.isRTL ? {width, right: inset, height} : {width, left: inset, height};
142152
}
143-
return undefined;
144-
}, [segmentsCounter.current, segments?.length]);
153+
return {};
154+
});
145155

146156
const renderSegments = () =>
147157
_.map(segments, (_value, index) => {
@@ -151,7 +161,7 @@ const SegmentedControl = (props: SegmentedControlProps) => {
151161
onLayout={onLayout}
152162
index={index}
153163
onPress={onSegmentPress}
154-
isSelected={selectedSegment === index}
164+
selectedIndex={animatedSelectedIndex}
155165
activeColor={activeColor}
156166
inactiveColor={inactiveColor}
157167
{...segments?.[index]}
@@ -163,21 +173,18 @@ const SegmentedControl = (props: SegmentedControlProps) => {
163173
return (
164174
<View style={containerStyle} testID={testID}>
165175
<View row center style={[styles.container, style, {borderRadius, backgroundColor}]}>
166-
{animatedStyle && (
167-
<Reanimated.View
168-
style={[
169-
styles.selectedSegment,
170-
animatedStyle,
171-
{
172-
borderColor: outlineColor,
173-
borderRadius,
174-
backgroundColor: activeBackgroundColor,
175-
borderWidth: outlineWidth,
176-
height: segmentedControlHeight.current
177-
}
178-
]}
179-
/>
180-
)}
176+
<Reanimated.View
177+
style={[
178+
styles.selectedSegment,
179+
{
180+
borderColor: outlineColor,
181+
borderRadius,
182+
backgroundColor: activeBackgroundColor,
183+
borderWidth: outlineWidth
184+
},
185+
animatedStyle
186+
]}
187+
/>
181188
{renderSegments()}
182189
</View>
183190
</View>

src/components/segmentedControl/segment.tsx

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import React, {useCallback, useMemo} from 'react';
22
import {LayoutChangeEvent, ImageSourcePropType, ImageStyle, StyleProp} from 'react-native';
3-
import {Colors, Spacings} from '../../style';
3+
import Reanimated, {useAnimatedStyle} from 'react-native-reanimated';
4+
import {Colors, Spacings, Typography} from '../../style';
45
import {asBaseComponent} from '../../commons/new';
56
import TouchableOpacity from '../touchableOpacity';
6-
import Text from '../text';
7-
import Image from '../image';
87

98
export type SegmentedControlItemProps = {
109
/**
@@ -27,9 +26,9 @@ export type SegmentedControlItemProps = {
2726

2827
export type SegmentProps = SegmentedControlItemProps & {
2928
/**
30-
* Is the item selected.
29+
* Shared value of the current selected index.
3130
*/
32-
isSelected?: boolean;
31+
selectedIndex?: Reanimated.SharedValue<number>;
3332
/**
3433
* The color of the active segment (label and outline).
3534
*/
@@ -41,7 +40,7 @@ export type SegmentProps = SegmentedControlItemProps & {
4140
/**
4241
* Callback for when segment has pressed.
4342
*/
44-
onPress: (index: number) => void;
43+
onPress?: (index: number) => void;
4544
/**
4645
* The index of the segment.
4746
*/
@@ -62,7 +61,7 @@ const Segment = React.memo((props: SegmentProps) => {
6261
label,
6362
iconSource,
6463
iconStyle,
65-
isSelected,
64+
selectedIndex,
6665
onLayout,
6766
onPress,
6867
inactiveColor,
@@ -71,17 +70,26 @@ const Segment = React.memo((props: SegmentProps) => {
7170
testID
7271
} = props;
7372

74-
const segmentedColor = useMemo(() => (isSelected ? activeColor : inactiveColor),
75-
[isSelected, activeColor, inactiveColor]);
73+
const animatedTextStyle = useAnimatedStyle(() => {
74+
const color = selectedIndex?.value === index ? activeColor : inactiveColor;
75+
return {color};
76+
});
77+
78+
const animatedIconStyle = useAnimatedStyle(() => {
79+
const tintColor = selectedIndex?.value === index ? activeColor : inactiveColor;
80+
return {tintColor};
81+
});
7682

7783
const segmentStyle = useMemo(() => ({paddingHorizontal: Spacings.s3, paddingVertical: Spacings.s2}), []);
7884

7985
const renderIcon = useCallback(() => {
80-
return iconSource && <Image source={iconSource} style={[{tintColor: segmentedColor}, iconStyle]}/>;
81-
}, [iconSource, segmentedColor, iconStyle]);
86+
return iconSource && <Reanimated.Image source={iconSource} style={[animatedIconStyle, iconStyle]}/>;
87+
// eslint-disable-next-line react-hooks/exhaustive-deps
88+
}, [iconSource, iconStyle]);
8289

8390
const onSegmentPress = useCallback(() => {
84-
onPress(index);
91+
selectedIndex?.value !== index && onPress?.(index);
92+
// eslint-disable-next-line react-hooks/exhaustive-deps
8593
}, [index, onPress]);
8694

8795
const segmentOnLayout = useCallback((event: LayoutChangeEvent) => {
@@ -101,9 +109,9 @@ const Segment = React.memo((props: SegmentProps) => {
101109
>
102110
{!iconOnRight && renderIcon()}
103111
{label && (
104-
<Text text90 numberOfLines={1} color={segmentedColor}>
112+
<Reanimated.Text numberOfLines={1} style={[animatedTextStyle, Typography.text90]}>
105113
{label}
106-
</Text>
114+
</Reanimated.Text>
107115
)}
108116
{iconOnRight && renderIcon()}
109117
</TouchableOpacity>

0 commit comments

Comments
 (0)