diff --git a/README.md b/README.md index 5c027ac..a3ad33f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ 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 | | `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | #### `HeroCarousel` diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index 1d1c915..ebcf824 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -31,20 +31,23 @@ export const AnimatedPagedView = forwardRef { 'worklet' - return clamp(value, 0, (childrenArray.length - 1) * slideWidth) + return clamp(value, 0, (slideCount - 1) * slideWidth) }, - [childrenArray.length, slideWidth], + [slideCount, slideWidth], ) const gesture = Gesture.Pan() .minDistance(MIN_DISTANCE_THRESHOLD) .onStart(() => { context.value = { x: translateX.value } - runOnJS(props.onScrollBeginDrag)() + runOnJS(onScrollBeginDrag)() }) .onUpdate((event) => { translateX.value = clampValue(context.value.x - event.translationX) @@ -72,9 +75,9 @@ export const AnimatedPagedView = forwardRef translateX.value, (value) => { - props.onScroll?.(value) + onScroll?.(value) }, - [props.onScroll], + [onScroll], ) useImperativeHandle( diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index 8665fca..1a22e66 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import React, { useState } from 'react' +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated' import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' import { HeroCarouselSlide } from '../HeroCarouselSlide' @@ -7,6 +8,7 @@ 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[] @@ -24,6 +26,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { interval, disableAutoScroll, autoScrollAnimation, + renderWindow, } = useCarouselContext() const { paddedChildrenArray } = useInfiniteScroll({ children, @@ -33,6 +36,16 @@ 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({ @@ -66,7 +79,16 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => { runAutoScroll={runAutoScroll} goToPage={goToPage} > - {child} + {isInRenderWindow({ + index, + activeIndex, + slideCount: children.length, + renderWindow, + isInfinite: !disableInfiniteScroll, + }) + ? child + : // An empty slot of the same width keeps every slide's position. + null} ))} diff --git a/src/components/HeroCarouselSlide/index.tsx b/src/components/HeroCarouselSlide/index.tsx index 0a6d09c..5f4c73f 100644 --- a/src/components/HeroCarouselSlide/index.tsx +++ b/src/components/HeroCarouselSlide/index.tsx @@ -1,10 +1,10 @@ import { View } from 'react-native' import { ItemContext } from '../../context/ItemContext' import { useAutoScroll } from '../../hooks/useAutoScroll' -import { useMemo } from 'react' +import { memo, useMemo } from 'react' import { useManualScroll } from '../../hooks/useManualScroll' -export const HeroCarouselSlide = ({ +export const HeroCarouselSlide = memo(function HeroCarouselSlide({ children, width, index, @@ -18,7 +18,7 @@ export const HeroCarouselSlide = ({ total: number runAutoScroll: ReturnType['runAutoScroll'] goToPage: ReturnType['goToPage'] -}) => { +}) { return ( ) -} +}) diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 8fac90f..92e95fa 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -39,6 +39,8 @@ 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 } export const CarouselContextProvider = ({ @@ -48,6 +50,7 @@ export const CarouselContextProvider = ({ interval = DEFAULT_INTERVAL, disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, + renderWindow, ...rest }: ContextProps) => { const userInteracted = useUserInteracted() @@ -69,6 +72,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, }), [ manualScroll, @@ -78,6 +82,7 @@ export const CarouselContextProvider = ({ interval, disableAutoScroll, autoScrollAnimation, + renderWindow, ], )} > diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 869c3f5..f90d5d9 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -26,6 +26,8 @@ export const useInfiniteScroll = ({ () => [childrenArray[childrenArray.length - 1], ...childrenArray, childrenArray[0]], [childrenArray], ) + // Worklets must not capture the array of React elements, only its length. + const paddedLength = paddedChildrenArray.length // This handles the infinite scrolling useAnimatedReaction( () => scrollValue.value, @@ -34,16 +36,16 @@ export const useInfiniteScroll = ({ const activeIndex = customRound(offset, ROUNDING_PRECISION) // if we are at the last index we need to switch to the second one without animation // second one because the first one is a clone of the last one - if (activeIndex >= paddedChildrenArray.length - 1) { + if (activeIndex >= paddedLength - 1) { goToPage(1) } // if we are at the first index we need to switch to the next to last one without animation // next to last one because the last one is a clone of the first one if (activeIndex <= 0.01) { - goToPage(paddedChildrenArray.length - 2, 0) + goToPage(paddedLength - 2, 0) } }, - [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue], + [goToPage, paddedLength, slideWidth, scrollValue], ) return useMemo( diff --git a/src/utils/__tests__/isInRenderWindow.test.ts b/src/utils/__tests__/isInRenderWindow.test.ts new file mode 100644 index 0000000..718d2cc --- /dev/null +++ b/src/utils/__tests__/isInRenderWindow.test.ts @@ -0,0 +1,56 @@ +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/isInRenderWindow.ts b/src/utils/isInRenderWindow.ts new file mode 100644 index 0000000..f661599 --- /dev/null +++ b/src/utils/isInRenderWindow.ts @@ -0,0 +1,29 @@ +/** + * 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 +}