From ae6dd06dff53765414062b1cfe9be08bab694e41 Mon Sep 17 00:00:00 2001 From: Andrew Smith Date: Fri, 18 Oct 2019 15:30:38 -0600 Subject: [PATCH] rewrite internals to simplify animated index logic --- example/App.tsx | 42 +- .../xcshareddata/xcschemes/example.xcscheme | 2 +- example/ios/exampleTests/exampleTests.m | 8 + example/src/kilter-cards.tsx | 9 +- example/src/shared-components.tsx | 9 +- example/src/stack.tsx | 2 +- example/src/stacked-cards.tsx | 12 +- example/src/vertical.tsx | 47 ++ src/index.tsx | 2 +- src/pager-2.tsx | 318 ++++++++--- src/pager.tsx | 506 +++++++----------- src/pagination.tsx | 2 +- 12 files changed, 532 insertions(+), 427 deletions(-) create mode 100644 example/src/vertical.tsx diff --git a/example/App.tsx b/example/App.tsx index a794384..58f937d 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -9,7 +9,7 @@ console.disableYellowBox = true; import React, {useState} from 'react'; -import {SafeAreaView, View, Text} from 'react-native'; +import {SafeAreaView, View} from 'react-native'; import {InlineCards} from './src/inline-cards'; import {KilterCards} from './src/kilter-cards'; @@ -18,49 +18,25 @@ import {SwipeCards} from './src/swipe-cards'; import {Stack} from './src/stack'; import {Tabs} from './src/tabs'; import {MyPager} from './src/basic-example'; -import {Pager} from '@crowdlinker/react-native-pager'; +import {PagerProvider} from '@crowdlinker/react-native-pager'; import {ContainerStyle} from './src/panhandler-width'; -import {Slide} from './src/shared-components'; - -const stackConfig: any = { - transform: [ - { - scale: { - inputRange: [-1, 0, 1], - outputRange: [0.95, 1, 0.95], - }, - }, - ], - - zIndex: offset => offset, -}; +import {VerticalPager} from './src/vertical'; const App = () => { const [activeIndex, setActiveIndex] = useState(1); function onChange(nextIndex: number) { - console.log({nextIndex}); + // console.log({nextIndex}); setActiveIndex(nextIndex); } return ( - - - - - - - - - - - {`Active index: ${activeIndex}`} + + + + ); diff --git a/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme b/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme index 7ad0109..ebc4dc0 100644 --- a/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme +++ b/example/ios/example.xcodeproj/xcshareddata/xcschemes/example.xcscheme @@ -78,7 +78,7 @@ = RCTLogLevelError) { redboxError = message; } }); + + #endif while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; @@ -58,7 +62,11 @@ - (void)testRendersWelcomeScreen }]; } + #ifdef DEBUG + RCTSetLogFunction(RCTDefaultLogFunction); + + #endif XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); diff --git a/example/src/kilter-cards.tsx b/example/src/kilter-cards.tsx index 3b2d24c..571053a 100644 --- a/example/src/kilter-cards.tsx +++ b/example/src/kilter-cards.tsx @@ -2,6 +2,9 @@ import React, {useState} from 'react'; import {Pager, iPageInterpolation} from '@crowdlinker/react-native-pager'; import {Slide, NavigationButtons} from './shared-components'; import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +const {multiply, floor} = Animated; const kilterCardsConfig: iPageInterpolation = { transform: [ @@ -28,6 +31,8 @@ const kilterCardsConfig: iPageInterpolation = { }, ], + zIndex: (offset: Animated.Node) => multiply(floor(offset), -1), + opacity: { inputRange: [-2, -1, 0, 1, 2, 3, 4], outputRange: [0, 0, 1, 1, 1, 0, 0], @@ -35,7 +40,7 @@ const kilterCardsConfig: iPageInterpolation = { }; function KilterCards() { - const [activeIndex, onChange] = useState(2); + const [activeIndex, onChange] = useState(3); return ( @@ -44,7 +49,7 @@ function KilterCards() { onChange={onChange} clamp={{next: 0}} threshold={0.3} - adjacentChildOffset={5} + adjacentChildOffset={3} style={{height: 200, width: 200, alignSelf: 'center', padding: 10}} pageInterpolation={kilterCardsConfig}> {Array.from({length: activeIndex + 3}, (_, i) => ( diff --git a/example/src/shared-components.tsx b/example/src/shared-components.tsx index d32126f..4f668a2 100644 --- a/example/src/shared-components.tsx +++ b/example/src/shared-components.tsx @@ -16,7 +16,6 @@ import { useInterpolation, } from '@crowdlinker/react-native-pager'; import Animated from 'react-native-reanimated'; -import {ReText} from 'react-native-redash'; const colors = [ 'aquamarine', @@ -29,10 +28,10 @@ const colors = [ 'salmon', ]; -function Slide({index}: any) { +function Slide() { // const [count, setCount] = useState(0); - // const focused = useFocus(); - // const index = useIndex(); + const focused = useFocus(); + const index = useIndex(); // const style = useInterpolation({ // transform: [ // { @@ -52,11 +51,9 @@ function Slide({index}: any) { alignItems: 'center', borderRadius: 10, marginHorizontal: 5, - borderWidth: 1, backgroundColor: colors[index % colors.length], }}> {`Screen: ${index}`} - {/* */} {/* {`Focused: ${focused}`} */} {/* {`Count: ${count}`} diff --git a/example/src/stack.tsx b/example/src/stack.tsx index 22c536c..844e547 100644 --- a/example/src/stack.tsx +++ b/example/src/stack.tsx @@ -31,7 +31,7 @@ function Stack() { enabled: activeIndex !== 0, }} onChange={onChange} - clamp={{prev: 0.4}} + clamp={{prev: 0.7}} clampDrag={{next: 0}} adjacentChildOffset={4} containerStyle={{height: 200}} diff --git a/example/src/stacked-cards.tsx b/example/src/stacked-cards.tsx index ee8c71c..9f9762b 100644 --- a/example/src/stacked-cards.tsx +++ b/example/src/stacked-cards.tsx @@ -2,6 +2,9 @@ import React, {useState} from 'react'; import {Pager, iPageInterpolation} from '@crowdlinker/react-native-pager'; import {Slide, NavigationButtons} from './shared-components'; import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +const {multiply, floor} = Animated; const stackedCardsConfig: iPageInterpolation = { transform: [ @@ -16,6 +19,13 @@ const stackedCardsConfig: iPageInterpolation = { }, }, ], + + opacity: { + inputRange: [-1, 0, 1, 2, 3], + outputRange: [1, 1, 1, 1, 0], + }, + + zIndex: offset => multiply(floor(offset), -1), }; function StackedCards() { @@ -28,7 +38,7 @@ function StackedCards() { onChange={onChange} clamp={{next: 0}} threshold={0.3} - adjacentChildOffset={3} + adjacentChildOffset={5} style={{height: 200, width: 200, alignSelf: 'center', padding: 10}} pageInterpolation={stackedCardsConfig}> {Array.from({length: activeIndex + 3}, (_, i) => ( diff --git a/example/src/vertical.tsx b/example/src/vertical.tsx new file mode 100644 index 0000000..e30f37a --- /dev/null +++ b/example/src/vertical.tsx @@ -0,0 +1,47 @@ +import React, {useState} from 'react'; +import {Pager, iPageInterpolation} from '@crowdlinker/react-native-pager'; +import {Slide, NavigationButtons} from './shared-components'; +import {View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +const {multiply, floor} = Animated; + +const verticalConfig: iPageInterpolation = { + transform: [ + { + scale: { + inputRange: [-1, 0, 1], + outputRange: [0.8, 1, 0.9], + }, + }, + ], + + opacity: { + inputRange: [-1, 0, 1, 2, 3], + outputRange: [1, 1, 1, 1, 0], + }, + + zIndex: offset => multiply(floor(offset), -1), +}; + +function VerticalPager() { + const [activeIndex, onChange] = useState(2); + + return ( + + + {Array.from({length: activeIndex + 3}, (_, i) => ( + + ))} + + + + ); +} + +export {VerticalPager}; diff --git a/src/index.tsx b/src/index.tsx index a4bb9ca..3a36690 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,3 @@ -export * from './pager-2'; +export * from './pager'; export * from './pagination'; export { interpolateWithConfig } from './util'; diff --git a/src/pager-2.tsx b/src/pager-2.tsx index f4bc384..678e66b 100644 --- a/src/pager-2.tsx +++ b/src/pager-2.tsx @@ -5,8 +5,6 @@ import React, { useContext, useEffect, memo, - cloneElement, - useMemo, } from 'react'; import { StyleSheet, LayoutChangeEvent, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -63,6 +61,9 @@ export interface iPageInterpolation { const VERTICAL = 1; const HORIZONTAL = 2; +const UNSET = -1; +const TRUE = 1; +const FALSE = 0; const { event, @@ -77,20 +78,13 @@ const { Clock, set, clockRunning, - spring, - startClock, multiply, - neq, sub, call, max, min, greaterThan, - greaterOrEq, - lessOrEq, - and, abs, - lessThan, ceil, proc, // @ts-ignore @@ -146,22 +140,19 @@ function Pager({ containerStyle, type = 'horizontal', pageInterpolation, - clamp = { - prev: REALLY_BIG_NUMBER, - next: REALLY_BIG_NUMBER, - }, - clampDrag = { - prev: REALLY_BIG_NUMBER, - next: REALLY_BIG_NUMBER, - }, - animatedIndex: parentAnimatedIndex, + clamp = {}, + clampDrag = {}, }: iPager) { + const context = useContext(PagerContext); + const isControlled = parentActiveIndex !== undefined; const [_activeIndex, _onChange] = useState(initialIndex); const activeIndex = isControlled ? (parentActiveIndex as number) + : context + ? (context[0] as number) : (_activeIndex as number); const numberOfScreens = Children.count(children); @@ -171,7 +162,11 @@ function Pager({ ? Math.ceil((numberOfScreens - 1) / pageSize) : parentMax; - const onChange = isControlled ? (parentOnChange as any) : (_onChange as any); + const onChange = isControlled + ? (parentOnChange as any) + : context + ? (context[1] as any) + : (_onChange as any); const dragX = memoize(new Value(0)); const dragY = memoize(new Value(0)); @@ -206,13 +201,18 @@ function Pager({ ) ); - const [width, setWidth] = useState(-1); - const [height, setHeight] = useState(-1); + const [width, setWidth] = useState(UNSET); + const [height, setHeight] = useState(UNSET); + // assign references based on vertical / horizontal configurations const dimension = memoize(new Value(0)); const targetDimension = type === 'vertical' ? 'height' : 'width'; const targetTransform = type === 'vertical' ? 'translateY' : 'translateX'; const delta = type === 'vertical' ? dragY : dragX; + + // `totalDimension` on the container view is required for android layouts to work properly + // otherwise translations move the panHandler off of the screen + // set the total width of the container view to the sum width of all the screens const totalDimension = multiply(dimension, numberOfScreens); function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { @@ -222,6 +222,7 @@ function Pager({ const TYPE = type === 'vertical' ? VERTICAL : HORIZONTAL; + // it's important to use dimension as an animated value because the computations are memoized Animated.useCode( cond( // dimension already set to last layout @@ -234,66 +235,86 @@ function Pager({ // props that might change over time should be reactive: const animatedThreshold = useAnimatedValue(threshold); - const animatedActiveIndex = useAnimatedValue(activeIndex); const clampDragPrev = useAnimatedValue(clampDrag.prev, REALLY_BIG_NUMBER); const clampDragNext = useAnimatedValue(clampDrag.next, REALLY_BIG_NUMBER); const animatedMaxIndex = useAnimatedValue(maxIndex); const animatedMinIndex = useAnimatedValue(minIndex); + // set the initial position - priority to direct prop over context, and context over uncontrolled + const _position = memoize(new Value(activeIndex)); + const position = isControlled ? _position : context ? context[2] : _position; + + // pan event values to track const dragStart = memoize(new Value(0)); - const swiping = memoize(new Value(0)); - const position = memoize(parentAnimatedIndex || new Value(activeIndex)); + const swiping = memoize(new Value(FALSE)); const nextIndex = memoize(new Value(activeIndex)); - + const animatedActiveIndex = memoize(new Value(activeIndex)); const change = memoize(sub(animatedActiveIndex, position)); const absChange = memoize(abs(change)); - const shouldTransition = memoize( - and( - greaterThan(absChange, animatedThreshold), - lessOrEq(position, animatedMaxIndex), - greaterOrEq(position, animatedMinIndex) - ) - ); - const clamped = memoize( + const shouldTransition = memoize(greaterThan(absChange, animatedThreshold)); + const indexChange = memoize(new Value(0)); + + // clamp drag values between the configured clamp props + // e.g prev => 0.5, next => 0.5 means change can only be between [-0.5, 0.5] + // minMax order is reversed because next is negative in translation values + const clampedDelta = memoize( minMax(divide(delta, dimension), multiply(clampDragNext, -1), clampDragPrev) ); - const indexChange = memoize(new Value(0)); const clock = memoize(new Clock()); + // snap focus to activeIndex when it updates useEffect(() => { if (activeIndex >= minIndex && activeIndex <= maxIndex) { nextIndex.setValue(activeIndex); } - }, [activeIndex]); + }, [activeIndex, minIndex, maxIndex]); + // animatedIndex represents pager position with an animated value + // this value is used to compute the transformations of the container screen + // its also used to compute the offsets of child screens, and any other consumers const animatedIndex = memoize( block([ cond( eq(gestureState, State.ACTIVE), [ cond(clockRunning(clock), stopClock(clock)), - cond(swiping, 0, [set(dragStart, position), set(swiping, 1)]), + // captures the initial drag value on first drag event + cond(swiping, 0, [set(dragStart, position), set(swiping, TRUE)]), - set(position, sub(dragStart, clamped)), + set(position, sub(dragStart, clampedDelta)), ], [ + // on release -- figure out if the index needs to change, and what index it should change to cond(swiping, [ - set(swiping, 0), + set(swiping, FALSE), cond(shouldTransition, [ + // rounds index change if pan gesture greater than just one screen set(indexChange, ceil(absChange)), + // nextIndex set to the next snap point set( nextIndex, cond( greaterThan(change, 0), - sub(animatedActiveIndex, indexChange), - add(animatedActiveIndex, indexChange) + minMax( + sub(animatedActiveIndex, indexChange), + animatedMinIndex, + animatedMaxIndex + ), + minMax( + add(animatedActiveIndex, indexChange), + animatedMinIndex, + animatedMaxIndex + ) ) ), + // update w/ value that will be snapped to call([nextIndex], ([nextIndex]) => onChange(nextIndex)), ]), ]), + // set animatedActiveIndex for next swipe event + set(animatedActiveIndex, nextIndex), set(position, runSpring(clock, position, nextIndex, springConfig)), ] ), @@ -304,6 +325,7 @@ function Pager({ const clampPrevValue = useAnimatedValue(clamp.prev, numberOfScreens); const clampNextValue = useAnimatedValue(clamp.next, numberOfScreens); + // stop child screens from translating beyond the bounds set by clamp props: const minimum = memoize( multiply(sub(animatedIndex, clampPrevValue), dimension) ); @@ -313,10 +335,21 @@ function Pager({ ); const animatedPageSize = useAnimatedValue(pageSize); + + // container offset -- this is the window of focus for active screens + // it shifts around based on the animatedIndex value const containerTranslation = memoize( multiply(animatedIndex, dimension, animatedPageSize, -1) ); + // slice the children that are rendered by the + // this enables very large child lists to render efficiently + // the downside is that children are unmounted after they pass this threshold + // it's an optional prop, however a default value of ~20 is set here to prevent + // possible performance bottlenecks to those not aware of the prop or what it does + + // this will slice adjacentChildOffset number of children previous and after + // the current active child index into a smaller child array const adjacentChildren = adjacentChildOffset !== undefined ? children.slice( @@ -325,9 +358,15 @@ function Pager({ ) : children; + // grabbing the height property from the style prop if there is no container style, this reduces + // the chances of messing up the layout with containerStyle configurations + // can be overridden by the prop itself, but its likely that this is what is intended most of the time + // also has the benefit of covering 100% width of container, meaning better pan coverage on android const defaultContainerStyle = style && style.height ? { height: style.height } : undefined; + // extra Animated.Views below may seem redundant but they preserve applied styles e.g padding and margin + // of the page views return ( - {width === -1 + {width === UNSET ? null : adjacentChildren.map((child: any, i) => { // use map instead of React.Children because we want to track @@ -364,19 +403,22 @@ function Pager({ } return ( - - {child} - + + + + {child} + + + ); })} @@ -388,7 +430,7 @@ function Pager({ ); } -function Page({ +function _Page({ children, index, minimum, @@ -399,11 +441,20 @@ function Page({ pageInterpolation, animatedIndex, }: any) { + // compute the absolute position of the page based on index and dimension + // this means that it's not relative to any other child, which is good because + // it doesn't rely on a mechanism like flex, which requires all children to be present + // to properly position pages const position = memoize(multiply(index, dimension)); + + // min-max the position based on clamp values + // this means the will have a container that is always positioned + // in the same place, but the inner view can be translated within these bounds const translation = memoize(minMax(position, minimum, maximum)); const defaultStyle = memoize({ // map to height / width value depending on vertical / horizontal configuration + // this is crucial to getting child views to properly lay out [targetDimension]: dimension, // min-max the position based on clamp values // this means the will have a container that is always positioned @@ -415,14 +466,16 @@ function Page({ ], }); + // compute the relative offset value to the current animated index so + // that can use interpolation values that are in sync with drag gestures const offset = memoize(sub(index, animatedIndex)); // apply interpolation configs to - const interpolatedStyles = interpolateWithConfig(offset, pageInterpolation); + const interpolatedStyles = memoize( + interpolateWithConfig(offset, pageInterpolation) + ); - // take out zIndex here as it needs to be applied to siblings, which inner containers - // are not, however the outer container requires the absolute translateX/Y to properly - // position itself + // take out zIndex here as it needs to be applied to siblings let { zIndex, ...otherStyles } = interpolatedStyles; // zIndex is not a requirement of interpolation @@ -446,6 +499,12 @@ function Page({ ); } +// the only thing that changes in is the children, since it usually +// gets a fresh child from a .map function +const Page = memo(_Page, () => true); + +// utility to update animated values without changing their reference +// this is key for using memoized Animated.Values and prevents costly rerenders function useAnimatedValue( value?: number, defaultValue?: number @@ -462,4 +521,143 @@ function useAnimatedValue( return animatedValue; } -export { Pager }; +type iPagerContext = [ + number, + (nextIndex: number) => void, + Animated.Value +]; + +const PagerContext = createContext(undefined); + +interface iPagerProvider { + children: React.ReactNode; + initialIndex?: number; + activeIndex?: number; + onChange?: (nextIndex: number) => void; +} + +function PagerProvider({ + children, + initialIndex = 0, + activeIndex: parentActiveIndex, + onChange: parentOnChange = () => + console.warn( + ' should have an onChange() prop if it is controlled' + ), +}: iPagerProvider) { + const [_activeIndex, _setActiveIndex] = useState(initialIndex); + + const isControlled = parentActiveIndex !== undefined; + + const activeIndex = isControlled ? parentActiveIndex : _activeIndex; + const onChange = isControlled ? parentOnChange : _setActiveIndex; + + const animatedIndex = memoize(new Value(activeIndex)); + + return ( + + {typeof children === 'function' + ? children({ activeIndex, onChange, animatedIndex }) + : children} + + ); +} + +function usePager(): iPagerContext { + const context = useContext(PagerContext); + + if (context === undefined) { + throw new Error(`usePager() must be used within a `); + } + + return context; +} + +// provide hook for child screens to access pager focus: +const FocusContext = React.createContext(false); + +interface iFocusProvider { + children: React.ReactNode; + focused: boolean; +} + +function FocusProvider({ focused, children }: iFocusProvider) { + return ( + {children} + ); +} + +function useFocus() { + const focused = useContext(FocusContext); + + return focused; +} + +const IndexContext = React.createContext(undefined); + +interface iIndexProvider { + children: React.ReactNode; + index: number; +} + +function IndexProvider({ children, index }: iIndexProvider) { + return ( + {children} + ); +} + +function useIndex() { + const index = useContext(IndexContext); + + if (index === undefined) { + throw new Error(`useIndex() must be used within an `); + } + + return index; +} + +function useOnFocus(fn: Function) { + const focused = useFocus(); + + useEffect(() => { + if (focused) { + fn(); + } + }, [focused]); +} + +function useAnimatedIndex() { + const pager = usePager(); + return pager[2]; +} + +function useOffset(index: number) { + const pager = usePager(); + const animatedIndex = pager[2]; + const offset = memoize(sub(index, animatedIndex)); + + return offset; +} + +function useInterpolation(pageInterpolation: iPageInterpolation) { + const index = useIndex(); + const offset = useOffset(index); + const styles = interpolateWithConfig(offset, pageInterpolation); + return styles; +} + +export { + Pager, + PagerProvider, + PagerContext, + usePager, + useFocus, + useOffset, + useOnFocus, + useIndex, + useAnimatedIndex, + useInterpolation, + IndexProvider, +}; diff --git a/src/pager.tsx b/src/pager.tsx index 45da341..678e66b 100644 --- a/src/pager.tsx +++ b/src/pager.tsx @@ -13,7 +13,7 @@ import { State, PanGestureHandlerProperties, } from 'react-native-gesture-handler'; -import { memoize, interpolateWithConfig } from './util'; +import { memoize, interpolateWithConfig, runSpring } from './util'; export type SpringConfig = { damping: Animated.Adaptable; @@ -61,6 +61,9 @@ export interface iPageInterpolation { const VERTICAL = 1; const HORIZONTAL = 2; +const UNSET = -1; +const TRUE = 1; +const FALSE = 0; const { event, @@ -75,31 +78,19 @@ const { Clock, set, clockRunning, - spring, - startClock, multiply, - neq, sub, call, max, min, greaterThan, abs, - lessThan, ceil, + proc, // @ts-ignore debug, } = Animated; -const DEFAULT_SPRING_CONFIG = { - stiffness: 1000, - damping: 500, - mass: 3, - overshootClamping: false, - restDisplacementThreshold: 0.01, - restSpeedThreshold: 0.01, -}; - export interface iPager { activeIndex?: number; onChange?: (nextIndex: number) => void; @@ -129,98 +120,57 @@ export interface iPager { } const REALLY_BIG_NUMBER = 1000000000; +const minMax = proc((value, minimum, maximum) => + min(max(value, minimum), maximum) +); + function Pager({ activeIndex: parentActiveIndex, onChange: parentOnChange, initialIndex = 0, children, - springConfig = DEFAULT_SPRING_CONFIG, + springConfig, panProps = {}, pageSize = 1, threshold = 0.1, minIndex = 0, maxIndex: parentMax, adjacentChildOffset = 10, - animatedValue, style, containerStyle, type = 'horizontal', pageInterpolation, - clamp = { - prev: REALLY_BIG_NUMBER, - next: REALLY_BIG_NUMBER, - }, - clampDrag = { - prev: REALLY_BIG_NUMBER, - next: REALLY_BIG_NUMBER, - }, - animatedIndex: parentAnimatedIndex, + clamp = {}, + clampDrag = {}, }: iPager) { const context = useContext(PagerContext); - // register these props if they exist -- they can be shared with other - // components to keep the translation values in sync - - // prioritize direct prop, then context, then internal value - // memoize these so they don't get reset on rerenders - const _animatedValue = - animatedValue !== undefined - ? animatedValue - : context - ? context[2] - : new Value(0); - - const translationValue = memoize(_animatedValue); - - const _animatedIndex = - parentAnimatedIndex !== undefined - ? parentAnimatedIndex - : context - ? context[3] - : new Value(0); - - const animatedIndex = memoize(_animatedIndex); + const isControlled = parentActiveIndex !== undefined; const [_activeIndex, _onChange] = useState(initialIndex); - // assign activeIndex and onChange correctly based on controlled / uncontrolled - // configurations - - // prioritize direct prop over context, and context over internal state - const isControlled = parentActiveIndex !== undefined; - const activeIndex = isControlled - ? parentActiveIndex + ? (parentActiveIndex as number) : context - ? context[0] - : (_activeIndex as any); - - const onChange = isControlled - ? parentOnChange - : context - ? context[1] - : (_onChange as any); + ? (context[0] as number) + : (_activeIndex as number); const numberOfScreens = Children.count(children); - // maxIndex might change over time, but computations using this value are memoized - // so it should be saved and updated as an Animated.Value accordingly - const maxIndexValue = + const maxIndex = parentMax === undefined ? Math.ceil((numberOfScreens - 1) / pageSize) : parentMax; - const maxIndex = memoize(new Value(maxIndexValue)); - - useEffect(() => { - requestAnimationFrame(() => { - maxIndex.setValue(maxIndexValue); - }); - }, [maxIndexValue]); + const onChange = isControlled + ? (parentOnChange as any) + : context + ? (context[1] as any) + : (_onChange as any); const dragX = memoize(new Value(0)); const dragY = memoize(new Value(0)); - const gestureState = memoize(new Value(-1)); + const gestureState = memoize(new Value(0)); const handleGesture = memoize( event( @@ -251,10 +201,19 @@ function Pager({ ) ); - const [width, setWidth] = useState(0); - const [height, setHeight] = useState(0); + const [width, setWidth] = useState(UNSET); + const [height, setHeight] = useState(UNSET); + // assign references based on vertical / horizontal configurations const dimension = memoize(new Value(0)); + const targetDimension = type === 'vertical' ? 'height' : 'width'; + const targetTransform = type === 'vertical' ? 'translateY' : 'translateX'; + const delta = type === 'vertical' ? dragY : dragX; + + // `totalDimension` on the container view is required for android layouts to work properly + // otherwise translations move the panHandler off of the screen + // set the total width of the container view to the sum width of all the screens + const totalDimension = multiply(dimension, numberOfScreens); function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { layout.width !== width && setWidth(layout.width); @@ -263,216 +222,124 @@ function Pager({ const TYPE = type === 'vertical' ? VERTICAL : HORIZONTAL; + // it's important to use dimension as an animated value because the computations are memoized Animated.useCode( cond( // dimension already set to last layout or(eq(dimension, width), eq(dimension, height)), [], - [ - cond(eq(TYPE, VERTICAL), set(dimension, height), set(dimension, width)), - set(translationValue, multiply(activeIndex, dimension, -1)), - ] + [cond(eq(TYPE, VERTICAL), set(dimension, height), set(dimension, width))] ), [width, height] ); - // assign variables based on vertical / horizontal configurations - const targetDimension = type === 'vertical' ? 'height' : 'width'; - const translateValue = type === 'vertical' ? 'translateY' : 'translateX'; - const dragValue = type === 'vertical' ? dragY : dragX; - - // compute the total size of a page to determine how far to snap - const page = memoize(multiply(dimension, pageSize)); - - // only need one clock - const clock = memoize(new Clock()); - - const runSpring = memoize((nextIndex: Animated.Node) => { - const state = { - finished: new Value(0), - velocity: new Value(0), - position: new Value(0), - time: new Value(0), - }; + // props that might change over time should be reactive: + const animatedThreshold = useAnimatedValue(threshold); + const clampDragPrev = useAnimatedValue(clampDrag.prev, REALLY_BIG_NUMBER); + const clampDragNext = useAnimatedValue(clampDrag.next, REALLY_BIG_NUMBER); + const animatedMaxIndex = useAnimatedValue(maxIndex); + const animatedMinIndex = useAnimatedValue(minIndex); - const config = { - ...DEFAULT_SPRING_CONFIG, - ...springConfig, - toValue: new Value(0), - }; + // set the initial position - priority to direct prop over context, and context over uncontrolled + const _position = memoize(new Value(activeIndex)); + const position = isControlled ? _position : context ? context[2] : _position; - const nextPosition = multiply(nextIndex, page, -1); - - return block([ - cond( - clockRunning(clock), - [ - set(state.position, translationValue), - set(state.finished, 0), - // only set the toValue when needed - // this block runs a lot, hopefully this helps with performance - cond( - neq(config.toValue, nextPosition), - set(config.toValue, nextPosition) - ), - - set(animatedIndex, divide(state.position, max(dimension, 1), -1)), - ], - [ - set(state.position, translationValue), - set(state.finished, 0), - set(state.time, 0), - set(state.velocity, 0), - set(config.toValue, nextPosition), - startClock(clock), - ] - ), - spring(clock, state, config), - cond(state.finished, [stopClock(clock), set(state.time, 0)]), - state.position, - ]); - }); - - // most important parts of the following function: - // `swiping` is used to determine how the activeIndex changed - // if an index change occurs and the state is swiping, its safe to say - // it occured by user action, and not an external / parent activeIndex prop change - const swiping = memoize(new Value(0)); + // pan event values to track const dragStart = memoize(new Value(0)); + const swiping = memoize(new Value(FALSE)); + const nextIndex = memoize(new Value(activeIndex)); + const animatedActiveIndex = memoize(new Value(activeIndex)); + const change = memoize(sub(animatedActiveIndex, position)); + const absChange = memoize(abs(change)); + const shouldTransition = memoize(greaterThan(absChange, animatedThreshold)); + const indexChange = memoize(new Value(0)); + + // clamp drag values between the configured clamp props + // e.g prev => 0.5, next => 0.5 means change can only be between [-0.5, 0.5] + // minMax order is reversed because next is negative in translation values + const clampedDelta = memoize( + minMax(divide(delta, dimension), multiply(clampDragNext, -1), clampDragPrev) + ); - // position is a different name for the current index registered with the - // it does not necessarily reflect the activeIndex in all states - const position = memoize(new Value(activeIndex)); - - // `nextPosition` is computed every frame (could be optimized probably) - // and the value is used on release to snap to a given index offset - // updating this value will trigger the spring to snap when the user releases or - // if the activeIndex prop changes - const nextPosition = memoize(new Value(0)); + const clock = memoize(new Clock()); - // Animated.useCode doesn't seem to fire in time to update the nextPosition - // value to trigger transitions, not sure why but this works for now + // snap focus to activeIndex when it updates useEffect(() => { - if (activeIndex >= minIndex && activeIndex <= maxIndexValue) { - requestAnimationFrame(() => { - nextPosition.setValue(activeIndex); - }); + if (activeIndex >= minIndex && activeIndex <= maxIndex) { + nextIndex.setValue(activeIndex); } - }, [activeIndex, maxIndexValue, minIndex]); - - // compute the next snap point - it could be multiply screens depending - // on how far the user has dragged and what pageSize value is - const clampedDragPrev = - clampDrag.prev !== undefined ? clampDrag.prev : REALLY_BIG_NUMBER; - - const clampedDragNext = - clampDrag.next !== undefined ? clampDrag.next : REALLY_BIG_NUMBER; - - // clamps the drag value between previous and next values - // this defaults to a really large amount so there is no real clamping - // but it can be use to prevent the user from swiping in a certain direction - // e.g clampDragNext value of 0 means the user cannot swipe to the next screen - // at all - const clampedDragValue = memoize( - max(min(clampedDragPrev, dragValue), multiply(clampedDragNext, -1)) - ); - - const percentDragged = memoize(divide(clampedDragValue, dimension)); - - // use pageSize to determine how many screens the user has dragged - // the default is 100% of a page - const numberOfPagesDragged = memoize( - ceil(divide(abs(percentDragged), pageSize)) - ); - - // next and previous indices based on how far the user has dragged, accounting - // for the potential of dragging past the min/max index values of the - const nextIndex = memoize(min(add(position, numberOfPagesDragged), maxIndex)); - const prevIndex = memoize(max(sub(position, numberOfPagesDragged), minIndex)); - - // if shouldTransition evaluates to true, it will snap to either previous or next - // depending on the direction of the drag - const shouldTransition = memoize(greaterThan(abs(percentDragged), threshold)); + }, [activeIndex, minIndex, maxIndex]); - const translation = memoize( + // animatedIndex represents pager position with an animated value + // this value is used to compute the transformations of the container screen + // its also used to compute the offsets of child screens, and any other consumers + const animatedIndex = memoize( block([ cond( eq(gestureState, State.ACTIVE), [ cond(clockRunning(clock), stopClock(clock)), - cond(swiping, 0, set(dragStart, translationValue)), - set(swiping, 1), - set( - nextPosition, - cond( - shouldTransition, - [cond(lessThan(percentDragged, 0), nextIndex, prevIndex)], - position - ) - ), - // `animatedIndex` is updated here to track index changes as an animated value - // this means it can have intermediate values (e.g 1.23) which is an easier value - // for other components (e.g Pagination) to consume - set( - animatedIndex, - divide(add(clampedDragValue, dragStart), max(dimension, 1), -1) - ), - set(translationValue, add(clampedDragValue, dragStart)), - ], - - // on release or index change, the following runs: + // captures the initial drag value on first drag event + cond(swiping, 0, [set(dragStart, position), set(swiping, TRUE)]), - // if the position has changed then alert the active onChange callback - // its better to use it in this block rather than Animated.onChange, - // as it seems to fire mid block and start other blocks, causing the potential - // for a loop of updates to activeIndex on rapid index changes that the component - // can't resolve - - // note that the onChange callback only needs to be updated if a user was swiping, - // otherwise it must have come from a controlled prop, in which case that - // component already knows the activeIndex - // this prevents another potential rerender from an incorrect increment / decrement + set(position, sub(dragStart, clampedDelta)), + ], [ - cond(neq(position, nextPosition), [ - set(position, nextPosition), - cond( - swiping, - call([position], ([nextIndex]) => onChange(nextIndex)) - ), + // on release -- figure out if the index needs to change, and what index it should change to + cond(swiping, [ + set(swiping, FALSE), + cond(shouldTransition, [ + // rounds index change if pan gesture greater than just one screen + set(indexChange, ceil(absChange)), + // nextIndex set to the next snap point + set( + nextIndex, + cond( + greaterThan(change, 0), + minMax( + sub(animatedActiveIndex, indexChange), + animatedMinIndex, + animatedMaxIndex + ), + minMax( + add(animatedActiveIndex, indexChange), + animatedMinIndex, + animatedMaxIndex + ) + ) + ), + // update w/ value that will be snapped to + call([nextIndex], ([nextIndex]) => onChange(nextIndex)), + ]), ]), - set(swiping, 0), - set(translationValue, runSpring(position)), - translationValue, + + // set animatedActiveIndex for next swipe event + set(animatedActiveIndex, nextIndex), + set(position, runSpring(clock, position, nextIndex, springConfig)), ] ), + position, ]) ); - // compute the minimum and maximum distance from the active screen window - // these are min-maxed in to enable control of their positioning - - // inverse used here to make the mental math a little less complex - // since screens are positioned starting from 0 to totalDimension - // and translation values are 0 to -totalDimension - const inverseTranslate = memoize(multiply(translation, -1)); - - // the max distance a screen can have from the active window should be - // the max number of possible screens that the pager will render (numberOfScreens) - // hence that is the default value when undefined - const clampPrevValue = - clamp.prev !== undefined ? clamp.prev : numberOfScreens; - const clampNextValue = - clamp.next !== undefined ? clamp.next : numberOfScreens; - - // determine how far offset previous / next screens can possibly be - // this will clamp them on either side of the active window if a value - // is specified - const clampPrev = memoize( - sub(inverseTranslate, multiply(dimension, clampPrevValue)) + const clampPrevValue = useAnimatedValue(clamp.prev, numberOfScreens); + const clampNextValue = useAnimatedValue(clamp.next, numberOfScreens); + + // stop child screens from translating beyond the bounds set by clamp props: + const minimum = memoize( + multiply(sub(animatedIndex, clampPrevValue), dimension) + ); + + const maximum = memoize( + multiply(add(animatedIndex, clampNextValue), dimension) ); - const clampNext = memoize( - add(inverseTranslate, multiply(dimension, clampNextValue)) + const animatedPageSize = useAnimatedValue(pageSize); + + // container offset -- this is the window of focus for active screens + // it shifts around based on the animatedIndex value + const containerTranslation = memoize( + multiply(animatedIndex, dimension, animatedPageSize, -1) ); // slice the children that are rendered by the @@ -491,11 +358,6 @@ function Pager({ ) : children; - // `totalDimension` on the container view is required for android layouts to work properly - // otherwise translations move the panHandler off of the screen - // set the total width of the container view to the sum width of all the screens - const totalDimension = memoize(multiply(dimension, numberOfScreens)); - // grabbing the height property from the style prop if there is no container style, this reduces // the chances of messing up the layout with containerStyle configurations // can be overridden by the prop itself, but its likely that this is what is intended most of the time @@ -521,10 +383,10 @@ function Pager({ style={{ flex: 1, [targetDimension]: totalDimension, - transform: [{ [translateValue]: translation }], + transform: [{ [targetTransform]: containerTranslation }], }} > - {width === 0 + {width === UNSET ? null : adjacentChildren.map((child: any, i) => { // use map instead of React.Children because we want to track @@ -541,23 +403,22 @@ function Pager({ } return ( - - - + + + {child} - - - + + + ); })} @@ -569,80 +430,66 @@ function Pager({ ); } -interface iPage { - index: number; - dimension: Animated.Value; - translation: Animated.Node; - targetDimension: 'width' | 'height'; - translateValue: 'translateX' | 'translateY'; - pageInterpolation?: iPageInterpolation; - children: React.ReactNode; - clampPrev: Animated.Node; - clampNext: Animated.Node; -} - function _Page({ + children, index, + minimum, + maximum, dimension, - translation, + targetTransform, targetDimension, - translateValue, - clampPrev, - clampNext, pageInterpolation, - children, -}: iPage) { + animatedIndex, +}: any) { // compute the absolute position of the page based on index and dimension // this means that it's not relative to any other child, which is good because // it doesn't rely on a mechanism like flex, which requires all children to be present // to properly position pages const position = memoize(multiply(index, dimension)); + // min-max the position based on clamp values + // this means the will have a container that is always positioned + // in the same place, but the inner view can be translated within these bounds + const translation = memoize(minMax(position, minimum, maximum)); + const defaultStyle = memoize({ // map to height / width value depending on vertical / horizontal configuration + // this is crucial to getting child views to properly lay out [targetDimension]: dimension, // min-max the position based on clamp values // this means the will have a container that is always positioned // in the same place, but the inner view can be translated within these bounds - transform: [{ [translateValue]: min(max(position, clampPrev), clampNext) }], + transform: [ + { + [targetTransform]: translation, + }, + ], }); - // compute the relative offset value to the current translation (__not__ index) so + // compute the relative offset value to the current animated index so // that can use interpolation values that are in sync with drag gestures - const offset = memoize(divide(add(translation, position), max(dimension, 1))); + const offset = memoize(sub(index, animatedIndex)); // apply interpolation configs to const interpolatedStyles = memoize( interpolateWithConfig(offset, pageInterpolation) ); - // take out zIndex here as it needs to be applied to siblings, which inner containers - // are not, however the outer container requires the absolute translateX/Y to properly - // position itself + // take out zIndex here as it needs to be applied to siblings let { zIndex, ...otherStyles } = interpolatedStyles; // zIndex is not a requirement of interpolation // it will be clear when someone needs it as views will overlap with some configurations if (!zIndex) { - zIndex = -index; - } - - // prevent initial style interpolations from bleeding through by delaying the view - // appearance until it has first laid out, otherwise there are some flashes of transformation - // as the page enters the view - const [initialized, setInitialized] = useState(false); - function handleLayout() { - setInitialized(true); + zIndex = 0; } return ( @@ -652,12 +499,31 @@ function _Page({ ); } -const Page = memo(_Page); +// the only thing that changes in is the children, since it usually +// gets a fresh child from a .map function +const Page = memo(_Page, () => true); + +// utility to update animated values without changing their reference +// this is key for using memoized Animated.Values and prevents costly rerenders +function useAnimatedValue( + value?: number, + defaultValue?: number +): Animated.Value { + const initialValue = value || defaultValue || 0; + const animatedValue = memoize(new Value(initialValue)); + + useEffect(() => { + if (value !== undefined) { + animatedValue.setValue(value); + } + }, [value]); + + return animatedValue; +} type iPagerContext = [ number, (nextIndex: number) => void, - Animated.Value, Animated.Value ]; @@ -686,17 +552,14 @@ function PagerProvider({ const activeIndex = isControlled ? parentActiveIndex : _activeIndex; const onChange = isControlled ? parentOnChange : _setActiveIndex; - const animatedValue = memoize(new Value(0)); const animatedIndex = memoize(new Value(activeIndex)); return ( {typeof children === 'function' - ? children({ activeIndex, onChange }) + ? children({ activeIndex, onChange, animatedIndex }) : children} ); @@ -767,12 +630,12 @@ function useOnFocus(fn: Function) { function useAnimatedIndex() { const pager = usePager(); - return pager[3]; + return pager[2]; } function useOffset(index: number) { const pager = usePager(); - const animatedIndex = pager[3]; + const animatedIndex = pager[2]; const offset = memoize(sub(index, animatedIndex)); return offset; @@ -796,4 +659,5 @@ export { useIndex, useAnimatedIndex, useInterpolation, + IndexProvider, }; diff --git a/src/pagination.tsx b/src/pagination.tsx index b2d9f1e..877804f 100644 --- a/src/pagination.tsx +++ b/src/pagination.tsx @@ -1,7 +1,7 @@ import React, { Children } from 'react'; import Animated from 'react-native-reanimated'; import { ViewStyle, LayoutChangeEvent } from 'react-native'; -import { iPageInterpolation, useOffset, useAnimatedIndex } from './pager'; +import { iPageInterpolation, useOffset, useAnimatedIndex } from './pager-2'; import { memoize, interpolateWithConfig } from './util'; const { Value, divide, multiply, add } = Animated;