Skip to content
Merged
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: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
13 changes: 8 additions & 5 deletions src/components/AnimatedPagedView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,23 @@ export const AnimatedPagedView = forwardRef<AnimatedPagedScrollViewRef, Animated
const context = useSharedValue({ x: 0 })
const { slideWidth } = useCarouselContext()
const childrenArray = React.Children.toArray(props.children)
// Worklets must not capture `props`: it holds React elements, which can't be copied to the UI thread.
const { onScroll, onScrollBeginDrag } = props
const slideCount = childrenArray.length

const clampValue = useCallback(
(value: number) => {
'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)
Expand Down Expand Up @@ -72,9 +75,9 @@ export const AnimatedPagedView = forwardRef<AnimatedPagedScrollViewRef, Animated
useAnimatedReaction(
() => translateX.value,
(value) => {
props.onScroll?.(value)
onScroll?.(value)
},
[props.onScroll],
[onScroll],
)

useImperativeHandle(
Expand Down
26 changes: 24 additions & 2 deletions src/components/HeroCarousel/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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[]
Expand All @@ -24,6 +26,7 @@ const HeroCarousel = ({ children }: HeroCarouselProps) => {
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
} = useCarouselContext()
const { paddedChildrenArray } = useInfiniteScroll({
children,
Expand All @@ -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({
Expand Down Expand Up @@ -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}
</HeroCarouselSlide>
))}
</HeroCarouselAdapter>
Expand Down
8 changes: 4 additions & 4 deletions src/components/HeroCarouselSlide/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,7 +18,7 @@ export const HeroCarouselSlide = ({
total: number
runAutoScroll: ReturnType<typeof useAutoScroll>['runAutoScroll']
goToPage: ReturnType<typeof useManualScroll>['goToPage']
}) => {
}) {
return (
<View style={{ flex: 1, width, minWidth: width }}>
<ItemContext.Provider
Expand All @@ -31,4 +31,4 @@ export const HeroCarouselSlide = ({
</ItemContext.Provider>
</View>
)
}
})
5 changes: 5 additions & 0 deletions src/context/CarouselContext/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -48,6 +50,7 @@ export const CarouselContextProvider = ({
interval = DEFAULT_INTERVAL,
disableAutoScroll = false,
autoScrollAnimation = DEFAULT_ANIMATION,
renderWindow,
...rest
}: ContextProps) => {
const userInteracted = useUserInteracted()
Expand All @@ -69,6 +72,7 @@ export const CarouselContextProvider = ({
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
}),
[
manualScroll,
Expand All @@ -78,6 +82,7 @@ export const CarouselContextProvider = ({
interval,
disableAutoScroll,
autoScrollAnimation,
renderWindow,
],
)}
>
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
56 changes: 56 additions & 0 deletions src/utils/__tests__/isInRenderWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
29 changes: 29 additions & 0 deletions src/utils/isInRenderWindow.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading