diff --git a/README.md b/README.md index 5b5a468..31ebbee 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ From App.js in /example directory import React, { useState } from 'react'; import { StyleSheet, View, Text, TouchableOpacity } from 'react-native'; -import { Pager } from '@crowdlinker/react-native-pager'; +import { Pager, useFocus } from '@crowdlinker/react-native-pager'; const children = Array.from({ length: 1000 }, (_, i) => ( @@ -99,6 +99,7 @@ const colors = [ ]; function Slide({ i }: { i: number }) { + const focused = useFocus(); return ( {`Screen: ${i}`} + {`Focused: ${focused}`} ); } diff --git a/example/App.tsx b/example/App.tsx index 9c30aa6..1990c09 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -18,7 +18,6 @@ import {SwipeCards} from './src/swipe-cards'; import {Stack} from './src/stack'; import {Tabs} from './src/tabs'; import {MyPager} from './src/basic-example'; -import {SingleStackExample} from './src/single-stack-example'; import {PagerProvider} from '@crowdlinker/react-native-pager'; const App = () => { diff --git a/example/src/basic-example.tsx b/example/src/basic-example.tsx index d4fab63..450363a 100644 --- a/example/src/basic-example.tsx +++ b/example/src/basic-example.tsx @@ -3,13 +3,21 @@ import {StyleSheet, View, Text, TouchableOpacity} from 'react-native'; import {Pager} from '@crowdlinker/react-native-pager'; import {Slide, NavigationButtons} from './shared-components'; -const children = Array.from({length: 1000}, (_, i) => ); +const children = Array.from({length: 10000}, (_, i) => ); function MyPager() { - const [activeIndex, onChange] = useState(400); + const [activeIndex, onChange] = useState(5000); return ( + + {`Number of screens: ${children.length}`} + + {children} + ); diff --git a/example/src/shared-components.tsx b/example/src/shared-components.tsx index b93e2b1..fd12d69 100644 --- a/example/src/shared-components.tsx +++ b/example/src/shared-components.tsx @@ -1,5 +1,13 @@ import React, {useState} from 'react'; -import {Text, View, TouchableOpacity, StyleSheet} from 'react-native'; +import { + Text, + View, + TouchableOpacity, + StyleSheet, + TextInput, + Button, +} from 'react-native'; +import {useFocus} from '@crowdlinker/react-native-pager'; const colors = [ 'aquamarine', @@ -12,8 +20,10 @@ const colors = [ 'salmon', ]; -function Slide({i, focused}: {i: number; focused?: boolean}) { - const [count, setCount] = useState(0); +function Slide({i}: {i: number}) { + // const [count, setCount] = useState(0); + const focused = useFocus(); + return ( - - - - - - - - - - - ); -} - -export {SingleStackExample}; diff --git a/example/src/stack.tsx b/example/src/stack.tsx index 460ab85..a5bc8ba 100644 --- a/example/src/stack.tsx +++ b/example/src/stack.tsx @@ -55,7 +55,7 @@ function Stack() { justifyContent: 'center', alignItems: 'center', }}> - Push + Push diff --git a/src/index.tsx b/src/index.tsx index 44af19a..591955b 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,2 @@ export * from './pager'; export * from './pagination'; -export * from './single-stack'; diff --git a/src/pager.tsx b/src/pager.tsx index 19e714b..e09d414 100644 --- a/src/pager.tsx +++ b/src/pager.tsx @@ -6,7 +6,7 @@ import React, { useContext, useEffect, memo, - cloneElement, + useRef, } from 'react'; import { StyleSheet, LayoutChangeEvent, ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; @@ -15,7 +15,7 @@ import { State, PanGestureHandlerProperties, } from 'react-native-gesture-handler'; -import { memoize, mapConfigToStyle } from './util'; +import { memoize, mapConfigToStyle, safelyUpdateValues } from './util'; export type SpringConfig = { damping: Animated.Adaptable; @@ -98,7 +98,7 @@ const DEFAULT_SPRING_CONFIG = { restSpeedThreshold: 0.01, }; -export interface PagerProps { +export interface iPager { activeIndex?: number; onChange?: (nextIndex: number) => void; initialIndex?: number; @@ -152,7 +152,7 @@ function Pager({ next: REALLY_BIG_NUMBER, }, animatedIndex: parentAnimatedIndex, -}: PagerProps) { +}: iPager) { const context = useContext(PagerContext); // register these props if they exist -- they can be shared with other @@ -209,8 +209,11 @@ function Pager({ // set this up on first render, and update when the value from above changes const maxIndex = memoize(new Value(maxIndexValue)); + const maxIndexUpdateReq = useRef(undefined); useEffect(() => { - maxIndex.setValue(maxIndexValue); + safelyUpdateValues(() => { + maxIndex.setValue(maxIndexValue); + }, maxIndexUpdateReq); }, [maxIndexValue]); const dragX = memoize(new Value(0)); @@ -252,15 +255,19 @@ function Pager({ const width = memoize(new Value(0)); const height = memoize(new Value(0)); + const layoutRequest = useRef(undefined); + function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { - width.setValue(layout.width as any); - height.setValue(layout.height as any); - - // this sets the initial offset to the correct translation w/o animation - // e.g an initial index of 4 will be centered when layout registers - translationValue.setValue((activeIndex * - layout[targetDimension] * - -1) as any); + safelyUpdateValues(() => { + width.setValue(layout.width as any); + height.setValue(layout.height as any); + + // this sets the initial offset to the correct translation w/o animation + // e.g an initial index of 4 will be centered when layout registers + translationValue.setValue((activeIndex * + layout[targetDimension] * + -1) as any); + }, layoutRequest); } // correctly assign variables based on vertical / horizontal configurations @@ -343,9 +350,14 @@ function Pager({ // not sure if Animated.useCode is any better here, it seemed to fire much more // frequently than activeIndex was actually changing. + + const indexChangeRequest = useRef(undefined); + useEffect(() => { if (activeIndex >= minIndex && activeIndex <= maxIndexValue) { - nextPosition.setValue(activeIndex); + safelyUpdateValues(() => { + nextPosition.setValue(activeIndex); + }, indexChangeRequest); } }, [activeIndex, nextPosition]); @@ -525,7 +537,9 @@ function Pager({ clampNext={clampNext} pageInterpolation={pageInterpolation} > - {cloneElement(child, { focused: activeIndex === index })} + + {child} + ); })} @@ -679,4 +693,24 @@ function usePager(): iPagerContext { return context; } -export { Pager, PagerProvider, usePager, PagerContext }; +// 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; +} + +export { Pager, PagerProvider, usePager, PagerContext, useFocus }; diff --git a/src/pagination.tsx b/src/pagination.tsx index d74bc43..5d2688c 100644 --- a/src/pagination.tsx +++ b/src/pagination.tsx @@ -1,8 +1,8 @@ -import React, { Children } from 'react'; +import React, { Children, useRef } from 'react'; import Animated from 'react-native-reanimated'; import { ViewStyle, LayoutChangeEvent } from 'react-native'; import { iPageInterpolation, usePager } from './pager'; -import { memoize, mapConfigToStyle } from './util'; +import { memoize, mapConfigToStyle, safelyUpdateValues } from './util'; const { sub, Value, divide, multiply, add } = Animated; @@ -105,9 +105,12 @@ function Slider({ : new Value(0); const width = memoize(new Value(0)); + const request = useRef(undefined); function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { - width.setValue(layout.width as any); + safelyUpdateValues(() => { + width.setValue(layout.width as any); + }, request); } const sliderWidth = divide(width, numberOfScreens); @@ -142,9 +145,12 @@ function Progress({ : new Value(0); const width = memoize(new Value(0)); + const request = useRef(undefined); function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { - width.setValue(layout.width as any); + safelyUpdateValues(() => { + width.setValue(layout.width as any); + }, request); } const sliderWidth = divide( diff --git a/src/single-stack.tsx b/src/single-stack.tsx deleted file mode 100644 index b1ea09d..0000000 --- a/src/single-stack.tsx +++ /dev/null @@ -1,442 +0,0 @@ -import React, { Children, useEffect, useContext, useState } from 'react'; -import { PanGestureHandler, State } from 'react-native-gesture-handler'; -import Animated from 'react-native-reanimated'; -import { LayoutChangeEvent, StyleSheet, ViewStyle } from 'react-native'; -import { mapConfigToStyle, memoize, runSpring } from './util'; -import { iPageInterpolation, SpringConfig, PagerContext } from './pager'; - -// SingleStack... can't think of a better name for this as of yet, it's likely to change... - -// this component manages the translation of two container views -// and translates them in and out of the active scope based on provided -// rootIndex and activeIndex values - -// the root view always stays in the center, as non-root views should only appear on -// top of the root view -- this component is mainly for navigation structures where -// there are multiple different, branched pathways to go to from a root view -// but the child views aren't related to eachother and so should appear to operate independently - -// the two containers with non-root views also manage their children views, springing the active -// view in and out of the window. this likely will only provide a layer of consistency, -// in the wild these situations should rarely occur, as those kinds of transitions indicate -// the two views are somehow related, and a different navigation component should probably -// be used - -const { - event, - block, - Value, - divide, - cond, - eq, - add, - Clock, - set, - neq, - sub, - call, - max, - min, - greaterThan, - abs, - multiply, - // @ts-ignore - debug, - lessThan, - floor, - and, - greaterOrEq, - lessOrEq, - ceil, - diff, -} = Animated; - -interface iSingleStack { - children: React.ReactNode[]; - activeIndex?: number; - onChange?: (nextIndex: number) => void; - rootIndex: number; - style?: ViewStyle; - rootInterpolation?: iPageInterpolation; - pageInterpolation?: iPageInterpolation; - springConfig?: Partial; - threshold?: number; - type?: 'vertical' | 'horizontal'; -} - -function SingleStack({ - children, - activeIndex: parentActiveIndex, - onChange: parentOnChange, - rootIndex, - style, - rootInterpolation, - pageInterpolation, - springConfig, - threshold = 0.2, - type = 'horizontal', -}: iSingleStack) { - const context = useContext(PagerContext); - const [_activeIndex, _onChange] = useState(parentActiveIndex || 0); - // 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 - : context - ? context[0] - : (_activeIndex as any); - - const onChange = isControlled - ? parentOnChange - : context - ? context[1] - : (_onChange as any); - - const width = memoize(new Value(0)); - const height = memoize(new Value(0)); - - function handleLayout({ nativeEvent: { layout } }: LayoutChangeEvent) { - width.setValue(layout.width); - height.setValue(layout.height); - } - - const dragX = memoize(new Value(0)); - const dragY = memoize(new Value(0)); - const gestureState = memoize(new Value(-1)); - - const handleGesture = memoize( - event( - [ - { - nativeEvent: { - translationX: dragX, - translationY: dragY, - }, - }, - ], - { useNativeDriver: true } - ) - ); - - const handleStateChange = memoize( - event( - [ - { - nativeEvent: { - state: gestureState, - }, - }, - ], - { - useNativeDriver: true, - } - ) - ); - - const dimension = type === 'vertical' ? height : width; - const translateValue = type === 'vertical' ? 'translateY' : 'translateX'; - const dragValue = type === 'vertical' ? dragY : dragX; - - const animatedIndex = memoize(new Value(activeIndex)); - - // memoize the animated index and next position to prevent extra rerenders - // in child components, using an integer prop that changes rapidly causes rerenders that - // can be prevented with no loss to the state of the component by updating Animated.Values here - useEffect(() => { - // nextPosition should always be a value between -1 and 1 - const offset = activeIndex - rootIndex; - const nextOffset = Math.max(Math.min(offset, 1), -1); - - nextPosition.setValue(nextOffset); - animatedIndex.setValue(activeIndex); - }, [activeIndex]); - - const initialOffset = activeIndex - rootIndex; - const initialPosition = memoize(Math.max(Math.min(initialOffset, 1), -1)); - - // position is the drag + active value ranging from -1 to 1 - // every child container and view computes its offset from this value - - // tracking and updating a single position is necessary to get smooth springing between indices - // otherwise there is a bit of a jank when updating an activeIndex value - // as there will always be a slight frame delay between states - // e.g passing a dragEnd value to a child view on drag end and runSpring in that view always means - // there will be a slight jank, can't quite figure out a better way to achieve smooth transitions - // without tracking and sharing one value with all views - // it's likely there is a slight jank still, just not so noticeable - const position = memoize(new Value(initialPosition)); - const nextPosition = memoize(new Value(initialPosition)); - - const clock = memoize(new Clock()); - - const swiping = memoize(new Value(0)); - const dragStart = memoize(new Value(0)); - const percentageDragged = memoize(new Value(0)); - const roundedPosition = memoize( - cond(lessThan(position, 0), floor(position), ceil(position)) - ); - - const animatedOffset = memoize( - block([ - cond( - eq(gestureState, State.ACTIVE), - [ - cond(swiping, 0, [set(dragStart, position)]), - - set(swiping, 1), - - set( - percentageDragged, - divide(abs(add(dragValue, dragStart)), max(dimension, 1)) - ), - - set( - nextPosition, - cond(greaterThan(percentageDragged, threshold), [0], [nextPosition]) - ), - - set(position, sub(dragStart, divide(dragValue, dimension))), - ], - [ - cond(neq(roundedPosition, nextPosition), [ - cond(swiping, call([], ([]) => onChange(rootIndex))), - ]), - - set(swiping, 0), - set(percentageDragged, 0), - - runSpring(clock, position, nextPosition, springConfig), - ] - ), - - position, - ]) - ); - - const interpolatedStyle = memoize( - mapConfigToStyle(animatedOffset, rootInterpolation) - ); - - // these are used to compute the offset of the two containers - // e.g offset = -width or offset = width - const leftPosition = memoize(new Value(-1)); - const rightPosition = memoize(new Value(1)); - - // this crazy computation clamps the value of container translate offsets (-1 -> 0) - // between a given range -> either -1 to 0 or 0 to 1 - const clampLeft = memoize(min(max(sub(leftPosition, animatedOffset), -1), 0)); - const clampRight = memoize( - max(min(sub(rightPosition, animatedOffset), 1), 0) - ); - - // divide the children up into container views - const childrenLeft = children.slice(0, rootIndex); - const childrenRight = children.slice(rootIndex + 1); - const childCenter = children[rootIndex]; - - const numberOfScreens = Children.count(children); - - return ( - - - - - - {childrenLeft} - - - - {childCenter} - - - - {childrenRight} - - - - - - ); -} - -interface iPageContainer { - children: React.ReactNode; - dimension: Animated.Node; - offset: Animated.Node; - activeIndex: Animated.Node; - pageInterpolation?: iPageInterpolation; - minIndex: number; - position: number; - range: [number, number]; - swiping: Animated.Node; - springConfig?: Partial; - translateValue: 'translateX' | 'translateY'; -} - -function PageContainer({ - children, - dimension, - offset, - pageInterpolation, - activeIndex, - minIndex, - position, - swiping, - range, - springConfig, - translateValue, -}: iPageContainer) { - const translation = memoize(multiply(dimension, offset)); - const interpolatedStyle = memoize( - mapConfigToStyle(offset, pageInterpolation) - ); - - return ( - - - - {Children.map(children, (child: any, index: number) => ( - - {child} - - ))} - - - - ); -} - -interface iPage { - children: React.ReactNode; - dimension: Animated.Node; - index: number; - parentOffset: number; - activeIndex: Animated.Node; - range: [number, number]; - swiping: Animated.Node; - springConfig?: Partial; - translateValue: 'translateX' | 'translateY'; -} - -function Page({ - children, - dimension, - parentOffset, - activeIndex, - index, - swiping, - range, - springConfig, - translateValue, -}: iPage) { - const clock = memoize(new Clock()); - const position = memoize(new Value(0)); - const zIndex = memoize(new Value(0)); - - const offset = memoize(multiply(dimension, parentOffset)); - - const isActive = memoize(eq(activeIndex, index)); - // if dragging value or active value is within range of this container - const containerIsActive = memoize( - and(greaterOrEq(activeIndex, range[0]), lessOrEq(activeIndex, range[1])) - ); - - // these updates are primarily to keep continuity in the UI for child views - // it will keep the last rendered view on top as it translates off screen, - // and set all views to be collapsed into the container view with no offsets - // when the container becomes inactive - const nextPosition = memoize( - block([ - // set initial position - cond(greaterThan(diff(dimension), 0), [ - set(position, cond(isActive, 0, offset)), - ]), - - cond( - swiping, - cond( - isActive, - [set(position, 0)], - [set(zIndex, 0), set(position, offset)] - ) - ), - - // if the view is active (e.g it matches activeIndex), center it back in - // the container - cond( - isActive, - [set(zIndex, 1), 0], - [set(zIndex, 0), cond(containerIsActive, offset, set(position, 0))] - ), - ]) - ); - - const translation = memoize( - runSpring(clock, position, nextPosition, springConfig) - ); - - return ( - - {children} - - ); -} - -export { SingleStack }; diff --git a/src/util.ts b/src/util.ts index 54221bc..fb44a2b 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, MutableRefObject } from 'react'; import { ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import { iPageInterpolation, SpringConfig } from './pager'; @@ -116,4 +116,22 @@ function runSpring( ]); } -export { mapConfigToStyle, memoize, runSpring }; +// prevents layout bugs on multiply calls to an Animated.Value.setValue() +// setValue() issue: https://github.com/kmagiera/react-native-reanimated/issues/216 +// it's not entirely foolproof, not exactly sure how this works to be honest, +// but from the issue above it appears to happen only in debug mode anyways +function safelyUpdateValues( + fn: Function, + ref: MutableRefObject +) { + if (ref.current) { + cancelAnimationFrame(ref.current); + } + + ref.current = requestAnimationFrame(() => { + fn(); + ref.current = undefined; + }); +} + +export { mapConfigToStyle, memoize, runSpring, safelyUpdateValues };