Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
9 changes: 5 additions & 4 deletions src/components/AnimatedPagedView/Adapter.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
Expand All @@ -32,9 +35,7 @@ export const HeroCarouselAdapter = ({
return (
<AnimatedPagedView
ref={scrollViewRef}
onScrollBeginDrag={() => {
setUserInteracted(true)
}}
onScrollBeginDrag={handleScrollBeginDrag}
onScroll={handleScroll}
>
{children}
Expand Down
59 changes: 35 additions & 24 deletions src/components/AnimatedPagedView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { forwardRef, useCallback, useImperativeHandle } from 'react'
import React, { forwardRef, useCallback, useImperativeHandle, useMemo } from 'react'
import Animated, {
useSharedValue,
useAnimatedStyle,
Expand All @@ -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<AnimatedPagedScrollViewRef, AnimatedPagedViewProps>(
(props, ref) => {
Expand All @@ -46,27 +48,36 @@ export const AnimatedPagedView = forwardRef<AnimatedPagedScrollViewRef, Animated
[slideCount, slideWidth],
)

const gesture = Gesture.Pan()
.minDistance(MIN_DISTANCE_THRESHOLD)
.onStart(() => {
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)
Expand Down
26 changes: 2 additions & 24 deletions src/components/HeroCarousel/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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[]
Expand All @@ -26,7 +24,6 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => {
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
} = useCarouselContext()
const { paddedChildrenArray } = useInfiniteScroll({
children,
Expand All @@ -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({
Expand Down Expand Up @@ -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}
</HeroCarouselSlide>
))}
</HeroCarouselAdapter>
Expand Down
5 changes: 0 additions & 5 deletions src/context/CarouselContext/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -52,7 +50,6 @@ export const CarouselContextProvider = ({
interval = DEFAULT_INTERVAL,
disableAutoScroll = false,
autoScrollAnimation = DEFAULT_ANIMATION,
renderWindow,
centerSlide = false,
...rest
}: ContextProps) => {
Expand All @@ -75,7 +72,6 @@ export const CarouselContextProvider = ({
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
centerSlide,
}),
[
Expand All @@ -86,7 +82,6 @@ export const CarouselContextProvider = ({
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
centerSlide,
],
)}
Expand Down
67 changes: 67 additions & 0 deletions src/utils/__tests__/interpolateInsideCarouselEquivalence.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
}
}
}
})
}
}
})
56 changes: 0 additions & 56 deletions src/utils/__tests__/isInRenderWindow.test.ts

This file was deleted.

18 changes: 14 additions & 4 deletions src/utils/interpolateInsideCarousel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 0 additions & 29 deletions src/utils/isInRenderWindow.ts

This file was deleted.

Loading