├── .gitignore ├── .npmignore ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── Readme.md ├── babel.config.js ├── demo.gif ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── index.test.ts ├── index.tsx ├── use-measure.ts └── use-previous.ts ├── stories ├── Filter-example.tsx ├── Images-example.tsx ├── Scrolling-example.tsx ├── images.css └── intro.stories.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | build 4 | dist 5 | .rpt2_cache 6 | .DS_Store 7 | .env 8 | .env.local 9 | .env.development.local 10 | .env.test.local 11 | .env.production.local 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | umd 16 | esm 17 | cjs -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmcmahen/react-page-controller/99769431969b3bdb86b2b5aecbf8202eb5ba17ff/.npmignore -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmcmahen/react-page-controller/99769431969b3bdb86b2b5aecbf8202eb5ba17ff/.storybook/addons.js -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | 3 | const req = require.context("../stories", true, /.stories.tsx$/); 4 | 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ config, mode }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | use: [ 5 | { 6 | loader: require.resolve("babel-loader") 7 | }, 8 | { 9 | loader: require.resolve("awesome-typescript-loader") 10 | } 11 | ] 12 | }); 13 | 14 | config.resolve.extensions.push(".ts", ".tsx", ".json"); 15 | return config; 16 | }; 17 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |
2 | A demo showing views being swiped left and right. 6 |
7 | 8 | # react-page-controller 9 | 10 | [![npm package](https://img.shields.io/npm/v/react-page-controller/latest.svg)](https://www.npmjs.com/package/react-page-controller) 11 | [![Follow on Twitter](https://img.shields.io/twitter/follow/benmcmahen.svg?style=social&logo=twitter)](https://twitter.com/intent/follow?screen_name=benmcmahen) 12 | 13 | React-page-controller is a react library for providing views that can be swiped left or right. It was originally built for use in [Sancho UI](https://github.com/bmcmahen/sancho) and is inspired by the iOS library of the same name. 14 | 15 | ## Features 16 | 17 | - **Built with [react-gesture-responder](https://github.com/bmcmahen/react-gesture-responder) to enable better control over gesture delegation.** This means that you can embed gesture based controls within this gesture view (or embed multiple gesture views within eachother) and delegate between them. 18 | - **Configurable**. Configure the animation spring, enable mouse support, use child render callbacks, etc. 19 | - **Optional lazy loading**. 20 | 21 | ## Install 22 | 23 | Install `react-page-controller` and its peer dependency `react-gesture-responder` using yarn or npm. 24 | 25 | ``` 26 | yarn add react-page-controller react-gesture-responder 27 | ``` 28 | 29 | ## Basic usage 30 | 31 | The gesture view should be provided with a collection of children, each representing a panel. By default, each child will be wrapped in an element wiith the recommended props. If you'd rather render the element yourself, provide a render callback for each child instead. 32 | 33 | ```jsx 34 | import Pager from "react-page-controller"; 35 | 36 | function TabContent() { 37 | const [index, setIndex] = React.useState(0); 38 | return ( 39 | setIndex(i)}> 40 |
First panel
41 |
Second panel
42 |
Third panel
43 | {(props, active, load) =>
fourth panel
} 44 |
45 | ); 46 | } 47 | ``` 48 | 49 | ## API 50 | 51 | | Name | Type | Default Value | Description | 52 | | -------------------- | --------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------- | 53 | | value\* | number | | The current index to show | 54 | | onRequestChange\* | (value: number) => void; | | A callback for handling index changes | 55 | | lazyLoad | boolean | false | Lazy load pane contents | 56 | | enableMouse | boolean | false | By default mouse gestures are not enabled | 57 | | enableGestures | boolean | true | By default gestures are enabled | 58 | | animationConfig | SpringConfig | { tension: 190, friction: 20, mass: 0.4 } | A react-spring config for animations | 59 | | onTerminationRequest | (state) => boolean; | | Optionally prevent parent views from claiming the pan-responder. Useful for embedded gesture views | 60 | | onMoveShouldSet | (state, e, suggested) => boolean; | | Optionally override the default onMoveShouldSet behaviour. Useful for embedding multiple gesture views. | 61 | | enableScrollLock | boolean | true | Lock all page scrolling when making swiping gestures. This is generally the desired behaviour. | 62 | 63 | ## Imperative API 64 | 65 | You can use the imperative API to manually focus the active panel, which is something you'll likely want to do for accessibility reasons. 66 | 67 | ```jsx 68 | function TabContent() { 69 | const [index, setIndex] = React.useState(0); 70 | const ref = React.useRef(); 71 | 72 | function focusCurrentIndex() { 73 | ref.current.focus(); 74 | } 75 | 76 | return ( 77 | setIndex(i)}> 78 |
First panel
79 |
Second panel
80 |
Third panel
81 |
82 | ); 83 | } 84 | ``` 85 | 86 | ## Embedding Views 87 | 88 | Each Pager exposes the `react-gesture-responder` `onTerminationRequest` function which allows you to negotiate between gesture views competing for the responder. Typically, you'll want the child view to prevent the parent from claiming the responder. 89 | 90 | ```jsx 91 | 92 |
Left parent pane
93 | false}> 94 |
child pane
95 |
another child
96 |
97 |
98 | ``` 99 | 100 | The logic can become more sophisticated. In the gif at the top of the readme, our parent claims the responder (and prevents the child from stealing it) when showing the first child pane and moving left. The code will look something like this: 101 | 102 | ```jsx 103 | const [childIndex, setChildIndex] = React.useState(0); 104 | const [parentIndex, setParentIndex] = React.useState(0); 105 | 106 | function onMoveShouldSet(state, e, suggested) { 107 | if (suggested) { 108 | if (parentIndex === 0 || (state.delta[0] > 0 && childIndex === 0)) { 109 | return true; 110 | } 111 | } 112 | 113 | return false; 114 | } 115 | 116 | function onTerminationRequest(state) { 117 | if (state.delta[0] > 0 && childIndex === 0) { 118 | return false; 119 | } 120 | 121 | return true; 122 | } 123 | 124 | setParentIndex(i)} 129 | > 130 |
Left parent pane
131 | setChildIndex(i)}> 132 |
child pane
133 |
another child
134 |
135 |
; 136 | ``` 137 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | useBuiltIns: "usage", 7 | corejs: 2, 8 | targets: { node: "6" } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | env: { 15 | test: { 16 | plugins: ["require-context-hook"] 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmcmahen/react-page-controller/99769431969b3bdb86b2b5aecbf8202eb5ba17ff/demo.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-page-controller", 3 | "version": "3.0.1", 4 | "description": "horizontal, swipeable panes built for react", 5 | "main": "cjs/index.js", 6 | "module": "esm/index.js", 7 | "typings": "esm/index.d.ts", 8 | "author": "Ben McMahen ", 9 | "license": "MIT", 10 | "private": false, 11 | "bugs": { 12 | "url": "https://github.com/bmcmahen/react-page-controller/issues" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/bmcmahen/react-page-controller.git" 17 | }, 18 | "scripts": { 19 | "test": "jest", 20 | "test-watch": "jest -w", 21 | "storybook": "start-storybook -p 6006", 22 | "build-esm": "rimraf esm && tsc", 23 | "build-cjs": "rimraf cjs && tsc --module commonjs --outDir cjs", 24 | "build-umd": "rimraf umd && rollup -c", 25 | "build": "yarn run build-esm && yarn run build-cjs && yarn run build-umd", 26 | "prepublishOnly": "yarn run build" 27 | }, 28 | "peerDependencies": { 29 | "react": "^16.8.6", 30 | "react-dom": "^16.8.6", 31 | "react-gesture-responder": "^2.1.0" 32 | }, 33 | "devDependencies": { 34 | "@babel/core": "^7.4.0", 35 | "@babel/preset-env": "^7.4.2", 36 | "@babel/preset-react": "^7.0.0", 37 | "@babel/preset-typescript": "^7.3.3", 38 | "@storybook/react": "^5.0.5", 39 | "@types/jest": "^24.0.11", 40 | "@types/lodash-es": "^4.17.3", 41 | "@types/storybook__react": "^4.0.1", 42 | "awesome-typescript-loader": "^5.2.1", 43 | "babel-core": "^6.26.3", 44 | "babel-jest": "^24.5.0", 45 | "babel-loader": "^8.0.5", 46 | "babel-plugin-require-context-hook": "^1.0.0", 47 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 48 | "classnames": "^2.2.6", 49 | "jest": "^24.5.0", 50 | "lodash-es": "^4.17.11", 51 | "lodash-move": "^1.1.1", 52 | "pan-responder-hook": "^1.1.4", 53 | "react": "^16.8.6", 54 | "react-dom": "^16.8.6", 55 | "react-gesture-responder": "^2.1.0", 56 | "rimraf": "^2.6.3", 57 | "rollup": "^1.7.4", 58 | "rollup-plugin-babel": "^4.3.2", 59 | "rollup-plugin-cleanup": "^3.1.1", 60 | "rollup-plugin-commonjs": "^9.2.2", 61 | "rollup-plugin-filesize": "^6.0.1", 62 | "rollup-plugin-json": "^4.0.0", 63 | "rollup-plugin-node-resolve": "^4.0.1", 64 | "rollup-plugin-sourcemaps": "^0.4.2", 65 | "rollup-plugin-typescript2": "^0.20.1", 66 | "rollup-plugin-uglify": "^6.0.2", 67 | "touchable-hook": "^1.1.3", 68 | "ts-jest": "^24.0.1", 69 | "typescript": "^3.5.0", 70 | "webpack": "^4.29.6" 71 | }, 72 | "dependencies": { 73 | "@types/classnames": "^2.2.7", 74 | "@types/react": "^16.8.10", 75 | "@types/react-dom": "^16.8.3", 76 | "react-spring": "^9.0.0-beta.31", 77 | "resize-observer-polyfill": "^1.5.1", 78 | "tslib": "^1.9.3", 79 | "use-scroll-lock": "^1.0.0" 80 | }, 81 | "sideEffects": false 82 | } 83 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import filesize from "rollup-plugin-filesize"; 3 | import { uglify } from "rollup-plugin-uglify"; 4 | import pkg from "./package.json"; 5 | import commonjs from "rollup-plugin-commonjs"; 6 | import cleanup from "rollup-plugin-cleanup"; 7 | import json from "rollup-plugin-json"; 8 | 9 | const commonjsOptions = { 10 | ignoreGlobal: true, 11 | include: /node_modules/ 12 | }; 13 | 14 | const input = pkg.main; 15 | 16 | const plugins = [ 17 | resolve(), 18 | commonjs(commonjsOptions), 19 | json(), 20 | cleanup(), 21 | uglify(), 22 | filesize() 23 | ]; 24 | 25 | const globals = { 26 | react: "React", 27 | "react-doc": "ReactDOM" 28 | }; 29 | 30 | const capitalize = s => { 31 | if (typeof s !== "string") return ""; 32 | return s.charAt(0).toUpperCase() + s.slice(1); 33 | }; 34 | 35 | export default [ 36 | { 37 | input, 38 | output: { 39 | file: "umd/react-gesture-view.js", 40 | format: "umd", 41 | name: capitalize("react-gesture-view"), 42 | globals 43 | }, 44 | external: Object.keys(globals), 45 | plugins 46 | } 47 | ]; 48 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | test("hello", () => { 2 | expect("hello").toBeTruthy(); 3 | }); 4 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { 3 | useGestureResponder, 4 | StateType, 5 | Callbacks, 6 | ResponderEvent 7 | } from "react-gesture-responder"; 8 | import { animated, useSpring, SpringConfig } from "react-spring"; 9 | import { useMeasure } from "./use-measure"; 10 | import useScrollLock from "use-scroll-lock"; 11 | import { usePrevious } from "./use-previous"; 12 | 13 | /** 14 | * ReactPager 15 | * 16 | * Provide views that can be swiped left or right (with touch devices). 17 | */ 18 | 19 | export interface PagerProps extends React.HTMLAttributes { 20 | children: Array; 21 | value: number; 22 | enableMouse?: boolean; 23 | enableGestures?: boolean; 24 | focusOnChange?: boolean; 25 | enableScrollLock?: boolean; 26 | onRequestChange: (value: number) => void; 27 | animationConfig?: SpringConfig; 28 | lazyLoad?: boolean; 29 | onSetLazy?: (suggestedIndex: number) => number[]; 30 | onTerminationRequest?: Callbacks["onTerminationRequest"]; 31 | /** Optionally override onMoveShouldSet defaults */ 32 | onMoveShouldSet?: ( 33 | state: StateType, 34 | e: ResponderEvent, 35 | suggested: boolean 36 | ) => boolean; 37 | } 38 | 39 | export interface PagerHandles { 40 | focus(i?: number): void; 41 | } 42 | 43 | export interface CallbackProps { 44 | style: React.CSSProperties; 45 | "aria-hidden": boolean; 46 | ref: (el: HTMLDivElement | null) => void; 47 | } 48 | 49 | export type PagerChildCallback = ( 50 | props: CallbackProps, 51 | active: boolean, 52 | load: boolean 53 | ) => React.ReactNode; 54 | 55 | const Pager: React.RefForwardingComponent = ( 56 | { 57 | children, 58 | id, 59 | value: index, 60 | onRequestChange, 61 | focusOnChange = false, 62 | enableScrollLock = true, 63 | enableGestures = true, 64 | enableMouse = false, 65 | lazyLoad = false, 66 | onSetLazy, 67 | animationConfig = { tension: 190, friction: 20, mass: 0.4 }, 68 | onTerminationRequest, 69 | onMoveShouldSet, 70 | style, 71 | ...other 72 | }, 73 | ref 74 | ) => { 75 | const containerRef = React.useRef(null); 76 | const [isDragging, setIsDragging] = React.useState(false); 77 | const [loaded, setLoaded] = React.useState( 78 | () => new Set(onSetLazy ? onSetLazy(index) : [index]) 79 | ); 80 | const { width } = useMeasure(containerRef); 81 | const childrenRefs = React.useRef>( 82 | new Map() 83 | ); 84 | 85 | const previousIndex = usePrevious(index); 86 | const shouldFocusRef = React.useRef(null); 87 | 88 | useScrollLock(isDragging && enableScrollLock); 89 | 90 | React.useEffect(() => { 91 | if (typeof previousIndex === "number" && previousIndex !== index) { 92 | shouldFocusRef.current = index; 93 | } else { 94 | shouldFocusRef.current = null; 95 | } 96 | }, [previousIndex, index]); 97 | 98 | function focusByIndex(i: number) { 99 | const el = childrenRefs.current.get(i); 100 | if (el) { 101 | el.focus(); 102 | } 103 | } 104 | 105 | // expose an imperative focus function which focuses 106 | // the currently active index 107 | React.useImperativeHandle(ref, () => ({ 108 | focus: (i?: number) => { 109 | focusByIndex(i || index); 110 | } 111 | })); 112 | 113 | const [{ x }, set] = useSpring(() => ({ 114 | x: index * -100, 115 | config: animationConfig 116 | })); 117 | 118 | /** 119 | * Potentially autofocus after our animation 120 | */ 121 | 122 | function onRest() { 123 | if (typeof shouldFocusRef.current === "number") { 124 | focusByIndex(shouldFocusRef.current); 125 | } 126 | } 127 | 128 | const renderableChildren = children.filter(child => child !== null); 129 | 130 | // gesture view counts 131 | const childCount = renderableChildren.length; 132 | const maxIndex = childCount - 1; 133 | const minIndex = 0; 134 | 135 | /** 136 | * Prevent invalid indexes 137 | */ 138 | 139 | function isValidNextIndex(index: number) { 140 | return index > 0 && index <= maxIndex; 141 | } 142 | 143 | /** 144 | * We keep a set of indexes that should 145 | * be loaded for lazy loading. 146 | */ 147 | 148 | function addIndexToLoaded(index: number) { 149 | if (!isValidNextIndex(index)) { 150 | return; 151 | } 152 | 153 | let indexes: number | number[] = index; 154 | 155 | // allow the user to customize which indexes to load 156 | if (onSetLazy) { 157 | indexes = onSetLazy(index); 158 | } 159 | 160 | const indexesArray = Array.isArray(indexes) ? indexes : [indexes]; 161 | const next = new Set(loaded); 162 | 163 | indexesArray.forEach(i => { 164 | // don't set items which are already loaded or are invalid 165 | if (loaded.has(i) || !isValidNextIndex(index)) { 166 | return; 167 | } 168 | 169 | next.add(i); 170 | }); 171 | 172 | setLoaded(next); 173 | } 174 | 175 | // animate into position if our index changes 176 | React.useEffect(() => { 177 | set({ 178 | x: index * -100, 179 | onRest 180 | }); 181 | loaded.add(index); 182 | }, [index]); 183 | 184 | /** 185 | * Handle gesture end event (either touchend 186 | * or pan responder termination). 187 | */ 188 | 189 | function releaseToPosition(x: number) { 190 | // if it's over 50% in either direction, move to that index. 191 | // otherwise, snap back to existing index. 192 | const threshold = width / 2; 193 | if (Math.abs(x) > threshold) { 194 | if (x < 0 && index < maxIndex) { 195 | onRequestChange(index + 1); 196 | } else if (x > 0 && index > minIndex) { 197 | onRequestChange(index - 1); 198 | } else { 199 | set({ x: index * -100 }); 200 | } 201 | } else { 202 | // return back! 203 | set({ x: index * -100, onRest }); 204 | } 205 | } 206 | 207 | function onTermination({ delta }: StateType) { 208 | setIsDragging(false); 209 | releaseToPosition(delta[0]); 210 | } 211 | 212 | function onEnd({ delta, velocity, direction }: StateType) { 213 | const [x] = delta; 214 | setIsDragging(false); 215 | 216 | // 1. If the force is great enough, switch to the previous index 217 | if (velocity > 0.2 && direction[0] > 0 && index > minIndex) { 218 | return onRequestChange(index - 1); 219 | } 220 | 221 | // or the next index, depending on direction 222 | if (velocity > 0.2 && direction[0] < 0 && index < maxIndex) { 223 | return onRequestChange(index + 1); 224 | } 225 | 226 | releaseToPosition(x); 227 | } 228 | 229 | /** 230 | * Observe our pan-responder to enable gestures 231 | */ 232 | 233 | const { bind } = useGestureResponder( 234 | { 235 | onTerminationRequest, 236 | onStartShouldSet: () => { 237 | if (!enableGestures) { 238 | return false; 239 | } 240 | 241 | return false; 242 | }, 243 | onMoveShouldSet: (state, e) => { 244 | const { initial, xy, initialDirection } = state; 245 | if (!enableGestures) { 246 | return false; 247 | } 248 | 249 | const set = initialDirection[0] != 0; 250 | 251 | // allow the user to tap into this component to potentially 252 | // override it 253 | if (onMoveShouldSet) { 254 | return onMoveShouldSet(state, e, set); 255 | } 256 | 257 | return set; 258 | }, 259 | onGrant: () => { 260 | setIsDragging(true); 261 | }, 262 | onMove: ({ delta, direction }) => { 263 | const [x] = delta; 264 | const xPos = (x / width) * 100 + index * -100; 265 | 266 | set({ 267 | x: xPos, 268 | immediate: true, 269 | onRest: () => {} 270 | }); 271 | 272 | // lazy load the item we are swiping towards 273 | addIndexToLoaded(direction[0] > 0 ? index - 1 : index + 1); 274 | }, 275 | onRelease: onEnd, 276 | onTerminate: onTermination 277 | }, 278 | { 279 | uid: id, 280 | enableMouse 281 | } 282 | ); 283 | 284 | return ( 285 |
298 | `translateX(${taper(x, maxIndex * -100)}%)` 309 | ) 310 | }} 311 | > 312 | {renderableChildren.map((child, i) => { 313 | const styles: React.CSSProperties = { 314 | display: "flex", 315 | flexDirection: "column", 316 | width: "100%", 317 | alignSelf: "stretch", 318 | justifyContent: "flex-start", 319 | flexShrink: 0, 320 | height: "100%", 321 | overflow: "hidden", 322 | outline: "none" 323 | }; 324 | 325 | const props = { 326 | key: i, 327 | tabIndex: index === i ? 0 : -1, 328 | style: styles, 329 | "aria-hidden": i !== index, 330 | ref: (el: HTMLDivElement | null) => { 331 | childrenRefs.current!.set(i, el); 332 | } 333 | }; 334 | 335 | const load = !lazyLoad || index === i || loaded.has(i); 336 | 337 | if (typeof child === "function") { 338 | return child(props, index === i, load); 339 | } 340 | 341 | return ( 342 |
343 | {load && child} 344 |
345 | ); 346 | })} 347 |
348 |
349 | ); 350 | }; 351 | 352 | export default React.forwardRef(Pager); 353 | 354 | /** 355 | * Add some resistance when swiping in a direction 356 | * that doesn't contain another pane 357 | */ 358 | 359 | function taper(x: number, maxWidth: number) { 360 | if (x > 0) { 361 | return x * 0.3; 362 | } 363 | 364 | if (x < maxWidth) { 365 | const diff = x - maxWidth; 366 | return x - diff * 0.7; 367 | } 368 | 369 | return x; 370 | } 371 | -------------------------------------------------------------------------------- /src/use-measure.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import ResizeObserver from "resize-observer-polyfill"; 3 | 4 | export interface Bounds { 5 | left: number; 6 | height: number; 7 | top: number; 8 | width: number; 9 | } 10 | 11 | export function useMeasure(ref: React.RefObject) { 12 | const [bounds, setBounds] = React.useState({ 13 | left: 0, 14 | top: 0, 15 | width: 0, 16 | height: 0 17 | }); 18 | 19 | const [observer] = React.useState( 20 | () => 21 | new ResizeObserver(([entry]) => { 22 | setBounds(entry.contentRect); 23 | }) 24 | ); 25 | 26 | React.useEffect(() => { 27 | if (ref.current) observer.observe(ref.current); 28 | return () => observer.disconnect(); 29 | }, []); 30 | 31 | return bounds; 32 | } 33 | -------------------------------------------------------------------------------- /src/use-previous.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function usePrevious(value: T) { 4 | const ref = React.useRef(null); 5 | React.useEffect(() => { 6 | ref.current = value; 7 | }, [value]); 8 | return ref.current; 9 | } 10 | -------------------------------------------------------------------------------- /stories/Filter-example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Pager, { CallbackProps, PagerHandles } from "../src"; 3 | import "./images.css"; 4 | 5 | export function FilterExample() { 6 | const [index, setIndex] = React.useState(0); 7 | 8 | return ( 9 | setIndex(i)} 18 | enableMouse 19 | > 20 | {null} 21 |
I should appear first
22 |
I should appear last
23 | {null} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /stories/Images-example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Pager, { CallbackProps, PagerHandles } from "../src"; 3 | import "./images.css"; 4 | 5 | export function ImagesExample() { 6 | const [index, setIndex] = React.useState(0); 7 | 8 | const images = [ 9 | { 10 | src: 11 | "https://images.unsplash.com/photo-1557958114-3d2440207108?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80" 12 | }, 13 | { 14 | src: 15 | "https://images.unsplash.com/photo-1557939403-1760a0e47505?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1931&q=80" 16 | }, 17 | { 18 | src: 19 | "https://images.unsplash.com/photo-1558029062-a37889b87526?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=975&q=80" 20 | }, 21 | { 22 | src: 23 | "https://images.unsplash.com/photo-1558088458-b65180740294?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1579&q=80" 24 | }, 25 | { 26 | src: 27 | "https://images.unsplash.com/photo-1558039719-79cb7b60d279?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80" 28 | } 29 | ]; 30 | 31 | function onKeyDown(e: KeyboardEvent) { 32 | // left 33 | if (e.keyCode === 37) { 34 | if (index > 0) { 35 | setIndex(index - 1); 36 | return true; 37 | } 38 | 39 | // right 40 | } else if (e.keyCode === 39) { 41 | if (index < images.length - 1) { 42 | setIndex(index + 1); 43 | return true; 44 | } 45 | } 46 | return false; 47 | } 48 | 49 | React.useEffect(() => { 50 | window.addEventListener("keydown", onKeyDown); 51 | return () => window.removeEventListener("keydown", onKeyDown); 52 | }, [index]); 53 | 54 | return ( 55 | { 58 | const indexes = [i]; 59 | if (i > 0) { 60 | indexes.unshift(i - 1); 61 | } 62 | 63 | if (i < images.length) { 64 | indexes.push(i + 1); 65 | } 66 | 67 | return indexes; 68 | }} 69 | style={{ 70 | width: "100vw", 71 | height: "600px" 72 | }} 73 | value={index} 74 | onRequestChange={i => setIndex(i)} 75 | enableMouse 76 | > 77 | {images.map((image, i) => ( 78 |
79 | 86 | e.preventDefault()} 88 | style={{ 89 | maxWidth: "100%", 90 | height: "auto", 91 | margin: "0 auto", 92 | display: "block", 93 | maxHeight: "100%", 94 | objectFit: "contain" 95 | }} 96 | src={image.src} 97 | /> 98 |
99 | ))} 100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /stories/Scrolling-example.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Pager, { CallbackProps, PagerHandles } from "../src"; 3 | import "./images.css"; 4 | 5 | export function ScrollingExample() { 6 | const [index, setIndex] = React.useState(0); 7 | 8 | const content = [ 9 | { 10 | content: 11 | "Eu magna culpa reprehenderit minim veniam culpa in exercitation aliqua laboris elit laboris occaecat consequat." 12 | }, 13 | { 14 | content: 15 | "Dolore cillum adipisicing aliqua voluptate aliqua cupidatat aute dolore in. Exercitation amet consectetur consequat elit Lorem nisi sint. Nostrud voluptate id exercitation ullamco aliquip eu laborum labore duis culpa. Aute proident et ex pariatur nostrud. Sint minim nisi non velit elit consectetur esse sit id laborum ut nulla labore proident. Occaecat ut nulla officia in est occaecat veniam consequat anim exercitation. Fugiat et reprehenderit consectetur aliqua magna do nisi magna voluptate ea aliqua. Commodo sunt dolor est nostrud. Excepteur do aute eu nisi elit ad velit ex. Tempor et quis culpa sint Lorem deserunt minim elit aliqua labore amet ex dolor sint. In incididunt ad do et sit tempor eu sit. Proident dolore pariatur pariatur voluptate eiusmod irure. Sunt culpa enim magna deserunt exercitation cupidatat tempor aliqua in elit veniam non. Ipsum incididunt enim dolore ea consequat et excepteur deserunt velit mollit. Elit commodo ea mollit magna ea dolor non sit adipisicing commodo aute nisi. Incididunt sunt ullamco adipisicing deserunt deserunt in ullamco deserunt deserunt dolore non eu ea occaecat. Labore veniam duis officia amet cupidatat mollit ex. Eiusmod tempor aute labore occaecat." 16 | } 17 | ]; 18 | 19 | return ( 20 |
21 | setIndex(i)} 30 | enableMouse 31 | > 32 | {content.map(item => ( 33 |
39 |

{item.content}

40 |
41 | ))} 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /stories/images.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 0; 3 | margin: 0; 4 | height: 100vh; 5 | display: flex; 6 | align-items: center; 7 | -webkit-overflow-scrolling: touch; 8 | } 9 | -------------------------------------------------------------------------------- /stories/intro.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import Pager, { CallbackProps, PagerHandles } from "../src"; 4 | import { StateType } from "pan-responder-hook"; 5 | 6 | import { useTouchable } from "touchable-hook"; 7 | import { ImagesExample } from "./Images-example"; 8 | import { ScrollingExample } from "./Scrolling-example"; 9 | import { FilterExample } from "./Filter-example"; 10 | 11 | function TouchableHighlight({ onPress, children }: any) { 12 | const { bind, active, hover } = useTouchable({ 13 | onPress, 14 | behavior: "button" // or 'link' 15 | }); 16 | 17 | return ( 18 |
19 | {children} 20 |
21 | ); 22 | } 23 | 24 | storiesOf("react-gesture-view", module) 25 | .add("basic usage", () => ) 26 | .add("initial index", () => ) 27 | .add("lazy loading", () => ) 28 | .add("disable gestures", () => ) 29 | .add("images example", () => ) 30 | .add("scrolling example", () => ) 31 | .add("filtering null children", () => ); 32 | 33 | function DisabledExample({ defaultIndex = 0 }) { 34 | const [index, setIndex] = React.useState(defaultIndex); 35 | 36 | return ( 37 | setIndex(i)} 40 | value={index} 41 | /> 42 | ); 43 | } 44 | 45 | function ControlledExample({ defaultIndex = 0 }) { 46 | const [index, setIndex] = React.useState(defaultIndex); 47 | 48 | return ( 49 | setIndex(i)} value={index} /> 50 | ); 51 | } 52 | 53 | function ParentTakeoverExample() { 54 | const [childIndex, setChildIndex] = React.useState(0); 55 | const [parentIndex, setParentIndex] = React.useState(0); 56 | 57 | function onParentTerminationRequest({ delta }: StateType) { 58 | if (childIndex !== 0) { 59 | return true; 60 | } 61 | 62 | const [x] = delta; 63 | 64 | if (x < 0) { 65 | return true; 66 | } 67 | 68 | return false; 69 | } 70 | 71 | function onChildTerminationRequest({ delta }: StateType) { 72 | if (childIndex > 0) { 73 | return false; 74 | } 75 | 76 | const [x] = delta; 77 | 78 | if (x < 0) { 79 | return false; 80 | } 81 | 82 | return true; 83 | } 84 | 85 | return ( 86 | setParentIndex(i)} 89 | onTerminationRequest={onParentTerminationRequest} 90 | id="parent" 91 | > 92 | setChildIndex(i)} 100 | onTerminationRequest={onChildTerminationRequest} 101 | /> 102 | 103 | ); 104 | } 105 | 106 | function BasicExample({ 107 | onTerminationRequest, 108 | style, 109 | enableGestures, 110 | id, 111 | onRequestChange, 112 | value, 113 | children 114 | }: any) { 115 | const ref = React.useRef(null); 116 | 117 | React.useEffect(() => { 118 | ref.current!.focus(); 119 | }, []); 120 | 121 | return ( 122 | 136 |
137 |
138 | onRequestChange(1)}> 139 | next 140 | 141 | 142 |
143 |
144 |
145 | onRequestChange(0)}> 146 | prev 147 | 148 |
149 | {(props: CallbackProps, active: boolean) => { 150 | return
Render callback
; 151 | }} 152 |
159 | {children} 160 |
161 |
162 | ); 163 | } 164 | 165 | function LazyExample() { 166 | const [index, setIndex] = React.useState(0); 167 | return ( 168 | setIndex(i)} 172 | style={{ 173 | width: "300px", 174 | height: "500px" 175 | }} 176 | > 177 |
178 |
179 | 180 | 181 |
182 |
183 |
184 | 185 |
186 |
187 | 188 |
189 |
190 | ); 191 | } 192 | 193 | export function RandomContent() { 194 | React.useEffect(() => { 195 | console.log("mounted!"); 196 | }, []); 197 | return ( 198 | 199 |

200 | Sunt consequat officia velit mollit nisi ex ut voluptate. Ipsum mollit 201 | fugiat non ipsum ea duis adipisicing duis tempor veniam et anim. 202 | Voluptate minim deserunt ipsum laboris duis aliquip consequat velit 203 | ipsum deserunt minim sit sint. Cillum aliqua mollit duis sunt minim elit 204 | ea laboris esse ipsum proident consequat enim. Deserunt quis ex labore 205 | amet officia veniam fugiat. Reprehenderit pariatur cillum consectetur 206 | consectetur ut. 207 |

208 |

209 | Sunt consequat officia velit mollit nisi ex ut voluptate. Ipsum mollit 210 | fugiat non ipsum ea duis adipisicing duis tempor veniam et anim. 211 | Voluptate minim deserunt ipsum laboris duis aliquip consequat velit 212 | ipsum deserunt minim sit sint. Cillum aliqua mollit duis sunt minim elit 213 | ea laboris esse ipsum proident consequat enim. Deserunt quis ex labore 214 | amet officia veniam fugiat. Reprehenderit pariatur cillum consectetur 215 | consectetur ut. 216 |

217 |

218 | Sunt consequat officia velit mollit nisi ex ut voluptate. Ipsum mollit 219 | fugiat non ipsum ea duis adipisicing duis tempor veniam et anim. 220 | Voluptate minim deserunt ipsum laboris duis aliquip consequat velit 221 | ipsum deserunt minim sit sint. Cillum aliqua mollit duis sunt minim elit 222 | ea laboris esse ipsum proident consequat enim. Deserunt quis ex labore 223 | amet officia veniam fugiat. Reprehenderit pariatur cillum consectetur 224 | consectetur ut. 225 |

226 |

227 | Sunt consequat officia velit mollit nisi ex ut voluptate. Ipsum mollit 228 | fugiat non ipsum ea duis adipisicing duis tempor veniam et anim. 229 | Voluptate minim deserunt ipsum laboris duis aliquip consequat velit 230 | ipsum deserunt minim sit sint. Cillum aliqua mollit duis sunt minim elit 231 | ea laboris esse ipsum proident consequat enim. Deserunt quis ex labore 232 | amet officia veniam fugiat. Reprehenderit pariatur cillum consectetur 233 | consectetur ut. 234 |

235 |

236 | Sunt consequat officia velit mollit nisi ex ut voluptate. Ipsum mollit 237 | fugiat non ipsum ea duis adipisicing duis tempor veniam et anim. 238 | Voluptate minim deserunt ipsum laboris duis aliquip consequat velit 239 | ipsum deserunt minim sit sint. Cillum aliqua mollit duis sunt minim elit 240 | ea laboris esse ipsum proident consequat enim. Deserunt quis ex labore 241 | amet officia veniam fugiat. Reprehenderit pariatur cillum consectetur 242 | consectetur ut. 243 |

244 |
245 | ); 246 | } 247 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "declarationDir": "esm", 7 | "esModuleInterop": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["es2015", "esnext.asynciterable", "dom"], 11 | "module": "esnext", 12 | "target": "es5", 13 | "outDir": "esm", 14 | "skipLibCheck": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": false, 18 | "strict": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowJs": false 21 | }, 22 | "includes": ["src"], 23 | "exclude": [ 24 | "node_modules", 25 | "dist", 26 | "stories", 27 | "tests", 28 | "esm", 29 | "src/**/__tests__" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------