import * as React from 'react'; import { Animated, GestureResponderEvent, I18nManager, Keyboard, PanResponder, PanResponderGestureState, StyleSheet, View, } from 'react-native'; import type { EventEmitterProps, Layout, Listener, NavigationState, PagerProps, Route, } from './types'; import { useAnimatedValue } from './useAnimatedValue'; type Props = PagerProps & { layout: Layout; onIndexChange: (index: number) => void; navigationState: NavigationState; children: ( props: EventEmitterProps & { // Animated value which represents the state of current index // It can include fractional digits as it represents the intermediate value position: Animated.AnimatedInterpolation; // Function to actually render the content of the pager // The parent component takes care of rendering render: (children: React.ReactNode) => React.ReactNode; // Callback to call when switching the tab // The tab switch animation is performed even if the index in state is unchanged jumpTo: (key: string) => void; } ) => React.ReactElement; }; const DEAD_ZONE = 12; const DefaultTransitionSpec = { timing: Animated.spring, stiffness: 1000, damping: 500, mass: 3, overshootClamping: true, }; export function PanResponderAdapter({ layout, keyboardDismissMode = 'auto', swipeEnabled = true, navigationState, onIndexChange, onSwipeStart, onSwipeEnd, children, style, animationEnabled = false, }: Props) { const { routes, index } = navigationState; const panX = useAnimatedValue(0); const listenersRef = React.useRef([]); const navigationStateRef = React.useRef(navigationState); const layoutRef = React.useRef(layout); const onIndexChangeRef = React.useRef(onIndexChange); const currentIndexRef = React.useRef(index); const pendingIndexRef = React.useRef(); const swipeVelocityThreshold = 0.15; const swipeDistanceThreshold = layout.width / 1.75; const jumpToIndex = React.useCallback( (index: number, animate = animationEnabled) => { const offset = -index * layoutRef.current.width; const { timing, ...transitionConfig } = DefaultTransitionSpec; if (animate) { Animated.parallel([ timing(panX, { ...transitionConfig, toValue: offset, useNativeDriver: false, }), ]).start(({ finished }) => { if (finished) { onIndexChangeRef.current(index); pendingIndexRef.current = undefined; } }); pendingIndexRef.current = index; } else { panX.setValue(offset); onIndexChangeRef.current(index); pendingIndexRef.current = undefined; } }, [animationEnabled, panX] ); React.useEffect(() => { navigationStateRef.current = navigationState; layoutRef.current = layout; onIndexChangeRef.current = onIndexChange; }); React.useEffect(() => { const offset = -navigationStateRef.current.index * layout.width; panX.setValue(offset); }, [layout.width, panX]); React.useEffect(() => { if (keyboardDismissMode === 'auto') { Keyboard.dismiss(); } if (layout.width && currentIndexRef.current !== index) { currentIndexRef.current = index; jumpToIndex(index); } }, [jumpToIndex, keyboardDismissMode, layout.width, index]); const isMovingHorizontally = ( _: GestureResponderEvent, gestureState: PanResponderGestureState ) => { return ( Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 2) && Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 2) ); }; const canMoveScreen = ( event: GestureResponderEvent, gestureState: PanResponderGestureState ) => { if (swipeEnabled === false) { return false; } const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx; return ( isMovingHorizontally(event, gestureState) && ((diffX >= DEAD_ZONE && currentIndexRef.current > 0) || (diffX <= -DEAD_ZONE && currentIndexRef.current < routes.length - 1)) ); }; const startGesture = () => { onSwipeStart?.(); if (keyboardDismissMode === 'on-drag') { Keyboard.dismiss(); } panX.stopAnimation(); // @ts-expect-error: _value is private, but docs use it as well panX.setOffset(panX._value); }; const respondToGesture = ( _: GestureResponderEvent, gestureState: PanResponderGestureState ) => { const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx; if ( // swiping left (diffX > 0 && index <= 0) || // swiping right (diffX < 0 && index >= routes.length - 1) ) { return; } if (layout.width) { // @ts-expect-error: _offset is private, but docs use it as well const position = (panX._offset + diffX) / -layout.width; const next = position > index ? Math.ceil(position) : Math.floor(position); if (next !== index) { listenersRef.current.forEach((listener) => listener(next)); } } panX.setValue(diffX); }; const finishGesture = ( _: GestureResponderEvent, gestureState: PanResponderGestureState ) => { panX.flattenOffset(); onSwipeEnd?.(); const currentIndex = typeof pendingIndexRef.current === 'number' ? pendingIndexRef.current : currentIndexRef.current; let nextIndex = currentIndex; if ( Math.abs(gestureState.dx) > Math.abs(gestureState.dy) && Math.abs(gestureState.vx) > Math.abs(gestureState.vy) && (Math.abs(gestureState.dx) > swipeDistanceThreshold || Math.abs(gestureState.vx) > swipeVelocityThreshold) ) { nextIndex = Math.round( Math.min( Math.max( 0, I18nManager.isRTL ? currentIndex + gestureState.dx / Math.abs(gestureState.dx) : currentIndex - gestureState.dx / Math.abs(gestureState.dx) ), routes.length - 1 ) ); currentIndexRef.current = nextIndex; } if (!isFinite(nextIndex)) { nextIndex = currentIndex; } jumpToIndex(nextIndex, true); }; // TODO: use the listeners const addEnterListener = React.useCallback((listener: Listener) => { listenersRef.current.push(listener); return () => { const index = listenersRef.current.indexOf(listener); if (index > -1) { listenersRef.current.splice(index, 1); } }; }, []); const jumpTo = React.useCallback( (key: string) => { const index = navigationStateRef.current.routes.findIndex( (route: { key: string }) => route.key === key ); jumpToIndex(index); }, [jumpToIndex] ); const panResponder = PanResponder.create({ onMoveShouldSetPanResponder: canMoveScreen, onMoveShouldSetPanResponderCapture: canMoveScreen, onPanResponderGrant: startGesture, onPanResponderMove: respondToGesture, onPanResponderTerminate: finishGesture, onPanResponderRelease: finishGesture, onPanResponderTerminationRequest: () => true, }); const maxTranslate = layout.width * (routes.length - 1); const translateX = Animated.multiply( panX.interpolate({ inputRange: [-maxTranslate, 0], outputRange: [-maxTranslate, 0], extrapolate: 'clamp', }), I18nManager.isRTL ? -1 : 1 ); const position = React.useMemo( () => (layout.width ? Animated.divide(panX, -layout.width) : null), [layout.width, panX] ); return children({ position: position ?? new Animated.Value(index), addEnterListener, jumpTo, render: (children) => ( {React.Children.map(children, (child, i) => { const route = routes[i]; const focused = i === index; return ( {focused || layout.width ? child : null} ); })} ), }); } const styles = StyleSheet.create({ sheet: { flex: 1, flexDirection: 'row', alignItems: 'stretch', }, });