Skip to content

Infra/ migrate SegmentedControl to reanimated v2 #1567

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Oct 14, 2021
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ const SectionsWheelPickerScreen = () => {
<SegmentedControl
segments={[{label: '1 section'}, {label: '2 sections'}, {label: '3 sections'}]}
onChangeIndex={onChangeIndex}
throttleTime={400}
/>
<Text text50 marginV-20>
Pick a duration
Expand Down
4 changes: 4 additions & 0 deletions generatedTypes/src/components/segmentedControl/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export declare type SegmentedControlProps = {
* Should the icon be on right of the label
*/
iconOnRight?: boolean;
/**
* Trailing throttle time of changing index in ms.
*/
throttleTime?: number;
/**
* Additional spacing styles for the container
*/
Expand Down
13 changes: 7 additions & 6 deletions generatedTypes/src/components/segmentedControl/segment.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import { LayoutChangeEvent, ImageSourcePropType, ImageStyle, StyleProp } from 'react-native';
import Reanimated from 'react-native-reanimated';
export declare type SegmentedControlItemProps = {
/**
* The label of the segment.
Expand All @@ -20,9 +21,9 @@ export declare type SegmentedControlItemProps = {
};
export declare type SegmentProps = SegmentedControlItemProps & {
/**
* Is the item selected.
* Shared value of the current selected index.
*/
isSelected?: boolean;
selectedIndex?: Reanimated.SharedValue<number>;
/**
* The color of the active segment (label and outline).
*/
Expand All @@ -34,7 +35,7 @@ export declare type SegmentProps = SegmentedControlItemProps & {
/**
* Callback for when segment has pressed.
*/
onPress: (index: number) => void;
onPress?: (index: number) => void;
/**
* The index of the segment.
*/
Expand All @@ -47,9 +48,9 @@ export declare type SegmentProps = SegmentedControlItemProps & {
};
declare const _default: React.ComponentClass<SegmentedControlItemProps & {
/**
* Is the item selected.
* Shared value of the current selected index.
*/
isSelected?: boolean | undefined;
selectedIndex?: Reanimated.SharedValue<number> | undefined;
/**
* The color of the active segment (label and outline).
*/
Expand All @@ -61,7 +62,7 @@ declare const _default: React.ComponentClass<SegmentedControlItemProps & {
/**
* Callback for when segment has pressed.
*/
onPress: (index: number) => void;
onPress?: ((index: number) => void) | undefined;
/**
* The index of the segment.
*/
Expand Down
125 changes: 66 additions & 59 deletions src/components/segmentedControl/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import _ from 'lodash';
import React, {useRef, useState, useCallback, useMemo} from 'react';
import React, {useRef, useCallback} from 'react';
import {StyleSheet, StyleProp, ViewStyle, LayoutChangeEvent} from 'react-native';
import Reanimated, {EasingNode, Easing as _Easing} from 'react-native-reanimated';
import Reanimated, {
Easing,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS
} from 'react-native-reanimated';
import {Colors, BorderRadiuses, Spacings} from '../../style';
import {asBaseComponent} from '../../commons/new';
import View from '../view';
import Segment, {SegmentedControlItemProps as SegmentProps} from './segment';
import {Constants} from 'helpers';

const {interpolate: _interpolate, interpolateNode} = Reanimated;
const interpolate = interpolateNode || _interpolate;
const Easing = EasingNode || _Easing;
const BORDER_WIDTH = 1;
const TIMING_CONFIG = {
duration: 300,
easing: Easing.bezier(0.33, 1, 0.68, 1)
};

export type SegmentedControlItemProps = SegmentProps;
export type SegmentedControlProps = {
Expand Down Expand Up @@ -59,6 +67,10 @@ export type SegmentedControlProps = {
* Should the icon be on right of the label
*/
iconOnRight?: boolean;
/**
* Trailing throttle time of changing index in ms.
*/
throttleTime?: number;
/**
* Additional spacing styles for the container
*/
Expand All @@ -85,63 +97,61 @@ const SegmentedControl = (props: SegmentedControlProps) => {
inactiveColor = Colors.grey20,
outlineColor = activeColor,
outlineWidth = BORDER_WIDTH,
throttleTime = 0,
testID
} = props;
const [selectedSegment, setSelectedSegment] = useState(-1);

const segmentsStyle = useRef([] as {x: number; width: number}[]);
const segmentedControlHeight = useRef(0);
const indexRef = useRef(0);
const animatedSelectedIndex = useSharedValue(initialIndex);
const segmentsStyle = useSharedValue([] as {x: number; width: number}[]);
const segmentedControlHeight = useSharedValue(0);
const segmentsCounter = useRef(0);
const animatedValue = useRef(new Reanimated.Value(initialIndex));

// eslint-disable-next-line react-hooks/exhaustive-deps
const changeIndex = useCallback(_.throttle(() => {
onChangeIndex?.(indexRef.current);
onChangeIndex?.(animatedSelectedIndex.value);
},
400,
throttleTime,
{trailing: true, leading: false}),
[]);
[throttleTime]);

const onSegmentPress = useCallback((index: number) => {
if (selectedSegment !== index) {
setSelectedSegment(index);
indexRef.current = index;

Reanimated.timing(animatedValue.current, {
toValue: index,
duration: 300,
easing: Easing.bezier(0.33, 1, 0.68, 1)
}).start(changeIndex);
useAnimatedReaction(() => {
return animatedSelectedIndex.value;
},
(selected, previous) => {
if (selected !== -1 && previous !== null && selected !== previous) {
onChangeIndex && runOnJS(changeIndex)();
}
},
[onChangeIndex, selectedSegment]);
[]);

const onSegmentPress = useCallback((index: number) => {
animatedSelectedIndex.value = index;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

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

return segmentsCounter.current === segments?.length && setSelectedSegment(initialIndex);
if (segmentsCounter.current === segments?.length) {
animatedSelectedIndex.value = initialIndex;
segmentsStyle.value = [...segmentsStyle.value];
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[initialIndex, segments?.length]);

const animatedStyle = useMemo(() => {
if (segmentsCounter.current === segments?.length) {
const inset = interpolate(animatedValue.current, {
inputRange: _.times(segmentsCounter.current),
outputRange: _.map(segmentsStyle.current, segment => segment.x)
});

const width = interpolate(animatedValue.current, {
inputRange: _.times(segmentsCounter.current),
outputRange: _.map(segmentsStyle.current, segment => segment.width - 2 * BORDER_WIDTH)
});

return [{width}, Constants.isRTL ? {right: inset} : {left: inset}];
const animatedStyle = useAnimatedStyle(() => {
if (segmentsStyle.value.length !== 0) {
const inset = withTiming(segmentsStyle.value[animatedSelectedIndex.value].x, TIMING_CONFIG);
const width = withTiming(segmentsStyle.value[animatedSelectedIndex.value].width - 2 * BORDER_WIDTH,
TIMING_CONFIG);
const height = segmentedControlHeight.value;
return Constants.isRTL ? {width, right: inset, height} : {width, left: inset, height};
}
return undefined;
}, [segmentsCounter.current, segments?.length]);
return {};
});

const renderSegments = () =>
_.map(segments, (_value, index) => {
Expand All @@ -151,7 +161,7 @@ const SegmentedControl = (props: SegmentedControlProps) => {
onLayout={onLayout}
index={index}
onPress={onSegmentPress}
isSelected={selectedSegment === index}
selectedIndex={animatedSelectedIndex}
activeColor={activeColor}
inactiveColor={inactiveColor}
{...segments?.[index]}
Expand All @@ -163,21 +173,18 @@ const SegmentedControl = (props: SegmentedControlProps) => {
return (
<View style={containerStyle} testID={testID}>
<View row center style={[styles.container, style, {borderRadius, backgroundColor}]}>
{animatedStyle && (
<Reanimated.View
style={[
styles.selectedSegment,
animatedStyle,
{
borderColor: outlineColor,
borderRadius,
backgroundColor: activeBackgroundColor,
borderWidth: outlineWidth,
height: segmentedControlHeight.current
}
]}
/>
)}
<Reanimated.View
style={[
styles.selectedSegment,
{
borderColor: outlineColor,
borderRadius,
backgroundColor: activeBackgroundColor,
borderWidth: outlineWidth
},
animatedStyle
]}
/>
{renderSegments()}
</View>
</View>
Expand Down
36 changes: 22 additions & 14 deletions src/components/segmentedControl/segment.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import React, {useCallback, useMemo} from 'react';
import {LayoutChangeEvent, ImageSourcePropType, ImageStyle, StyleProp} from 'react-native';
import {Colors, Spacings} from '../../style';
import Reanimated, {useAnimatedStyle} from 'react-native-reanimated';
import {Colors, Spacings, Typography} from '../../style';
import {asBaseComponent} from '../../commons/new';
import TouchableOpacity from '../touchableOpacity';
import Text from '../text';
import Image from '../image';

export type SegmentedControlItemProps = {
/**
Expand All @@ -27,9 +26,9 @@ export type SegmentedControlItemProps = {

export type SegmentProps = SegmentedControlItemProps & {
/**
* Is the item selected.
* Shared value of the current selected index.
*/
isSelected?: boolean;
selectedIndex?: Reanimated.SharedValue<number>;
/**
* The color of the active segment (label and outline).
*/
Expand All @@ -41,7 +40,7 @@ export type SegmentProps = SegmentedControlItemProps & {
/**
* Callback for when segment has pressed.
*/
onPress: (index: number) => void;
onPress?: (index: number) => void;
/**
* The index of the segment.
*/
Expand All @@ -62,7 +61,7 @@ const Segment = React.memo((props: SegmentProps) => {
label,
iconSource,
iconStyle,
isSelected,
selectedIndex,
onLayout,
onPress,
inactiveColor,
Expand All @@ -71,17 +70,26 @@ const Segment = React.memo((props: SegmentProps) => {
testID
} = props;

const segmentedColor = useMemo(() => (isSelected ? activeColor : inactiveColor),
[isSelected, activeColor, inactiveColor]);
const animatedTextStyle = useAnimatedStyle(() => {
const color = selectedIndex?.value === index ? activeColor : inactiveColor;
return {color};
});

const animatedIconStyle = useAnimatedStyle(() => {
const tintColor = selectedIndex?.value === index ? activeColor : inactiveColor;
return {tintColor};
});

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

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

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

const segmentOnLayout = useCallback((event: LayoutChangeEvent) => {
Expand All @@ -101,9 +109,9 @@ const Segment = React.memo((props: SegmentProps) => {
>
{!iconOnRight && renderIcon()}
{label && (
<Text text90 numberOfLines={1} color={segmentedColor}>
<Reanimated.Text numberOfLines={1} style={[animatedTextStyle, Typography.text90]}>
{label}
</Text>
</Reanimated.Text>
)}
{iconOnRight && renderIcon()}
</TouchableOpacity>
Expand Down