├── .eslintignore ├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierignore ├── LICENSE.txt ├── README.md ├── babel.config.js ├── package.json ├── src ├── index.tsx ├── pageInterpolators.ts └── useStableCallback.ts ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | /lib/* -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["react-hooks", "react-native"], 4 | "extends": [ 5 | "@react-native-community", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier", 8 | "prettier/@typescript-eslint" 9 | ], 10 | "rules": { 11 | "react-hooks/rules-of-hooks": 2, 12 | "react-hooks/exhaustive-deps": 2, 13 | "@typescript-eslint/no-unused-vars": 2, 14 | "@typescript-eslint/no-empty-function": 0, 15 | "@typescript-eslint/prefer-interface": 0, 16 | "@typescript-eslint/ban-ts-comment": 0, 17 | "@typescript-eslint/ban-types": 0, 18 | "@typescript-eslint/explicit-module-boundary-types": 0, 19 | "@typescript-eslint/array-type": 0, 20 | "@typescript-eslint/explicit-member-accessibility": 0, 21 | "@typescript-eslint/no-explicit-any": 0, 22 | "@typescript-eslint/no-non-null-assertion": 0, 23 | "@typescript-eslint/explicit-function-return-type": 0, 24 | "@typescript-eslint/no-use-before-define": 0, 25 | "@typescript-eslint/no-parameter-properties": 0, 26 | "standard/computed-property-even-spacing": 0, 27 | "standard/array-bracket-even-spacing": 0, 28 | "prettier/prettier": 0, 29 | "react/prop-types": 0, 30 | "react-native/no-inline-styles": 0, 31 | "react/no-did-mount-set-state": 0, 32 | "dot-notation": 2, 33 | "no-shadow": 0, 34 | "radix": 0, 35 | "no-bitwise": 0, 36 | "no-useless-constructor": 0, 37 | "prefer-const": 1 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [computerjazz] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/**/* 2 | /lib -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore all markdown files: 2 | *.md -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 computerjazz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Native Infinite Pager 2 | 3 | An infinitely-swipeable horizontal and vertical pager component.
4 | Fully native interactions powered by [Reanimated 2](https://github.com/kmagiera/react-native-reanimated) and [React Native Gesture Handler](https://github.com/kmagiera/react-native-gesture-handler) 5 | 6 | ![InfinitePager demo](https://i.imgur.com/5lIxuQX.gif) 7 | 8 | ## Install 9 | 10 | 1. Follow installation instructions for [reanimated](https://github.com/kmagiera/react-native-reanimated) and [react-native-gesture-handler](https://github.com/kmagiera/react-native-gesture-handler) 11 | 2. `npm install` or `yarn add` `react-native-infinite-pager` 12 | 3. `import InfinitePager from 'react-native-infinite-pager'` 13 | 14 | ### Props 15 | 16 | ```typescript 17 | type PageProps = { 18 | index: number; 19 | focusAnim: Animated.DerivedValue; 20 | isActive: boolean; 21 | pageWidthAnim: Animated.SharedValue; 22 | pageAnim: Animated.SharedValue; 23 | } 24 | 25 | type PageComponentType = (props: PageProps) => JSX.Element | null; 26 | 27 | type AnyStyle = StyleProp | ReturnType; 28 | 29 | type Props = { 30 | PageComponent?: 31 | | PageComponentType 32 | | React.MemoExoticComponent; 33 | renderPage?: PageComponentType 34 | pageCallbackNode?: Animated.SharedValue; 35 | onPageChange?: (page: number) => void; 36 | pageBuffer?: number; 37 | style?: AnyStyle; 38 | pageWrapperStyle?: AnyStyle; 39 | pageInterpolator?: typeof defaultPageInterpolator; 40 | minIndex?: number; 41 | maxIndex?: number; 42 | initialIndex?: number; 43 | simultaneousGestures?: (ComposedGesture | GestureType)[]; 44 | gesturesDisabled?: boolean; 45 | animationConfig?: Partial; 46 | vertical?: boolean; 47 | flingVelocity?: number; 48 | preset?: Preset; 49 | }; 50 | ``` 51 | 52 | | Name | Type | Description | 53 | | :----------------- | :----------------------- | :---------------------------------------------- | 54 | | `PageComponent` | `PageComponentType` | Component to be rendered as each page (either PageComponent OR renderPage must be defined, but not both — choose the version that suits your use case). | 55 | | `renderPage` | `PageComponentType` | Function to be called to render each page. | 56 | | `onPageChange` | `(page: number) => void` | Callback invoked when the current page changes. | 57 | | `style` | `AnyStyle` | Style of the pager container. | 58 | | `pageWrapperStyle` | `AnyStyle` | Style of the container that wraps each page. | 59 | | `pageCallbackNode` | `Animated.SharedValue` | SharedValue that represents the index of the current page. | 60 | | `pageBuffer` | `number` | Number of pages to render on either side of the active page. | 61 | | `pageInterpolator` | `(params: PageInterpolatorParams) => ReturnType` | Interpolator for custom page animations. | 62 | | `minIndex` | `number` | Minimum page index for non-infinite behavior (optional). | 63 | | `maxIndex` | `number` | Maximum page index for non-infinite behavior (optional). | 64 | | `initialIndex` | `number` | Index that the pager initializes at (optional). | 65 | | `simultaneousGestures` | `(ComposedGesture \| GestureType)[]` | Simultaneous RNGH gestures. | 66 | | `gesturesDisabled` | `boolean` | Disables pan gestures. | 67 | | `animationConfig` | `Partial` | Customizes paging animations. | 68 | | `vertical` | `boolean` | Sets page gesture to the vertical axis. | 69 | | `flingVelocity` | `number` | Determines sensitivity of a page-turning "fling" at the end of the gesture. | 70 | | `preset` | `Preset` | Uses a pre-configured page interpolator. | 71 | 72 | 73 | ### Imperative Api 74 | 75 | ```typescript 76 | type ImperativeApiOptions = { 77 | animated?: boolean; 78 | }; 79 | 80 | export type InfinitePagerImperativeApi = { 81 | setPage: (index: number, options: ImperativeApiOptions) => void; 82 | incrementPage: (options: ImperativeApiOptions) => void; 83 | decrementPage: (options: ImperativeApiOptions) => void; 84 | }; 85 | ``` 86 | 87 | | Name | Type | Description | 88 | | :-------------- | :------------------------------------------------------- | :------------------------- | 89 | | `incrementPage` | `(options: ImperativeApiOptions) => void` | Go to next page. | 90 | | `decrementPage` | `(options: ImperativeApiOptions) => void` | Go to previous page. | 91 | | `setPage` | `(index: number, options: ImperativeApiOptions) => void` | Go to page of given index. | 92 | 93 | ### Example 94 | 95 | https://snack.expo.dev/@computerjazz/infinite-pager 96 | 97 | ```typescript 98 | import React from "react"; 99 | import { Text, View, StyleSheet, TouchableOpacity } from "react-native"; 100 | import InfinitePager from "react-native-infinite-pager"; 101 | 102 | const NUM_ITEMS = 50; 103 | 104 | function getColor(i: number) { 105 | const multiplier = 255 / (NUM_ITEMS - 1); 106 | const colorVal = Math.abs(i) * multiplier; 107 | return `rgb(${colorVal}, ${Math.abs(128 - colorVal)}, ${255 - colorVal})`; 108 | } 109 | 110 | const Page = ({ index }: { index: number }) => { 111 | return ( 112 | 122 | {index} 123 | 124 | ); 125 | }; 126 | 127 | export default function App() { 128 | return ( 129 | 130 | 135 | 136 | ); 137 | } 138 | 139 | const styles = StyleSheet.create({ 140 | flex: { flex: 1 }, 141 | }); 142 | ``` 143 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["module:metro-react-native-babel-preset"], 3 | plugins: ["react-native-reanimated/plugin"], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-infinite-pager", 3 | "version": "0.3.18", 4 | "description": "A pager component that renders pages dynamically. Powered by reanimated.", 5 | "main": "lib/commonjs/index", 6 | "module": "lib/module/index", 7 | "react-native": "src/index.tsx", 8 | "types": "lib/typescript/index.d.ts", 9 | "author": "Daniel Merrill", 10 | "license": "MIT", 11 | "scripts": { 12 | "test": "echo \"Error: no test specified\" && exit 1", 13 | "typecheck": "tsc --skipLibCheck --noEmit", 14 | "build": "bob build", 15 | "lint": "eslint --ext .ts,.js,.tsx ." 16 | }, 17 | "husky": { 18 | "hooks": { 19 | "pre-commit": "pretty-quick --staged" 20 | } 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/computerjazz/react-native-infinite-pager.git" 25 | }, 26 | "keywords": [ 27 | "react", 28 | "native", 29 | "infinite", 30 | "pager", 31 | "swipe", 32 | "slideshow" 33 | ], 34 | "peerDependencies": { 35 | "react-native": ">=0.62.0", 36 | "react-native-gesture-handler": ">=2.0.0", 37 | "react-native-reanimated": ">=3.8.0" 38 | }, 39 | "devDependencies": { 40 | "@react-native-community/eslint-config": "^2.0.0", 41 | "@types/react": "^17.0.5", 42 | "@types/react-native": "^0.64.5", 43 | "@typescript-eslint/eslint-plugin": "^3.4.0", 44 | "@typescript-eslint/parser": "^3.4.0", 45 | "eslint": "^7.3.1", 46 | "husky": "^4.2.0", 47 | "prettier": "^2.2.1", 48 | "pretty-quick": "^2.0.1", 49 | "react": "~16.9.0", 50 | "react-native": "^0.62.2", 51 | "react-native-builder-bob": "^0.18.1", 52 | "react-native-gesture-handler": "^2.18.1", 53 | "react-native-reanimated": "^3.14.0", 54 | "typescript": "^5.3.0" 55 | }, 56 | "react-native-builder-bob": { 57 | "source": "src", 58 | "output": "lib", 59 | "targets": [ 60 | "commonjs", 61 | "module", 62 | "typescript" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useImperativeHandle, 4 | useRef, 5 | useContext, 6 | useMemo, 7 | useEffect, 8 | } from "react"; 9 | import { StyleProp, StyleSheet, ViewStyle } from "react-native"; 10 | import Animated, { 11 | useAnimatedStyle, 12 | useSharedValue, 13 | withSpring, 14 | useDerivedValue, 15 | useAnimatedReaction, 16 | runOnJS, 17 | WithSpringConfig, 18 | makeMutable, 19 | SharedValue, 20 | DerivedValue, 21 | } from "react-native-reanimated"; 22 | import { 23 | ComposedGesture, 24 | Gesture, 25 | GestureDetector, 26 | GestureType, 27 | } from "react-native-gesture-handler"; 28 | import { 29 | defaultPageInterpolator, 30 | pageInterpolatorCube, 31 | pageInterpolatorSlide, 32 | pageInterpolatorStack, 33 | pageInterpolatorTurnIn, 34 | } from "./pageInterpolators"; 35 | import { useStableCallback } from "./useStableCallback"; 36 | 37 | // dummy value to translate pages offscreen before layout is known 38 | const preInitSize = makeMutable(99999); 39 | 40 | export enum Preset { 41 | SLIDE = "slide", 42 | CUBE = "cube", 43 | STACK = "stack", 44 | TURN_IN = "turn-in", 45 | } 46 | 47 | const PageInterpolators = { 48 | [Preset.SLIDE]: pageInterpolatorSlide, 49 | [Preset.CUBE]: pageInterpolatorCube, 50 | [Preset.STACK]: pageInterpolatorStack, 51 | [Preset.TURN_IN]: pageInterpolatorTurnIn, 52 | }; 53 | 54 | export const DEFAULT_ANIMATION_CONFIG: WithSpringConfig = { 55 | damping: 20, 56 | mass: 0.2, 57 | stiffness: 100, 58 | overshootClamping: false, 59 | restSpeedThreshold: 0.2, 60 | restDisplacementThreshold: 0.2, 61 | }; 62 | 63 | export type InfinitePagerPageProps = { 64 | index: number; 65 | focusAnim: DerivedValue; 66 | isActive: boolean; 67 | pageWidthAnim: SharedValue; 68 | pageHeightAnim: SharedValue; 69 | pageAnim: SharedValue; 70 | }; 71 | 72 | type SimultaneousGesture = ComposedGesture | GestureType; 73 | 74 | export type InfinitePagerPageComponent = ( 75 | props: InfinitePagerPageProps 76 | ) => JSX.Element | null; 77 | 78 | type AnyStyle = StyleProp | ReturnType; 79 | 80 | export type InfinitePagerProps = { 81 | vertical?: boolean; 82 | PageComponent?: 83 | | InfinitePagerPageComponent 84 | | React.MemoExoticComponent; 85 | renderPage?: InfinitePagerPageComponent; 86 | pageCallbackNode?: SharedValue; 87 | syncNode?: SharedValue; 88 | onPageChange?: (page: number) => void; 89 | pageBuffer?: number; // number of pages to render on either side of active page 90 | style?: AnyStyle; 91 | pageWrapperStyle?: AnyStyle; 92 | pageInterpolator?: typeof defaultPageInterpolator; 93 | minIndex?: number; 94 | maxIndex?: number; 95 | simultaneousGestures?: SimultaneousGesture[]; 96 | gesturesDisabled?: boolean; 97 | animationConfig?: Partial; 98 | flingVelocity?: number; 99 | preset?: Preset; 100 | bouncePct?: number; 101 | debugTag?: string; 102 | width?: number; 103 | height?: number; 104 | minDistance?: number; 105 | initialIndex?: number; 106 | }; 107 | 108 | type ImperativeApiOptions = { 109 | animated?: boolean; 110 | }; 111 | 112 | export type InfinitePagerImperativeApi = { 113 | setPage: (index: number, options: ImperativeApiOptions) => void; 114 | incrementPage: (options: ImperativeApiOptions) => void; 115 | decrementPage: (options: ImperativeApiOptions) => void; 116 | gestureRef: React.MutableRefObject; 117 | }; 118 | 119 | const EMPTY_SIMULTANEOUS_GESTURES: NonNullable< 120 | InfinitePagerProps["simultaneousGestures"] 121 | > = []; 122 | const EMPTY_ANIMATION_CONFIG: NonNullable< 123 | InfinitePagerProps["animationConfig"] 124 | > = {}; 125 | 126 | function InfinitePager( 127 | { 128 | vertical = false, 129 | PageComponent, 130 | pageCallbackNode, 131 | onPageChange, 132 | pageBuffer = 1, 133 | style, 134 | pageWrapperStyle, 135 | minIndex = -Infinity, 136 | maxIndex = Infinity, 137 | simultaneousGestures = EMPTY_SIMULTANEOUS_GESTURES, 138 | gesturesDisabled, 139 | animationConfig = EMPTY_ANIMATION_CONFIG, 140 | renderPage, 141 | flingVelocity = 500, 142 | preset = Preset.SLIDE, 143 | pageInterpolator = PageInterpolators[preset], 144 | bouncePct = 0.0, 145 | debugTag = "", 146 | width, 147 | height, 148 | minDistance, 149 | initialIndex = 0, 150 | syncNode, 151 | }: InfinitePagerProps, 152 | ref: React.ForwardedRef 153 | ) { 154 | const orientation = vertical ? "vertical" : "horizontal"; 155 | 156 | const pageWidth = useSharedValue(width || 0); 157 | const pageHeight = useSharedValue(height || 0); 158 | const pageSize = vertical ? pageHeight : pageWidth; 159 | 160 | const [{ onLayoutPromise, onLayoutResolve }] = useState(() => { 161 | let _r = (_val: number) => {}; 162 | const _p = new Promise((resolve) => { 163 | _r = resolve; 164 | }); 165 | return { 166 | onLayoutPromise: _p, 167 | onLayoutResolve: _r, 168 | }; 169 | }); 170 | 171 | const _translate = useSharedValue(0); 172 | const translate = syncNode || _translate; 173 | 174 | const [curIndex, setCurIndex] = useState(initialIndex); 175 | const gestureRef = useRef(); 176 | 177 | const pageAnimInternal = useSharedValue(initialIndex); 178 | const pageAnim = pageCallbackNode || pageAnimInternal; 179 | 180 | const { activePagers, nestingDepth, pagers } = 181 | useContext(InfinitePagerContext); 182 | 183 | const parentGestures = useContext(SimultaneousGestureContext); 184 | 185 | const pagerId = useMemo(() => { 186 | return `${orientation}:${nestingDepth}:${Math.random()}`; 187 | }, [orientation, nestingDepth]); 188 | 189 | useEffect(() => { 190 | const updated = new Set(pagers.value); 191 | updated.add(pagerId); 192 | pagers.value = [...updated.values()]; 193 | return () => { 194 | const updated = new Set(pagers.value); 195 | updated.delete(pagerId); 196 | pagers.value = [...updated.values()]; 197 | }; 198 | }, [pagerId, pagers]); 199 | 200 | const curIndexRef = useRef(curIndex); 201 | curIndexRef.current = curIndex; 202 | 203 | const animCfgVal = useDerivedValue(() => animationConfig, [animationConfig]); 204 | 205 | const gesturesDisabledAnim = useDerivedValue(() => { 206 | return !!gesturesDisabled; 207 | }, [gesturesDisabled]); 208 | 209 | const setPage = useStableCallback( 210 | async (index: number, options: ImperativeApiOptions = {}) => { 211 | const layoutPageSize = await onLayoutPromise; 212 | const pSize = pageSize.value || layoutPageSize; 213 | const updatedTranslate = index * pSize * -1 + initialIndex * pSize; 214 | 215 | if (index < minIndex || index > maxIndex) return; 216 | 217 | if (options.animated) { 218 | const animCfg = { 219 | ...DEFAULT_ANIMATION_CONFIG, 220 | ...animCfgVal.value, 221 | } as WithSpringConfig; 222 | 223 | translate.value = withSpring(updatedTranslate, animCfg); 224 | } else { 225 | translate.value = updatedTranslate; 226 | } 227 | } 228 | ); 229 | 230 | useImperativeHandle( 231 | ref, 232 | () => ({ 233 | setPage, 234 | incrementPage: (options?: ImperativeApiOptions) => { 235 | setPage(curIndexRef.current + 1, options); 236 | }, 237 | decrementPage: (options?: ImperativeApiOptions) => { 238 | setPage(curIndexRef.current - 1, options); 239 | }, 240 | gestureRef, 241 | }), 242 | [setPage] 243 | ); 244 | 245 | const pageIndices = [...Array(pageBuffer * 2 + 1)].map((_, i) => { 246 | const bufferIndex = i - pageBuffer; 247 | return curIndex - bufferIndex; 248 | }); 249 | 250 | useDerivedValue(() => { 251 | if (pageSize.value) { 252 | pageAnim.value = initialIndex + (translate.value / pageSize.value) * -1; 253 | } 254 | }, [pageSize, pageAnim, translate, initialIndex]); 255 | 256 | const onPageChangeInternal = useStableCallback((pg: number) => { 257 | onPageChange?.(pg); 258 | setCurIndex(pg); 259 | }); 260 | 261 | useAnimatedReaction( 262 | () => { 263 | return Math.round(pageAnim.value); 264 | }, 265 | (cur, prev) => { 266 | if (cur !== prev) { 267 | runOnJS(onPageChangeInternal)(cur); 268 | } 269 | }, 270 | [] 271 | ); 272 | 273 | const startTranslate = useSharedValue(0); 274 | 275 | const minIndexAnim = useDerivedValue(() => { 276 | return minIndex; 277 | }, [minIndex]); 278 | const maxIndexAnim = useDerivedValue(() => { 279 | return maxIndex; 280 | }, [maxIndex]); 281 | 282 | const isMinIndex = useDerivedValue(() => { 283 | return curIndex <= minIndex; 284 | }, [curIndex, minIndex]); 285 | const isMaxIndex = useDerivedValue(() => { 286 | return curIndex >= maxIndex; 287 | }, [curIndex, maxIndex]); 288 | 289 | const isAtEdge = isMinIndex || isMaxIndex; 290 | const isAtEdgeAnim = useDerivedValue(() => { 291 | return isAtEdge; 292 | }, [isAtEdge]); 293 | 294 | const initTouchX = useSharedValue(0); 295 | const initTouchY = useSharedValue(0); 296 | 297 | const isGestureLocked = useDerivedValue(() => { 298 | // Gesture goes to the most-nested active child of both orientations 299 | // All other pagers are locked 300 | const isDeepestInOrientation = activePagers.value 301 | .filter((v) => { 302 | return v.split(":")[0] === orientation; 303 | }) 304 | .every((v) => { 305 | return Number(v.split(":")[1]) <= nestingDepth; 306 | }); 307 | return activePagers.value.length && !isDeepestInOrientation; 308 | }, [activePagers, orientation]); 309 | 310 | const panGesture = useMemo( 311 | () => 312 | Gesture.Pan() 313 | .onBegin((evt) => { 314 | "worklet"; 315 | if (!isAtEdgeAnim.value) { 316 | const updated = activePagers.value.slice(); 317 | updated.push(pagerId); 318 | activePagers.value = updated; 319 | } 320 | startTranslate.value = translate.value; 321 | initTouchX.value = evt.x; 322 | initTouchY.value = evt.y; 323 | if (debugTag) { 324 | console.log(`${debugTag} onBegin`, evt); 325 | } 326 | }) 327 | .onTouchesMove((evt, mgr) => { 328 | "worklet"; 329 | const mainTouch = evt.changedTouches[0]; 330 | 331 | const evtVal = mainTouch[vertical ? "y" : "x"]; 332 | const initTouch = vertical ? initTouchY.value : initTouchX.value; 333 | const evtTranslate = evtVal - initTouch; 334 | 335 | const swipingPastEnd = 336 | (isMinIndex.value && evtTranslate > 0) || 337 | (isMaxIndex.value && evtTranslate < 0); 338 | 339 | const shouldFailSelf = 340 | (!bouncePct && swipingPastEnd) || 341 | isGestureLocked.value || 342 | gesturesDisabledAnim.value; 343 | 344 | if (shouldFailSelf) { 345 | if (debugTag) { 346 | const failReason = swipingPastEnd ? "range" : "locked"; 347 | const failDetails = swipingPastEnd 348 | ? `${isMinIndex.value ? "min" : "max"}, ${evtTranslate}` 349 | : ""; 350 | console.log( 351 | `${debugTag}: ${failReason} fail (${failDetails})`, 352 | evt 353 | ); 354 | const updated = activePagers.value 355 | .slice() 356 | .filter((pId) => pId !== pagerId); 357 | activePagers.value = updated; 358 | } 359 | mgr.fail(); 360 | } else { 361 | if (!activePagers.value.includes(pagerId)) { 362 | const updated = activePagers.value.slice(); 363 | updated.push(pagerId); 364 | activePagers.value = updated; 365 | } 366 | } 367 | }) 368 | .onUpdate((evt) => { 369 | "worklet"; 370 | const evtTranslate = vertical ? evt.translationY : evt.translationX; 371 | const crossAxisTranslate = vertical 372 | ? evt.translationX 373 | : evt.translationY; 374 | 375 | const isSwipingCrossAxis = 376 | Math.abs(crossAxisTranslate) > 10 && 377 | Math.abs(crossAxisTranslate) > Math.abs(evtTranslate); 378 | 379 | if (isGestureLocked.value || isSwipingCrossAxis) return; 380 | 381 | if (debugTag) { 382 | console.log( 383 | `${debugTag} onUpdate: ${ 384 | isGestureLocked.value ? "(locked)" : "" 385 | }`, 386 | evt 387 | ); 388 | } 389 | 390 | const rawVal = startTranslate.value + evtTranslate; 391 | const page = initialIndex + -rawVal / pageSize.value; 392 | if (page >= minIndexAnim.value && page <= maxIndexAnim.value) { 393 | translate.value = rawVal; 394 | } else { 395 | const referenceVal = 396 | page < minIndexAnim.value 397 | ? minIndexAnim.value 398 | : maxIndexAnim.value; 399 | const pageOverflowPct = referenceVal - page; 400 | const overflowTrans = pageOverflowPct * pageSize.value; 401 | const maxBounceTrans = bouncePct * pageSize.value; 402 | const bounceTrans = pageOverflowPct * maxBounceTrans; 403 | const clampedVal = rawVal - overflowTrans; 404 | translate.value = clampedVal + bounceTrans; 405 | } 406 | }) 407 | .onEnd((evt) => { 408 | "worklet"; 409 | const evtVelocity = vertical ? evt.velocityY : evt.velocityX; 410 | const evtTranslate = vertical ? evt.translationY : evt.translationX; 411 | const crossAxisTranslate = vertical 412 | ? evt.translationX 413 | : evt.translationY; 414 | const isSwipingCrossAxis = 415 | Math.abs(crossAxisTranslate) > Math.abs(evtTranslate); 416 | 417 | const isFling = 418 | isGestureLocked.value || isSwipingCrossAxis 419 | ? false 420 | : Math.abs(evtVelocity) > flingVelocity; 421 | let velocityModifier = isFling ? pageSize.value / 2 : 0; 422 | if (evtVelocity < 0) velocityModifier *= -1; 423 | let page = 424 | initialIndex + 425 | -1 * 426 | Math.round((translate.value + velocityModifier) / pageSize.value); 427 | if (page < minIndexAnim.value) page = minIndexAnim.value; 428 | if (page > maxIndexAnim.value) page = maxIndexAnim.value; 429 | 430 | const animCfg = Object.assign( 431 | {}, 432 | DEFAULT_ANIMATION_CONFIG, 433 | animCfgVal.value 434 | ); 435 | translate.value = withSpring( 436 | -(page - initialIndex) * pageSize.value, 437 | animCfg 438 | ); 439 | if (debugTag) { 440 | console.log( 441 | `${debugTag}: onEnd (${ 442 | isGestureLocked.value ? "locked" : "unlocked" 443 | })`, 444 | evt 445 | ); 446 | } 447 | }) 448 | .onFinalize((evt) => { 449 | "worklet"; 450 | const updatedPagerIds = activePagers.value 451 | .slice() 452 | .filter((id) => id !== pagerId); 453 | activePagers.value = updatedPagerIds; 454 | 455 | if (debugTag) { 456 | console.log( 457 | `${debugTag}: onFinalize (${ 458 | isGestureLocked.value ? "locked" : "unlocked" 459 | })`, 460 | evt 461 | ); 462 | } 463 | }), 464 | [ 465 | activePagers, 466 | animCfgVal, 467 | bouncePct, 468 | debugTag, 469 | flingVelocity, 470 | gesturesDisabledAnim, 471 | initTouchX, 472 | initTouchY, 473 | initialIndex, 474 | isAtEdgeAnim, 475 | isGestureLocked, 476 | isMaxIndex, 477 | isMinIndex, 478 | maxIndexAnim, 479 | minIndexAnim, 480 | pageSize, 481 | pagerId, 482 | startTranslate, 483 | translate, 484 | vertical, 485 | ] 486 | ); 487 | 488 | panGesture.enabled(!gesturesDisabled).withRef(gestureRef); 489 | 490 | if (typeof minDistance === "number") { 491 | panGesture.minDistance(minDistance); 492 | } 493 | 494 | const externalGestures = useMemo(() => { 495 | const all = [...parentGestures, ...simultaneousGestures]; 496 | const toGestureType = all.reduce((acc, cur) => { 497 | acc.push(...cur.toGestureArray()); 498 | return acc; 499 | }, [] as GestureType[]); 500 | 501 | return toGestureType; 502 | }, [parentGestures, simultaneousGestures]); 503 | 504 | panGesture.simultaneousWithExternalGesture(...externalGestures); 505 | 506 | const allGestures = useMemo(() => { 507 | return [panGesture, ...externalGestures]; 508 | }, [panGesture, externalGestures]); 509 | 510 | const wrapperStyle = useMemo(() => { 511 | const s: StyleProp = {}; 512 | if (width) s.width = width; 513 | if (height) s.height = height; 514 | return s; 515 | }, [width, height]); 516 | 517 | return ( 518 | 519 | 520 | { 523 | pageWidth.value = layout.width; 524 | pageHeight.value = layout.height; 525 | onLayoutResolve(vertical ? layout.height : layout.width); 526 | }} 527 | > 528 | {pageIndices.map((pageIndex) => { 529 | return ( 530 | 546 | ); 547 | })} 548 | 549 | 550 | 551 | ); 552 | } 553 | 554 | type PageWrapperProps = { 555 | vertical: boolean; 556 | pageAnim: SharedValue; 557 | index: number; 558 | pageWidth: SharedValue; 559 | pageHeight: SharedValue; 560 | PageComponent?: InfinitePagerPageComponent; 561 | renderPage?: InfinitePagerPageComponent; 562 | isActive: boolean; 563 | style?: AnyStyle; 564 | pageInterpolator: typeof defaultPageInterpolator; 565 | pageBuffer: number; 566 | debugTag?: string; 567 | initialIndex: number; 568 | }; 569 | 570 | export type PageInterpolatorParams = { 571 | index: number; 572 | vertical: boolean; 573 | focusAnim: DerivedValue; 574 | pageAnim: DerivedValue; 575 | pageWidth: SharedValue; 576 | pageHeight: SharedValue; 577 | pageBuffer: number; 578 | }; 579 | 580 | const PageWrapper = React.memo( 581 | ({ 582 | index, 583 | pageAnim, 584 | pageWidth, 585 | pageHeight, 586 | vertical, 587 | PageComponent, 588 | renderPage, 589 | isActive, 590 | style, 591 | pageInterpolator, 592 | pageBuffer, 593 | initialIndex, 594 | }: PageWrapperProps) => { 595 | const pageSize = vertical ? pageHeight : pageWidth; 596 | 597 | const translation = useDerivedValue(() => { 598 | const translate = (index - pageAnim.value) * pageSize.value; 599 | return translate; 600 | }, []); 601 | 602 | const focusAnim = useDerivedValue(() => { 603 | if (!pageSize.value) { 604 | return index - initialIndex; 605 | } 606 | return translation.value / pageSize.value; 607 | }, [initialIndex]); 608 | 609 | const animStyle = useAnimatedStyle(() => { 610 | // Short circuit page interpolation to prevent buggy initial values due to possible race condition: 611 | // https://github.com/software-mansion/react-native-reanimated/issues/2571 612 | const isInitialPage = index === initialIndex; 613 | const hasInitialized = !!pageSize.value; 614 | const isInactivePageBeforeInit = !isInitialPage && !hasInitialized; 615 | const _pageWidth = isInactivePageBeforeInit ? preInitSize : pageWidth; 616 | const _pageHeight = isInactivePageBeforeInit ? preInitSize : pageHeight; 617 | return pageInterpolator({ 618 | focusAnim, 619 | pageAnim, 620 | pageWidth: _pageWidth, 621 | pageHeight: _pageHeight, 622 | index, 623 | vertical, 624 | pageBuffer, 625 | }); 626 | }, [ 627 | pageWidth, 628 | pageHeight, 629 | pageAnim, 630 | index, 631 | initialIndex, 632 | translation, 633 | vertical, 634 | pageBuffer, 635 | ]); 636 | 637 | if (PageComponent && renderPage) { 638 | console.warn( 639 | "PageComponent and renderPage both defined, defaulting to PageComponent" 640 | ); 641 | } 642 | 643 | if (!PageComponent && !renderPage) { 644 | throw new Error("Either PageComponent or renderPage must be defined."); 645 | } 646 | 647 | return ( 648 | 657 | {PageComponent ? ( 658 | 666 | ) : ( 667 | renderPage?.({ 668 | index, 669 | isActive, 670 | focusAnim, 671 | pageWidthAnim: pageWidth, 672 | pageHeightAnim: pageHeight, 673 | pageAnim, 674 | }) 675 | )} 676 | 677 | ); 678 | } 679 | ); 680 | 681 | export default React.memo(withWrappedProvider(React.forwardRef(InfinitePager))); 682 | 683 | function withWrappedProvider

( 684 | Inner: React.ComponentType

685 | ) { 686 | return React.forwardRef((props: P, ref: React.ForwardedRef) => { 687 | return ( 688 | 689 | 690 | 691 | ); 692 | }); 693 | } 694 | 695 | const styles = StyleSheet.create({ 696 | pageWrapper: { 697 | left: 0, 698 | right: 0, 699 | top: 0, 700 | bottom: 0, 701 | position: "absolute", 702 | }, 703 | activePage: { 704 | position: "relative", 705 | }, 706 | }); 707 | 708 | const InfinitePagerContext = React.createContext({ 709 | activePagers: makeMutable([] as string[]) as SharedValue, 710 | pagers: makeMutable([] as string[]) as SharedValue, 711 | nestingDepth: -1, 712 | }); 713 | 714 | const SimultaneousGestureContext = React.createContext( 715 | [] as SimultaneousGesture[] 716 | ); 717 | 718 | function SimultaneousGestureProvider({ 719 | simultaneousGestures = EMPTY_SIMULTANEOUS_GESTURES, 720 | children, 721 | }: { 722 | simultaneousGestures?: SimultaneousGesture[]; 723 | children: React.ReactNode; 724 | }) { 725 | return ( 726 | 727 | {children} 728 | 729 | ); 730 | } 731 | 732 | function InfinitePagerProvider({ children }: { children: React.ReactNode }) { 733 | const { nestingDepth, activePagers, pagers } = 734 | useContext(InfinitePagerContext); 735 | const rootPagers = useSharedValue([]); 736 | const rootActivePagers = useSharedValue([]); 737 | 738 | const value = useMemo(() => { 739 | const isRoot = nestingDepth === -1; 740 | 741 | return { 742 | nestingDepth: nestingDepth + 1, 743 | activePagers: isRoot ? rootActivePagers : activePagers, 744 | pagers: isRoot ? rootPagers : pagers, 745 | }; 746 | }, [nestingDepth, activePagers, pagers, rootPagers, rootActivePagers]); 747 | 748 | return ( 749 | 750 | {children} 751 | 752 | ); 753 | } 754 | -------------------------------------------------------------------------------- /src/pageInterpolators.ts: -------------------------------------------------------------------------------- 1 | import { Platform } from "react-native"; 2 | import { 3 | Extrapolate, 4 | interpolate, 5 | useAnimatedStyle, 6 | } from "react-native-reanimated"; 7 | import { PageInterpolatorParams } from "."; 8 | 9 | export function pageInterpolatorSlide({ 10 | focusAnim, 11 | pageWidth, 12 | pageHeight, 13 | vertical, 14 | }: PageInterpolatorParams): ReturnType { 15 | "worklet"; 16 | 17 | const translateX = vertical 18 | ? 0 19 | : interpolate( 20 | focusAnim.value, 21 | [-1, 0, 1], 22 | [-pageWidth.value, 0, pageWidth.value] 23 | ); 24 | const translateY = vertical 25 | ? interpolate( 26 | focusAnim.value, 27 | [-1, 0, 1], 28 | [-pageHeight.value, 0, pageHeight.value] 29 | ) 30 | : 0; 31 | 32 | return { 33 | transform: [{ translateX }, { translateY }], 34 | }; 35 | } 36 | 37 | export function pageInterpolatorCube({ 38 | focusAnim, 39 | pageWidth, 40 | pageHeight, 41 | vertical, 42 | }: PageInterpolatorParams) { 43 | "worklet"; 44 | 45 | const size = vertical ? pageHeight.value : pageWidth.value; 46 | 47 | // FIXME: how to calculate this programatically? 48 | const ratio = Platform.OS === "android" ? 1.23 : 2; 49 | const perspective = size; 50 | 51 | const angle = Math.atan(perspective / (size / 2)); 52 | 53 | const inputVal = interpolate(focusAnim.value, [-1, 1], [1, -1]); 54 | const inputRange = [-1, 1]; 55 | 56 | const translate = interpolate( 57 | inputVal, 58 | inputRange, 59 | [size / ratio, -size / ratio], 60 | Extrapolate.CLAMP 61 | ); 62 | 63 | const rotate = interpolate( 64 | inputVal, 65 | inputRange, 66 | [angle, -angle], 67 | Extrapolate.CLAMP 68 | ); 69 | 70 | const translate1 = interpolate( 71 | inputVal, 72 | inputRange, 73 | [size / 2, -size / 2], 74 | Extrapolate.CLAMP 75 | ); 76 | 77 | const extra = size / ratio / Math.cos(angle / 2) - size / ratio; 78 | const translate2 = interpolate( 79 | inputVal, 80 | inputRange, 81 | [-extra, extra], 82 | Extrapolate.CLAMP 83 | ); 84 | 85 | return { 86 | transform: vertical 87 | ? [ 88 | { perspective }, 89 | { translateY: translate }, 90 | { rotateX: `${-rotate}rad` }, 91 | { translateY: translate1 }, 92 | { translateY: translate2 }, 93 | ] 94 | : [ 95 | { perspective }, 96 | { translateX: translate }, 97 | { rotateY: `${rotate}rad` }, 98 | { translateX: translate1 }, 99 | { translateX: translate2 }, 100 | ], 101 | opacity: interpolate(inputVal, [-2, -1, 0, 1, 2], [0, 0.9, 1, 0.9, 0]), 102 | }; 103 | } 104 | 105 | export function pageInterpolatorStack({ 106 | focusAnim, 107 | pageWidth, 108 | pageHeight, 109 | pageBuffer, 110 | vertical, 111 | }: PageInterpolatorParams) { 112 | "worklet"; 113 | 114 | const translateX = interpolate( 115 | focusAnim.value, 116 | [-1, 0, 1], 117 | vertical ? [10, 0, -10] : [-pageWidth.value * 1.3, 0, -10] 118 | ); 119 | 120 | const translateY = interpolate( 121 | focusAnim.value, 122 | [-0.5, 0, 1], 123 | vertical ? [-pageHeight.value * 1.3, 0, -10] : [10, 0, -10] 124 | ); 125 | 126 | const opacity = interpolate( 127 | focusAnim.value, 128 | [-pageBuffer, -pageBuffer + 1, 0, pageBuffer - 1, pageBuffer], 129 | [0, 1, 1, 1, 1] 130 | ); 131 | 132 | const scale = interpolate( 133 | focusAnim.value, 134 | [-pageBuffer, -pageBuffer + 1, 0, pageBuffer - 1, pageBuffer], 135 | [0.1, 0.9, 0.9, 0.9, 0.1] 136 | ); 137 | 138 | return { 139 | transform: [{ translateX }, { translateY }, { scale }], 140 | opacity, 141 | }; 142 | } 143 | 144 | export function pageInterpolatorTurnIn({ 145 | focusAnim, 146 | pageWidth, 147 | pageHeight, 148 | vertical, 149 | }: PageInterpolatorParams) { 150 | "worklet"; 151 | 152 | const translateX = interpolate( 153 | focusAnim.value, 154 | [-1, 0, 1], 155 | vertical ? [0, 0, 0] : [-pageWidth.value * 0.4, 0, pageWidth.value * 0.4] 156 | ); 157 | 158 | const translateY = interpolate( 159 | focusAnim.value, 160 | [-1, 0, 1], 161 | vertical ? [-pageHeight.value * 0.5, 0, pageHeight.value * 0.5] : [0, 0, 0] 162 | ); 163 | 164 | const scale = interpolate(focusAnim.value, [-1, 0, 1], [0.4, 0.5, 0.4]); 165 | 166 | const rotateY = interpolate( 167 | focusAnim.value, 168 | [-1, 1], 169 | vertical ? [0, 0] : [75, -75], 170 | Extrapolate.CLAMP 171 | ); 172 | const rotateX = interpolate( 173 | focusAnim.value, 174 | [-1, 1], 175 | vertical ? [-75, 75] : [0, 0], 176 | Extrapolate.CLAMP 177 | ); 178 | 179 | return { 180 | transform: [ 181 | { perspective: 1000 }, 182 | { translateX }, 183 | { translateY }, 184 | { rotateY: `${rotateY}deg` }, 185 | { rotateX: `${rotateX}deg` }, 186 | { scale }, 187 | ], 188 | }; 189 | } 190 | 191 | export const defaultPageInterpolator = pageInterpolatorSlide; 192 | -------------------------------------------------------------------------------- /src/useStableCallback.ts: -------------------------------------------------------------------------------- 1 | // Utility hook that returns a function that never has stale dependencies, but 2 | // without changing identity, as a useCallback with dep array would. 3 | // Useful for functions that depend on external state, but 4 | // should not trigger effects when that external state changes. 5 | 6 | import { useCallback, useRef } from "react"; 7 | 8 | export function useStableCallback< 9 | T extends (arg1?: any, arg2?: any, arg3?: any) => any 10 | >(cb: T) { 11 | const cbRef = useRef(cb); 12 | cbRef.current = cb; 13 | const stableCb = useCallback( 14 | (...args: Parameters) => cbRef.current(...args), 15 | [] 16 | ); 17 | return stableCb as T; 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "jsx": "react", 5 | "lib": ["esnext"], 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "noFallthroughCasesInSwitch": true, 9 | "noImplicitReturns": true, 10 | "noImplicitUseStrict": false, 11 | "noStrictGenericChecks": false, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "strict": true, 15 | "target": "esnext" 16 | }, 17 | "exclude": ["node_modules"], 18 | "include": ["src"] 19 | } 20 | --------------------------------------------------------------------------------