UNPKG

react-native-multiswitch-controller

Version:
277 lines (237 loc) 7.81 kB
import { type RefObject, useCallback, useEffect, useRef, useState, useMemo, useImperativeHandle, type Ref, } from 'react'; import { FlatList, type LayoutChangeEvent } from 'react-native'; import { Easing, type SharedValue, type StyleProps, runOnJS, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import type { ControlOption } from './types'; export type ControlListProps<TValue> = { options: ControlOption<TValue>[]; optionPadding: number; optionGap: number; defaultOption: TValue; onPressCallback?: (value: TValue) => void; }; export type ControlListRef<TValue> = { setForcedOption: (value: TValue | null) => void; activeOption: TValue; }; export type ControlListState<TValue> = { options: ControlOption<TValue>[]; activeOption: TValue; animatedActiveOptionIndex: SharedValue<number | null>; animatedActiveOptionStyle: StyleProps; scrollHandler: ReturnType<typeof useAnimatedScrollHandler>; controlListRef: RefObject<FlatList<ControlOption<TValue>> | null>; onLayoutOptionItem: (event: LayoutChangeEvent, index: number) => void; onAnimationFinish: ( newValue: TValue, initialAnimationCallback?: () => void ) => void; }; /** * 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<TValue>( props: ControlListProps<TValue>, ref?: Ref<ControlListRef<TValue>> ): ControlListState<TValue> { const { options, defaultOption, onPressCallback, optionGap, optionPadding } = props; const optionsRef = useRef(options); const [activeOption, setActiveOption] = useState<TValue>(defaultOption); const [forcedOption, setForcedOption] = useState<TValue | null>( defaultOption ); const [optionLayouts, setOptionLayouts] = useState<{ [optionIndex: number]: { width: number; label: string }; }>({}); const optionsJSON = useMemo(() => JSON.stringify(options), [options]); const controlListRef = useRef<FlatList<ControlOption<TValue>>>(null); const animatedTranslateX = useSharedValue(0); const animatedWidth = useSharedValue(0); const animatedScrollX = useSharedValue(0); const animatedActiveOptionIndex = useSharedValue<number | null>(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 }: LayoutChangeEvent, index: number) => { 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, }: { activeOptionValue: TValue; afterAnimationCallback?: (newValue: TValue) => void; initialAnimationCallback?: () => void; }) => { 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: TValue, callback?: () => void) => { setActiveOption(value); setForcedOption(null); callback?.(); onPressCallback?.(value); }, [onPressCallback] ); // Allows to call onChange after animation is finished. const onAnimationFinish = useCallback( (newValue: TValue, initialAnimationCallback?: () => void) => { 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;