import React, { useState, useImperativeHandle, useRef, useContext, useMemo, useEffect, } from "react"; import { StyleProp, StyleSheet, ViewStyle } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withSpring, useDerivedValue, useAnimatedReaction, runOnJS, WithSpringConfig, makeMutable, SharedValue, DerivedValue, } from "react-native-reanimated"; import { ComposedGesture, Gesture, GestureDetector, GestureType, } from "react-native-gesture-handler"; import { defaultPageInterpolator, pageInterpolatorCube, pageInterpolatorSlide, pageInterpolatorStack, pageInterpolatorTurnIn, } from "./pageInterpolators"; import { useStableCallback } from "./useStableCallback"; // dummy value to translate pages offscreen before layout is known const preInitSize = makeMutable(99999); export enum Preset { SLIDE = "slide", CUBE = "cube", STACK = "stack", TURN_IN = "turn-in", } const PageInterpolators = { [Preset.SLIDE]: pageInterpolatorSlide, [Preset.CUBE]: pageInterpolatorCube, [Preset.STACK]: pageInterpolatorStack, [Preset.TURN_IN]: pageInterpolatorTurnIn, }; export const DEFAULT_ANIMATION_CONFIG: WithSpringConfig = { damping: 20, mass: 0.2, stiffness: 100, overshootClamping: false, restSpeedThreshold: 0.2, restDisplacementThreshold: 0.2, }; export type InfinitePagerPageProps = { index: number; focusAnim: DerivedValue; isActive: boolean; pageWidthAnim: SharedValue; pageHeightAnim: SharedValue; pageAnim: SharedValue; }; type SimultaneousGesture = ComposedGesture | GestureType; export type InfinitePagerPageComponent = ( props: InfinitePagerPageProps ) => JSX.Element | null; type AnyStyle = StyleProp | ReturnType; export type InfinitePagerProps = { vertical?: boolean; PageComponent?: | InfinitePagerPageComponent | React.MemoExoticComponent; renderPage?: InfinitePagerPageComponent; pageCallbackNode?: SharedValue; syncNode?: SharedValue; onPageChange?: (page: number) => void; pageBuffer?: number; // number of pages to render on either side of active page style?: AnyStyle; pageWrapperStyle?: AnyStyle; pageInterpolator?: typeof defaultPageInterpolator; minIndex?: number; maxIndex?: number; simultaneousGestures?: SimultaneousGesture[]; gesturesDisabled?: boolean; animationConfig?: Partial; flingVelocity?: number; preset?: Preset; bouncePct?: number; debugTag?: string; width?: number; height?: number; minDistance?: number; initialIndex?: number; }; type ImperativeApiOptions = { animated?: boolean; }; export type InfinitePagerImperativeApi = { setPage: (index: number, options: ImperativeApiOptions) => void; incrementPage: (options: ImperativeApiOptions) => void; decrementPage: (options: ImperativeApiOptions) => void; gestureRef: React.MutableRefObject; }; const EMPTY_SIMULTANEOUS_GESTURES: NonNullable< InfinitePagerProps["simultaneousGestures"] > = []; const EMPTY_ANIMATION_CONFIG: NonNullable< InfinitePagerProps["animationConfig"] > = {}; function InfinitePager( { vertical = false, PageComponent, pageCallbackNode, onPageChange, pageBuffer = 1, style, pageWrapperStyle, minIndex = -Infinity, maxIndex = Infinity, simultaneousGestures = EMPTY_SIMULTANEOUS_GESTURES, gesturesDisabled, animationConfig = EMPTY_ANIMATION_CONFIG, renderPage, flingVelocity = 500, preset = Preset.SLIDE, pageInterpolator = PageInterpolators[preset], bouncePct = 0.0, debugTag = "", width, height, minDistance, initialIndex = 0, syncNode, }: InfinitePagerProps, ref: React.ForwardedRef ) { const orientation = vertical ? "vertical" : "horizontal"; const pageWidth = useSharedValue(width || 0); const pageHeight = useSharedValue(height || 0); const pageSize = vertical ? pageHeight : pageWidth; const [{ onLayoutPromise, onLayoutResolve }] = useState(() => { let _r = (_val: number) => {}; const _p = new Promise((resolve) => { _r = resolve; }); return { onLayoutPromise: _p, onLayoutResolve: _r, }; }); const _translate = useSharedValue(0); const translate = syncNode || _translate; const [curIndex, setCurIndex] = useState(initialIndex); const gestureRef = useRef(); const pageAnimInternal = useSharedValue(initialIndex); const pageAnim = pageCallbackNode || pageAnimInternal; const { activePagers, nestingDepth, pagers } = useContext(InfinitePagerContext); const parentGestures = useContext(SimultaneousGestureContext); const pagerId = useMemo(() => { return `${orientation}:${nestingDepth}:${Math.random()}`; }, [orientation, nestingDepth]); useEffect(() => { const updated = new Set(pagers.value); updated.add(pagerId); pagers.value = [...updated.values()]; return () => { const updated = new Set(pagers.value); updated.delete(pagerId); pagers.value = [...updated.values()]; }; }, [pagerId, pagers]); const curIndexRef = useRef(curIndex); curIndexRef.current = curIndex; const animCfgVal = useDerivedValue(() => animationConfig, [animationConfig]); const gesturesDisabledAnim = useDerivedValue(() => { return !!gesturesDisabled; }, [gesturesDisabled]); const setPage = useStableCallback( async (index: number, options: ImperativeApiOptions = {}) => { const layoutPageSize = await onLayoutPromise; const pSize = pageSize.value || layoutPageSize; const updatedTranslate = index * pSize * -1 + initialIndex * pSize; if (index < minIndex || index > maxIndex) return; if (options.animated) { const animCfg = { ...DEFAULT_ANIMATION_CONFIG, ...animCfgVal.value, } as WithSpringConfig; translate.value = withSpring(updatedTranslate, animCfg); } else { translate.value = updatedTranslate; } } ); useImperativeHandle( ref, () => ({ setPage, incrementPage: (options?: ImperativeApiOptions) => { setPage(curIndexRef.current + 1, options); }, decrementPage: (options?: ImperativeApiOptions) => { setPage(curIndexRef.current - 1, options); }, gestureRef, }), [setPage] ); const pageIndices = [...Array(pageBuffer * 2 + 1)].map((_, i) => { const bufferIndex = i - pageBuffer; return curIndex - bufferIndex; }); useDerivedValue(() => { if (pageSize.value) { pageAnim.value = initialIndex + (translate.value / pageSize.value) * -1; } }, [pageSize, pageAnim, translate, initialIndex]); const onPageChangeInternal = useStableCallback((pg: number) => { onPageChange?.(pg); setCurIndex(pg); }); useAnimatedReaction( () => { return Math.round(pageAnim.value); }, (cur, prev) => { if (cur !== prev) { runOnJS(onPageChangeInternal)(cur); } }, [] ); const startTranslate = useSharedValue(0); const minIndexAnim = useDerivedValue(() => { return minIndex; }, [minIndex]); const maxIndexAnim = useDerivedValue(() => { return maxIndex; }, [maxIndex]); const isMinIndex = useDerivedValue(() => { return curIndex <= minIndex; }, [curIndex, minIndex]); const isMaxIndex = useDerivedValue(() => { return curIndex >= maxIndex; }, [curIndex, maxIndex]); const isAtEdge = isMinIndex || isMaxIndex; const isAtEdgeAnim = useDerivedValue(() => { return isAtEdge; }, [isAtEdge]); const initTouchX = useSharedValue(0); const initTouchY = useSharedValue(0); const isGestureLocked = useDerivedValue(() => { // Gesture goes to the most-nested active child of both orientations // All other pagers are locked const isDeepestInOrientation = activePagers.value .filter((v) => { return v.split(":")[0] === orientation; }) .every((v) => { return Number(v.split(":")[1]) <= nestingDepth; }); return activePagers.value.length && !isDeepestInOrientation; }, [activePagers, orientation]); const panGesture = useMemo( () => Gesture.Pan() .onBegin((evt) => { "worklet"; if (!isAtEdgeAnim.value) { const updated = activePagers.value.slice(); updated.push(pagerId); activePagers.value = updated; } startTranslate.value = translate.value; initTouchX.value = evt.x; initTouchY.value = evt.y; if (debugTag) { console.log(`${debugTag} onBegin`, evt); } }) .onTouchesMove((evt, mgr) => { "worklet"; const mainTouch = evt.changedTouches[0]; const evtVal = mainTouch[vertical ? "y" : "x"]; const initTouch = vertical ? initTouchY.value : initTouchX.value; const evtTranslate = evtVal - initTouch; const swipingPastEnd = (isMinIndex.value && evtTranslate > 0) || (isMaxIndex.value && evtTranslate < 0); const shouldFailSelf = (!bouncePct && swipingPastEnd) || isGestureLocked.value || gesturesDisabledAnim.value; if (shouldFailSelf) { if (debugTag) { const failReason = swipingPastEnd ? "range" : "locked"; const failDetails = swipingPastEnd ? `${isMinIndex.value ? "min" : "max"}, ${evtTranslate}` : ""; console.log( `${debugTag}: ${failReason} fail (${failDetails})`, evt ); const updated = activePagers.value .slice() .filter((pId) => pId !== pagerId); activePagers.value = updated; } mgr.fail(); } else { if (!activePagers.value.includes(pagerId)) { const updated = activePagers.value.slice(); updated.push(pagerId); activePagers.value = updated; } } }) .onUpdate((evt) => { "worklet"; const evtTranslate = vertical ? evt.translationY : evt.translationX; const crossAxisTranslate = vertical ? evt.translationX : evt.translationY; const isSwipingCrossAxis = Math.abs(crossAxisTranslate) > 10 && Math.abs(crossAxisTranslate) > Math.abs(evtTranslate); if (isGestureLocked.value || isSwipingCrossAxis) return; if (debugTag) { console.log( `${debugTag} onUpdate: ${ isGestureLocked.value ? "(locked)" : "" }`, evt ); } const rawVal = startTranslate.value + evtTranslate; const page = initialIndex + -rawVal / pageSize.value; if (page >= minIndexAnim.value && page <= maxIndexAnim.value) { translate.value = rawVal; } else { const referenceVal = page < minIndexAnim.value ? minIndexAnim.value : maxIndexAnim.value; const pageOverflowPct = referenceVal - page; const overflowTrans = pageOverflowPct * pageSize.value; const maxBounceTrans = bouncePct * pageSize.value; const bounceTrans = pageOverflowPct * maxBounceTrans; const clampedVal = rawVal - overflowTrans; translate.value = clampedVal + bounceTrans; } }) .onEnd((evt) => { "worklet"; const evtVelocity = vertical ? evt.velocityY : evt.velocityX; const evtTranslate = vertical ? evt.translationY : evt.translationX; const crossAxisTranslate = vertical ? evt.translationX : evt.translationY; const isSwipingCrossAxis = Math.abs(crossAxisTranslate) > Math.abs(evtTranslate); const isFling = isGestureLocked.value || isSwipingCrossAxis ? false : Math.abs(evtVelocity) > flingVelocity; let velocityModifier = isFling ? pageSize.value / 2 : 0; if (evtVelocity < 0) velocityModifier *= -1; let page = initialIndex + -1 * Math.round((translate.value + velocityModifier) / pageSize.value); if (page < minIndexAnim.value) page = minIndexAnim.value; if (page > maxIndexAnim.value) page = maxIndexAnim.value; const animCfg = Object.assign( {}, DEFAULT_ANIMATION_CONFIG, animCfgVal.value ); translate.value = withSpring( -(page - initialIndex) * pageSize.value, animCfg ); if (debugTag) { console.log( `${debugTag}: onEnd (${ isGestureLocked.value ? "locked" : "unlocked" })`, evt ); } }) .onFinalize((evt) => { "worklet"; const updatedPagerIds = activePagers.value .slice() .filter((id) => id !== pagerId); activePagers.value = updatedPagerIds; if (debugTag) { console.log( `${debugTag}: onFinalize (${ isGestureLocked.value ? "locked" : "unlocked" })`, evt ); } }), [ activePagers, animCfgVal, bouncePct, debugTag, flingVelocity, gesturesDisabledAnim, initTouchX, initTouchY, initialIndex, isAtEdgeAnim, isGestureLocked, isMaxIndex, isMinIndex, maxIndexAnim, minIndexAnim, pageSize, pagerId, startTranslate, translate, vertical, ] ); panGesture.enabled(!gesturesDisabled).withRef(gestureRef); if (typeof minDistance === "number") { panGesture.minDistance(minDistance); } const externalGestures = useMemo(() => { const all = [...parentGestures, ...simultaneousGestures]; const toGestureType = all.reduce((acc, cur) => { acc.push(...cur.toGestureArray()); return acc; }, [] as GestureType[]); return toGestureType; }, [parentGestures, simultaneousGestures]); panGesture.simultaneousWithExternalGesture(...externalGestures); const allGestures = useMemo(() => { return [panGesture, ...externalGestures]; }, [panGesture, externalGestures]); const wrapperStyle = useMemo(() => { const s: StyleProp = {}; if (width) s.width = width; if (height) s.height = height; return s; }, [width, height]); return ( { pageWidth.value = layout.width; pageHeight.value = layout.height; onLayoutResolve(vertical ? layout.height : layout.width); }} > {pageIndices.map((pageIndex) => { return ( ); })} ); } type PageWrapperProps = { vertical: boolean; pageAnim: SharedValue; index: number; pageWidth: SharedValue; pageHeight: SharedValue; PageComponent?: InfinitePagerPageComponent; renderPage?: InfinitePagerPageComponent; isActive: boolean; style?: AnyStyle; pageInterpolator: typeof defaultPageInterpolator; pageBuffer: number; debugTag?: string; initialIndex: number; }; export type PageInterpolatorParams = { index: number; vertical: boolean; focusAnim: DerivedValue; pageAnim: DerivedValue; pageWidth: SharedValue; pageHeight: SharedValue; pageBuffer: number; }; const PageWrapper = React.memo( ({ index, pageAnim, pageWidth, pageHeight, vertical, PageComponent, renderPage, isActive, style, pageInterpolator, pageBuffer, initialIndex, }: PageWrapperProps) => { const pageSize = vertical ? pageHeight : pageWidth; const translation = useDerivedValue(() => { const translate = (index - pageAnim.value) * pageSize.value; return translate; }, []); const focusAnim = useDerivedValue(() => { if (!pageSize.value) { return index - initialIndex; } return translation.value / pageSize.value; }, [initialIndex]); const animStyle = useAnimatedStyle(() => { // Short circuit page interpolation to prevent buggy initial values due to possible race condition: // https://github.com/software-mansion/react-native-reanimated/issues/2571 const isInitialPage = index === initialIndex; const hasInitialized = !!pageSize.value; const isInactivePageBeforeInit = !isInitialPage && !hasInitialized; const _pageWidth = isInactivePageBeforeInit ? preInitSize : pageWidth; const _pageHeight = isInactivePageBeforeInit ? preInitSize : pageHeight; return pageInterpolator({ focusAnim, pageAnim, pageWidth: _pageWidth, pageHeight: _pageHeight, index, vertical, pageBuffer, }); }, [ pageWidth, pageHeight, pageAnim, index, initialIndex, translation, vertical, pageBuffer, ]); if (PageComponent && renderPage) { console.warn( "PageComponent and renderPage both defined, defaulting to PageComponent" ); } if (!PageComponent && !renderPage) { throw new Error("Either PageComponent or renderPage must be defined."); } return ( {PageComponent ? ( ) : ( renderPage?.({ index, isActive, focusAnim, pageWidthAnim: pageWidth, pageHeightAnim: pageHeight, pageAnim, }) )} ); } ); export default React.memo(withWrappedProvider(React.forwardRef(InfinitePager))); function withWrappedProvider

( Inner: React.ComponentType

) { return React.forwardRef((props: P, ref: React.ForwardedRef) => { return ( ); }); } const styles = StyleSheet.create({ pageWrapper: { left: 0, right: 0, top: 0, bottom: 0, position: "absolute", }, activePage: { position: "relative", }, }); const InfinitePagerContext = React.createContext({ activePagers: makeMutable([] as string[]) as SharedValue, pagers: makeMutable([] as string[]) as SharedValue, nestingDepth: -1, }); const SimultaneousGestureContext = React.createContext( [] as SimultaneousGesture[] ); function SimultaneousGestureProvider({ simultaneousGestures = EMPTY_SIMULTANEOUS_GESTURES, children, }: { simultaneousGestures?: SimultaneousGesture[]; children: React.ReactNode; }) { return ( {children} ); } function InfinitePagerProvider({ children }: { children: React.ReactNode }) { const { nestingDepth, activePagers, pagers } = useContext(InfinitePagerContext); const rootPagers = useSharedValue([]); const rootActivePagers = useSharedValue([]); const value = useMemo(() => { const isRoot = nestingDepth === -1; return { nestingDepth: nestingDepth + 1, activePagers: isRoot ? rootActivePagers : activePagers, pagers: isRoot ? rootPagers : pagers, }; }, [nestingDepth, activePagers, pagers, rootPagers, rootActivePagers]); return ( {children} ); }