From e6b25b34ea3041c1b9de750ecdbcf990ac955b0a Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 22:47:56 +0200 Subject: [PATCH 1/3] perf: interpolate over the points near the slide only interpolateInsideCarousel built and searched one point per slide on every frame, for every slide using it, so a long carousel got slower the more slides it had. Only the points around the slide and around its clone change the result, so it builds those alone. The result is identical, which a test checks against the old all-slides version. Co-Authored-By: Claude Opus 5.5 --- ...terpolateInsideCarouselEquivalence.test.ts | 67 +++++++++++++++++++ src/utils/interpolateInsideCarousel.ts | 18 +++-- 2 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/utils/__tests__/interpolateInsideCarouselEquivalence.test.ts 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/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) From e8be1e4221a196e3f2d15da913d2ebea25b5de7c Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 22:49:13 +0200 Subject: [PATCH 2/3] fix: stop the row jumping when a drag starts or the carousel re-renders The pan activated only after 30pt of movement in any direction, but onUpdate subtracted the translation from where the finger first touched, so the row jumped by that distance when a drag started. The starting point now counts the movement made before activation. The pan now activates on horizontal movement only and fails on vertical movement, so scrolling a parent past the carousel no longer drags and snaps it. The gesture is memoized, and the adapter's drag callback is stable, so a re-render during a drag no longer hands the detector a new gesture. Co-Authored-By: Claude Opus 5.5 --- src/components/AnimatedPagedView/Adapter.tsx | 9 +-- src/components/AnimatedPagedView/index.tsx | 59 ++++++++++++-------- 2 files changed, 40 insertions(+), 28 deletions(-) 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) From f944505799dcf4663853074168f25758703c9e17 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Sat, 10 Oct 2026 23:01:22 +0200 Subject: [PATCH 3/3] perf: remove renderWindow Mounting slides as they entered the window happened during the snap animation, and the page tracking behind it re-rendered the carousel on every page change, even without a window. Building the new slide's views mid-animation made swiping visibly stutter, which cost more than keeping every slide mounted saved. Every slide stays mounted again. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 - src/components/HeroCarousel/index.tsx | 26 +-------- src/context/CarouselContext/index.tsx | 5 -- src/utils/__tests__/isInRenderWindow.test.ts | 56 -------------------- src/utils/isInRenderWindow.ts | 29 ---------- 5 files changed, 2 insertions(+), 115 deletions(-) delete mode 100644 src/utils/__tests__/isInRenderWindow.test.ts delete mode 100644 src/utils/isInRenderWindow.ts 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/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__/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/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 -}