diff --git a/README.md b/README.md index ff151da..25032e1 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,6 @@ The context provider that must wrap your carousel components. **All carousel con | `disableAutoScroll` | `boolean` | `false` | Disable automatic scrolling | | `disableInfiniteScroll` | `boolean` | `false` | Disable infinite scrolling (shows first/last slide boundaries) | | `autoScrollAnimation` | `(to: number, duration: number) => number` | `withTiming` | Custom animation function for auto-scroll transitions | -| `renderWindow` | `number` | all slides | Slides kept mounted on each side of the current one; for long carousels | | `centerSlide` | `boolean` | `false` | Centre the current slide when `slideWidth` is narrower, so neighbours peek in | | `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | diff --git a/src/components/AnimatedPagedView/Adapter.tsx b/src/components/AnimatedPagedView/Adapter.tsx index 28045dd..22fd3ff 100644 --- a/src/components/AnimatedPagedView/Adapter.tsx +++ b/src/components/AnimatedPagedView/Adapter.tsx @@ -1,5 +1,5 @@ import { useAnimatedReaction } from 'react-native-reanimated' -import { useRef } from 'react' +import { useCallback, useRef } from 'react' import { AnimatedPagedScrollViewRef, AnimatedPagedView } from './index' import { customRound } from '../../utils/round' import { ROUNDING_PRECISION } from '../HeroCarousel/index.preset' @@ -22,6 +22,9 @@ export const HeroCarouselAdapter = ({ [], ) + // Stable, so the pager's memoized gesture isn't rebuilt on every render. + const handleScrollBeginDrag = useCallback(() => setUserInteracted(true), [setUserInteracted]) + const handleScroll = (value: number) => { 'worklet' const activeIndex = customRound(value / slideWidth, ROUNDING_PRECISION) @@ -32,9 +35,7 @@ export const HeroCarouselAdapter = ({ return ( { - setUserInteracted(true) - }} + onScrollBeginDrag={handleScrollBeginDrag} onScroll={handleScroll} > {children} diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index bcd9a5e..dafa72f 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback, useImperativeHandle } from 'react' +import React, { forwardRef, useCallback, useImperativeHandle, useMemo } from 'react' import Animated, { useSharedValue, useAnimatedStyle, @@ -22,8 +22,10 @@ type AnimatedPagedViewProps = { children: React.ReactNode } -// easiest way to allow other gestures to work, for example the scroll view -const MIN_DISTANCE_THRESHOLD = 30 +// The pan starts only after this much horizontal movement, and gives way to a vertical scroll after +// this much vertical movement, so a parent scroll view keeps its gestures. +const ACTIVE_OFFSET_X = 10 +const FAIL_OFFSET_Y = 10 export const AnimatedPagedView = forwardRef( (props, ref) => { @@ -46,27 +48,36 @@ export const AnimatedPagedView = forwardRef { - context.value = { x: translateX.value } - runOnJS(onScrollBeginDrag)() - }) - .onUpdate((event) => { - translateX.value = clampValue(context.value.x - event.translationX) - }) - .onEnd((event) => { - const velocity = event.velocityX - const currentPage = Math.round(translateX.value / slideWidth) - const targetPage = - velocity > 500 ? currentPage - 1 : velocity < -500 ? currentPage + 1 : currentPage - // in case the gesture overshoots, snap to the nearest page - if (Math.abs(context.value.x - translateX.value) > slideWidth / 2) { - translateX.value = withTiming(clampValue(currentPage * slideWidth)) - } else { - translateX.value = withTiming(clampValue(targetPage * slideWidth)) - } - }) + // Memoized, because a gesture rebuilt mid-drag (the carousel re-renders while it's dragged) + // can disturb the drag in progress. + const gesture = useMemo( + () => + Gesture.Pan() + .activeOffsetX([-ACTIVE_OFFSET_X, ACTIVE_OFFSET_X]) + .failOffsetY([-FAIL_OFFSET_Y, FAIL_OFFSET_Y]) + .onStart((event) => { + // The finger has already moved by the time the pan starts. Counting that movement in + // keeps the row where it is, instead of jumping to catch up with the finger. + context.value = { x: translateX.value + event.translationX } + runOnJS(onScrollBeginDrag)() + }) + .onUpdate((event) => { + translateX.value = clampValue(context.value.x - event.translationX) + }) + .onEnd((event) => { + const velocity = event.velocityX + const currentPage = Math.round(translateX.value / slideWidth) + const targetPage = + velocity > 500 ? currentPage - 1 : velocity < -500 ? currentPage + 1 : currentPage + // in case the gesture overshoots, snap to the nearest page + if (Math.abs(context.value.x - translateX.value) > slideWidth / 2) { + translateX.value = withTiming(clampValue(currentPage * slideWidth)) + } else { + translateX.value = withTiming(clampValue(targetPage * slideWidth)) + } + }), + [clampValue, context, onScrollBeginDrag, slideWidth, translateX], + ) const animatedStyle = useAnimatedStyle(() => { const clampedTranslateX = clampValue(translateX.value) diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index 1a22e66..8665fca 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -1,5 +1,4 @@ -import React, { useState } from 'react' -import { runOnJS, useAnimatedReaction } from 'react-native-reanimated' +import React from 'react' import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' import { HeroCarouselSlide } from '../HeroCarouselSlide' @@ -8,7 +7,6 @@ import { useAutoScroll } from '../../hooks/useAutoScroll' import { useInfiniteScroll } from '../../hooks/useInfiniteScroll' import { DEFAULT_INTERVAL } from './index.preset' import { ItemAnimatedView } from '../ItemAnimatedView' -import { isInRenderWindow } from '../../utils/isInRenderWindow' export type HeroCarouselProps = { children: React.ReactNode[] @@ -26,7 +24,6 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { interval, disableAutoScroll, autoScrollAnimation, - renderWindow, } = useCarouselContext() const { paddedChildrenArray } = useInfiniteScroll({ children, @@ -36,16 +33,6 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { disabled: disableInfiniteScroll, }) - // Follows the page React-side only when the rounded page changes, not on every frame. - const [activeIndex, setActiveIndex] = useState(() => Math.round(scrollValue.value)) - - useAnimatedReaction( - () => Math.round(scrollValue.value), - (index, previous) => { - if (index !== previous) runOnJS(setActiveIndex)(index) - }, - ) - const autoScrollEnabled = !userInteracted const { runAutoScroll } = useAutoScroll({ @@ -79,16 +66,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { runAutoScroll={runAutoScroll} goToPage={goToPage} > - {isInRenderWindow({ - index, - activeIndex, - slideCount: children.length, - renderWindow, - isInfinite: !disableInfiniteScroll, - }) - ? child - : // An empty slot of the same width keeps every slide's position. - null} + {child} ))} diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index dfd06ba..0491594 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -39,8 +39,6 @@ type ContextProps = { interval?: number | ((index: number) => number) disableAutoScroll?: boolean autoScrollAnimation?: (to: number, duration: number) => number - /** Slides kept mounted on each side of the current one. Mounts every slide when omitted. */ - renderWindow?: number /** Centres the current slide when `slideWidth` is narrower than the carousel, so its neighbours peek in on both sides. */ centerSlide?: boolean } @@ -52,7 +50,6 @@ export const CarouselContextProvider = ({ interval = DEFAULT_INTERVAL, disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, - renderWindow, centerSlide = false, ...rest }: ContextProps) => { @@ -75,7 +72,6 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, - renderWindow, centerSlide, }), [ @@ -86,7 +82,6 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, - renderWindow, centerSlide, ], )} diff --git a/src/utils/__tests__/interpolateInsideCarouselEquivalence.test.ts b/src/utils/__tests__/interpolateInsideCarouselEquivalence.test.ts new file mode 100644 index 0000000..91d8788 --- /dev/null +++ b/src/utils/__tests__/interpolateInsideCarouselEquivalence.test.ts @@ -0,0 +1,67 @@ +import { Extrapolation, interpolate } from 'react-native-reanimated' + +import { interpolateInsideCarousel } from '../interpolateInsideCarousel' + +// The previous implementation, which built a point for every slide. +const interpolateOverAllSlides = ( + scrollValue: number, + slideIndex: number, + totalLength: number, + values: { valueBefore: number; thisValue: number; valueAfter: number; offset?: number }, + isInfinite: boolean, +) => { + const { valueBefore, thisValue, valueAfter, offset = 0 } = values + + if (totalLength === 1) return thisValue + + const getAdjustedIndex = (index: number) => { + if (!isInfinite) return index + if (index === 0) return Math.max(totalLength - 2, 0) + if (index === totalLength - 1) return 1 + if (index === 1) return totalLength - 1 + return index + } + const adjustedIndex = getAdjustedIndex(slideIndex) + + const inputRange = Array.from({ length: totalLength }).map((_, index) => { + if (index < slideIndex) return index + offset + if (index > slideIndex) return index - offset + return index + }) + const outputValues = inputRange.map((_, index) => { + if (index === adjustedIndex) return thisValue + if (index === slideIndex) return thisValue + if (index < slideIndex) return valueAfter + return valueBefore + }) + + return interpolate(scrollValue, inputRange, outputValues, Extrapolation.CLAMP) +} + +describe('interpolateInsideCarousel matches interpolating over every slide', () => { + const valueSets = [ + { valueBefore: 0, thisValue: 1, valueAfter: 0 }, + { valueBefore: 20, thisValue: 0, valueAfter: -20 }, + { valueBefore: 0, thisValue: 100, valueAfter: 50, offset: 0.2 }, + ] + + for (const isInfinite of [true, false]) { + for (const totalLength of [2, 3, 4, 7, 12]) { + it(`${totalLength} slides, infinite ${isInfinite}`, () => { + for (const values of valueSets) { + for (let slideIndex = 0; slideIndex < totalLength; slideIndex++) { + for (let step = 0; step <= (totalLength - 1) * 20; step++) { + const scrollValue = step / 20 + expect( + interpolateInsideCarousel(scrollValue, slideIndex, totalLength, values, isInfinite), + ).toBeCloseTo( + interpolateOverAllSlides(scrollValue, slideIndex, totalLength, values, isInfinite), + 10, + ) + } + } + } + }) + } + } +}) diff --git a/src/utils/__tests__/isInRenderWindow.test.ts b/src/utils/__tests__/isInRenderWindow.test.ts deleted file mode 100644 index 718d2cc..0000000 --- a/src/utils/__tests__/isInRenderWindow.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { isInRenderWindow } from '../isInRenderWindow' - -describe('isInRenderWindow', () => { - it('mounts every slide without a render window', () => { - expect( - isInRenderWindow({ - index: 40, - activeIndex: 0, - slideCount: 50, - renderWindow: undefined, - isInfinite: false, - }), - ).toBe(true) - }) - - it('mounts slides within the window around the current one', () => { - const check = (index: number) => - isInRenderWindow({ - index, - activeIndex: 10, - slideCount: 50, - renderWindow: 2, - isInfinite: false, - }) - - expect([7, 8, 9, 10, 11, 12, 13].map(check)).toEqual([ - false, - true, - true, - true, - true, - true, - false, - ]) - }) - - // With infinite scroll and 10 slides, rendered index 0 clones slide 9, 1-10 are the real slides - // and 11 clones slide 0. - const infinite = (index: number, activeIndex: number) => - isInRenderWindow({ index, activeIndex, slideCount: 10, renderWindow: 2, isInfinite: true }) - - it('keeps the first real slide mounted while on the clone at the end', () => { - expect(infinite(1, 11)).toBe(true) - }) - - it('keeps the last real slide mounted while on the clone at the start', () => { - expect(infinite(10, 0)).toBe(true) - }) - - it('wraps the window around the ends', () => { - // On the first real slide, the last two real slides are its neighbours to the left. - expect(infinite(10, 1)).toBe(true) - expect(infinite(9, 1)).toBe(true) - expect(infinite(8, 1)).toBe(false) - }) -}) diff --git a/src/utils/interpolateInsideCarousel.ts b/src/utils/interpolateInsideCarousel.ts index d64d09b..b68c5e6 100644 --- a/src/utils/interpolateInsideCarousel.ts +++ b/src/utils/interpolateInsideCarousel.ts @@ -39,17 +39,27 @@ export const interpolateInsideCarousel = ( const adjustedIndex = getAdjustedIndex(slideIndex) - const inputRange = Array.from({ length: totalLength }).map((_, index) => { + // Only the points around the slide and around its clone change the result. Every other point + // sits on a flat stretch between two of these, so leaving it out gives the same value, and the + // work no longer grows with the number of slides. + const indexes: number[] = [] + for (const center of [slideIndex, adjustedIndex]) { + for (let index = center - 1; index <= center + 1; index++) { + if (index >= 0 && index < totalLength && !indexes.includes(index)) indexes.push(index) + } + } + indexes.sort((a, b) => a - b) + + const inputRange = indexes.map((index) => { if (index < slideIndex) return index + offset if (index > slideIndex) return index - offset return index }) - const outputValues = inputRange.map((_, index) => { + const outputValues = indexes.map((index) => { if (index === adjustedIndex) return thisValue if (index === slideIndex) return thisValue if (index < slideIndex) return valueAfter - if (index > slideIndex) return valueBefore - return 0 + return valueBefore }) return interpolate(scrollValue, inputRange, outputValues, Extrapolation.CLAMP) diff --git a/src/utils/isInRenderWindow.ts b/src/utils/isInRenderWindow.ts deleted file mode 100644 index f661599..0000000 --- a/src/utils/isInRenderWindow.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Whether the slide at `index` stays mounted while `activeIndex` is the current one. - * Both are indexes into the rendered slides. With infinite scroll those start and end with a - * clone, so the distance is measured between the real slides and wraps around: the slide a clone - * jumps to is then always mounted. - */ -export const isInRenderWindow = ({ - index, - activeIndex, - slideCount, - renderWindow, - isInfinite, -}: { - index: number - activeIndex: number - /** Number of real slides, clones excluded. */ - slideCount: number - /** Slides kept mounted on each side of the current one. Everything is mounted when omitted. */ - renderWindow: number | undefined - isInfinite: boolean -}) => { - if (renderWindow === undefined) return true - if (!isInfinite) return Math.abs(index - activeIndex) <= renderWindow - - const toReal = (renderedIndex: number) => (renderedIndex - 1 + slideCount) % slideCount - const distance = Math.abs(toReal(index) - toReal(activeIndex)) - - return Math.min(distance, slideCount - distance) <= renderWindow -}