react-native-multiswitch-controller
Version:
Smooth animated multiswitch component with dynamic width
157 lines (146 loc) • 5.96 kB
JavaScript
;
import { useCallback, useEffect, useRef, useState, useMemo, useImperativeHandle } from 'react';
import { Easing, runOnJS, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
/**
* Helps with animation of SegmentedControl and Tabs.
* To avoid fps drops while animating, SegmentedControl/Tabs needs to be animated first, and after that the content may be refreshed.
* */
function useControlListState(props, ref) {
const {
options,
defaultOption,
onPressCallback,
optionGap,
optionPadding
} = props;
const optionsRef = useRef(options);
const [activeOption, setActiveOption] = useState(defaultOption);
const [forcedOption, setForcedOption] = useState(defaultOption);
const [optionLayouts, setOptionLayouts] = useState({});
const optionsJSON = useMemo(() => JSON.stringify(options), [options]);
const controlListRef = useRef(null);
const animatedTranslateX = useSharedValue(0);
const animatedWidth = useSharedValue(0);
const animatedScrollX = useSharedValue(0);
const animatedActiveOptionIndex = useSharedValue(null);
const isFirstRender = useRef(true);
useImperativeHandle(ref, () => ({
setForcedOption,
activeOption
}), [activeOption]);
const animatedActiveOptionStyle = useAnimatedStyle(() => ({
transform: [{
translateX: animatedTranslateX.value - animatedScrollX.value
}],
width: animatedWidth.value
}));
const scrollHandler = useAnimatedScrollHandler({
onScroll: ({
contentOffset: {
x
}
}) => {
animatedScrollX.value = x;
}
});
const onLayoutOptionItem = useCallback(({
nativeEvent
}, index) => {
const option = options?.[index];
if (!option) return;
setOptionLayouts(previous => ({
...previous,
[index]: {
width: nativeEvent?.layout.width,
label: option.label
}
}));
}, [options]);
const moveActiveOption = useCallback(({
activeOptionValue,
afterAnimationCallback,
initialAnimationCallback
}) => {
const activeOptionIndex = options.findIndex(option => option.value === activeOptionValue);
const activeOptionLayout = optionLayouts[activeOptionIndex];
if (!activeOptionLayout) return;
const sumPreviousWidths = Object.values(optionLayouts).slice(0, activeOptionIndex).reduce((totalWidth, currentWidth) => totalWidth + currentWidth.width, 0);
const sumPreviousPaddingAndGaps = optionPadding + optionGap * activeOptionIndex;
const translateValue = sumPreviousWidths + sumPreviousPaddingAndGaps;
const textWidthValue = activeOptionLayout.width - optionPadding * 2;
// Allows to animate based on index e.g. text color inside list items.
animatedActiveOptionIndex.value = activeOptionIndex;
// Instant animation only for the first time SegmentedControl/Tabs is rendered.
if (!!initialAnimationCallback && isFirstRender.current) {
animatedWidth.value = textWidthValue;
animatedTranslateX.value = translateValue;
afterAnimationCallback?.(activeOptionValue);
initialAnimationCallback();
} else {
animatedWidth.value = withTiming(textWidthValue, {
duration: 200,
easing: Easing.inOut(Easing.quad)
});
animatedTranslateX.value = withTiming(translateValue, {
duration: 200,
easing: Easing.inOut(Easing.quad)
}, () => {
if (afterAnimationCallback) runOnJS(afterAnimationCallback)(activeOptionValue);
});
}
controlListRef.current?.scrollToIndex({
index: activeOptionIndex,
animated: true,
viewPosition: 0.5
});
}, [optionLayouts, animatedWidth, animatedTranslateX, animatedActiveOptionIndex, optionGap, optionPadding, options]);
// Fires after animation is finished.
const onChange = useCallback((value, callback) => {
setActiveOption(value);
setForcedOption(null);
callback?.();
onPressCallback?.(value);
}, [onPressCallback]);
// Allows to call onChange after animation is finished.
const onAnimationFinish = useCallback((newValue, initialAnimationCallback) => {
moveActiveOption({
activeOptionValue: newValue,
afterAnimationCallback: onChange,
initialAnimationCallback
});
}, [moveActiveOption, onChange]);
// Fires after initial animation is finished to allow future setting of initial value, which will force animation.
const removeInitialOption = useCallback(() => setForcedOption(null), []);
// First time animation, based on forcedOption after layout is calculated.
useEffect(() => {
if (forcedOption && Object.values(optionLayouts).length === options.length) {
removeInitialOption();
onAnimationFinish(forcedOption, () => isFirstRender.current = false);
}
}, [forcedOption, onAnimationFinish, removeInitialOption, optionLayouts, options]);
// Handle edge case when option label is changed e.g. in case of language change.
// optionLayouts is calculated synchronously for each option. We need to start animation only when all of labels are updated.
useEffect(() => {
const optionLayoutsValues = Object.values(optionLayouts);
if (optionLayoutsValues.length !== options.length) return;
const optionsPropDeepEqual = JSON.stringify(optionsRef.current) === optionsJSON;
if (optionsPropDeepEqual) return;
const isAllLabelsUpdated = options.every((option, index) => optionLayoutsValues[index]?.label === option.label);
if (!isAllLabelsUpdated) return;
optionsRef.current = options;
onAnimationFinish(activeOption);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [optionsJSON, optionLayouts]);
return {
options,
activeOption,
animatedActiveOptionIndex,
animatedActiveOptionStyle,
scrollHandler,
controlListRef,
onLayoutOptionItem,
onAnimationFinish
};
}
export default useControlListState;
//# sourceMappingURL=useControlListState.js.map