From 34ccc8f2323c6852728694672a94a288be938169 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Mon, 28 Jul 2025 13:22:40 +0200 Subject: [PATCH 1/4] feat: add option to disable infinite scrolling --- example/examples/BasicExample.tsx | 2 +- src/components/AnimatedPagedView/index.tsx | 21 ++++++++++++++++----- src/components/HeroCarousel/index.tsx | 13 +++++++++++-- src/context/CarouselContext/index.tsx | 11 +++++++---- src/hooks/useAutoScroll.ts | 5 +++++ src/hooks/useInfiniteScroll.ts | 7 +++++-- src/hooks/useManualScroll.ts | 8 ++++---- 7 files changed, 49 insertions(+), 18 deletions(-) diff --git a/example/examples/BasicExample.tsx b/example/examples/BasicExample.tsx index e93dce5..912816c 100644 --- a/example/examples/BasicExample.tsx +++ b/example/examples/BasicExample.tsx @@ -30,7 +30,7 @@ export default function BasicExample() { }, []) return ( - + diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx index ad75f15..5e74628 100644 --- a/src/components/AnimatedPagedView/index.tsx +++ b/src/components/AnimatedPagedView/index.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle } from 'react' +import React, { forwardRef, useCallback, useImperativeHandle } from 'react' import { Dimensions } from 'react-native' import Animated, { useSharedValue, @@ -6,6 +6,7 @@ import Animated, { useAnimatedReaction, runOnJS, withTiming, + clamp, } from 'react-native-reanimated' import { Gesture, GestureDetector } from 'react-native-gesture-handler' @@ -27,6 +28,15 @@ export const AnimatedPagedView = forwardRef { const translateX = useSharedValue(0) const context = useSharedValue({ x: 0 }) + const childrenArray = React.Children.toArray(props.children) + + const clampValue = useCallback( + (value: number) => { + 'worklet' + return clamp(value, 0, (childrenArray.length - 1) * SCREEN_WIDTH) + }, + [childrenArray.length], + ) const gesture = Gesture.Pan() .onStart(() => { @@ -34,7 +44,7 @@ export const AnimatedPagedView = forwardRef { - translateX.value = context.value.x - event.translationX + translateX.value = clampValue(context.value.x - event.translationX) }) .onEnd((event) => { const velocity = event.velocityX @@ -43,15 +53,16 @@ export const AnimatedPagedView = forwardRef 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) > SCREEN_WIDTH / 2) { - translateX.value = withTiming(currentPage * SCREEN_WIDTH) + translateX.value = withTiming(clampValue(currentPage * SCREEN_WIDTH)) } else { - translateX.value = withTiming(targetPage * SCREEN_WIDTH) + translateX.value = withTiming(clampValue(targetPage * SCREEN_WIDTH)) } }) const animatedStyle = useAnimatedStyle(() => { + const clampedTranslateX = clampValue(translateX.value) return { - transform: [{ translateX: -translateX.value }], + transform: [{ translateX: -clampedTranslateX }], } }, []) diff --git a/src/components/HeroCarousel/index.tsx b/src/components/HeroCarousel/index.tsx index f8ad395..bdb8ecc 100644 --- a/src/components/HeroCarousel/index.tsx +++ b/src/components/HeroCarousel/index.tsx @@ -21,13 +21,21 @@ export const HeroCarousel = ({ disableAutoScroll = false, autoScrollAnimation = DEFAULT_ANIMATION, }: HeroCarouselProps) => { - const { scrollValue, userInteracted, slideWidth, timeoutValue, goToPage, manualScrollValue } = - useCarouselContext() + const { + scrollValue, + userInteracted, + slideWidth, + timeoutValue, + goToPage, + manualScrollValue, + disableInfiniteScroll, + } = useCarouselContext() const { paddedChildrenArray } = useInfiniteScroll({ children, slideWidth, goToPage, scrollValue, + disabled: disableInfiniteScroll, }) const autoScrollEnabled = !userInteracted @@ -42,6 +50,7 @@ export const HeroCarousel = ({ goToPage(page, duration, autoScrollAnimation) }, timeoutValue, + totalLength: paddedChildrenArray.length, }) return ( diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 9d600a1..e3914ad 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -32,19 +32,21 @@ const useUserInteracted = () => { type ContextProps = { children: React.ReactNode - defaultScrollValue?: number + initialIndex?: number slideWidth?: number + disableInfiniteScroll?: boolean } export const CarouselContextProvider = ({ children, - defaultScrollValue = 1, + initialIndex = 0, slideWidth = windowWidth, + disableInfiniteScroll = false, }: ContextProps) => { const userInteracted = useUserInteracted() const manualScroll = useManualScroll({ slideWidth, - defaultScrollValue, + initialIndex: disableInfiniteScroll ? initialIndex : initialIndex + 1, }) return ( @@ -54,8 +56,9 @@ export const CarouselContextProvider = ({ ...manualScroll, ...userInteracted, slideWidth, + disableInfiniteScroll, }), - [manualScroll, userInteracted, slideWidth], + [manualScroll, userInteracted, slideWidth, disableInfiniteScroll], )} > {children} diff --git a/src/hooks/useAutoScroll.ts b/src/hooks/useAutoScroll.ts index 818d281..a39a72c 100644 --- a/src/hooks/useAutoScroll.ts +++ b/src/hooks/useAutoScroll.ts @@ -15,6 +15,7 @@ export const useAutoScroll = ({ slideWidth, disableAutoScroll, interval, + totalLength, autoScrollEnabled, goToPage, timeoutValue, @@ -24,6 +25,7 @@ export const useAutoScroll = ({ autoScrollEnabled: boolean disableAutoScroll: boolean interval: number | ((index: number) => number) + totalLength: number goToPage: (page: number, duration?: number) => void timeoutValue: SharedValue }) => { @@ -45,6 +47,9 @@ export const useAutoScroll = ({ ) => { const offset = scrollValue.value const nextIndex = offset + 1 + if (nextIndex >= totalLength) { + return + } const autoScroll = () => { onComplete(nextIndex) } diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts index 19363ea..869c3f5 100644 --- a/src/hooks/useInfiniteScroll.ts +++ b/src/hooks/useInfiniteScroll.ts @@ -10,11 +10,13 @@ export const useInfiniteScroll = ({ slideWidth, goToPage, scrollValue, + disabled, }: { children: React.ReactNode[] slideWidth: number | undefined scrollValue: SharedValue goToPage: ReturnType['goToPage'] + disabled?: boolean }) => { const childrenArray = useMemo(() => React.Children.toArray(children), [children]) @@ -28,6 +30,7 @@ export const useInfiniteScroll = ({ useAnimatedReaction( () => scrollValue.value, (offset) => { + if (disabled) return 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 @@ -45,8 +48,8 @@ export const useInfiniteScroll = ({ return useMemo( () => ({ - paddedChildrenArray, + paddedChildrenArray: disabled ? childrenArray : paddedChildrenArray, }), - [paddedChildrenArray], + [paddedChildrenArray, disabled, childrenArray], ) } diff --git a/src/hooks/useManualScroll.ts b/src/hooks/useManualScroll.ts index c11c97d..cef39cd 100644 --- a/src/hooks/useManualScroll.ts +++ b/src/hooks/useManualScroll.ts @@ -5,13 +5,13 @@ export const DEFAULT_ANIMATION = (to: number, duration: number) => withTiming(to export const useManualScroll = ({ slideWidth, - defaultScrollValue, + initialIndex, }: { slideWidth: number - defaultScrollValue: number + initialIndex: number }) => { - const manualScrollValue = useSharedValue({ value: slideWidth }) - const scrollValue = useSharedValue(defaultScrollValue) + const manualScrollValue = useSharedValue({ value: slideWidth * initialIndex }) + const scrollValue = useSharedValue(initialIndex) const goToPage = useCallback( (page: number, duration = 0, animation = DEFAULT_ANIMATION) => { From d6a5657299214d9848174fbacce785a1f3d3df34 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Mon, 28 Jul 2025 16:11:58 +0200 Subject: [PATCH 2/4] fix: some additional fixes --- example/examples/BasicExample.tsx | 3 +-- example/examples/EnteringAnimationExample.tsx | 1 - example/examples/OffsetExample.tsx | 1 - example/examples/TimerPaginationExample.tsx | 1 - .../__tests__/interpolateInsideCarousel.test.ts | 17 +++++++++++++++++ src/utils/interpolateInsideCarousel.ts | 1 + 6 files changed, 19 insertions(+), 5 deletions(-) diff --git a/example/examples/BasicExample.tsx b/example/examples/BasicExample.tsx index 912816c..ca86584 100644 --- a/example/examples/BasicExample.tsx +++ b/example/examples/BasicExample.tsx @@ -30,7 +30,7 @@ export default function BasicExample() { }, []) return ( - + @@ -59,7 +59,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', transformOrigin: 'center', - transform: [{ scale: 1.6 }], }, gradient: { position: 'absolute', diff --git a/example/examples/EnteringAnimationExample.tsx b/example/examples/EnteringAnimationExample.tsx index 3344445..eb5a905 100644 --- a/example/examples/EnteringAnimationExample.tsx +++ b/example/examples/EnteringAnimationExample.tsx @@ -86,7 +86,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', transformOrigin: 'center', - transform: [{ scale: 1.6 }], }, gradient: { position: 'absolute', diff --git a/example/examples/OffsetExample.tsx b/example/examples/OffsetExample.tsx index 2e58bd8..a3729ae 100644 --- a/example/examples/OffsetExample.tsx +++ b/example/examples/OffsetExample.tsx @@ -148,7 +148,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', transformOrigin: 'center', - transform: [{ scale: 1.6 }], }, gradient: { position: 'absolute', diff --git a/example/examples/TimerPaginationExample.tsx b/example/examples/TimerPaginationExample.tsx index 1a0b25c..e0c68fb 100644 --- a/example/examples/TimerPaginationExample.tsx +++ b/example/examples/TimerPaginationExample.tsx @@ -93,7 +93,6 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', transformOrigin: 'center', - transform: [{ scale: 1.6 }], }, gradient: { position: 'absolute', diff --git a/src/utils/__tests__/interpolateInsideCarousel.test.ts b/src/utils/__tests__/interpolateInsideCarousel.test.ts index 53667e8..101c3c2 100644 --- a/src/utils/__tests__/interpolateInsideCarousel.test.ts +++ b/src/utils/__tests__/interpolateInsideCarousel.test.ts @@ -264,6 +264,23 @@ describe('interpolateInsideCarousel', () => { expect(result).toBe(1) }) + it('should correclty mirror between first and last slide', () => { + const result = interpolateInsideCarousel(6, 6, 7, { + valueBefore: 0, + thisValue: 1, + valueAfter: 0, + offset: 0, + }) + expect(result).toBe(1) + const result2 = interpolateInsideCarousel(6, 1, 7, { + valueBefore: 0, + thisValue: 1, + valueAfter: 0, + offset: 0, + }) + expect(result2).toBe(1) + }) + it('should correctly mirror between last and first slide with offset', () => { const result = interpolateInsideCarousel(6, 1, 7, { valueBefore: 1, diff --git a/src/utils/interpolateInsideCarousel.ts b/src/utils/interpolateInsideCarousel.ts index 6c370e2..51333d7 100644 --- a/src/utils/interpolateInsideCarousel.ts +++ b/src/utils/interpolateInsideCarousel.ts @@ -29,6 +29,7 @@ export const interpolateInsideCarousel = ( const getAdjustedIndex = (slideIndex: number) => { if (slideIndex === 0) return Math.max(totalLength - 2, 0) if (slideIndex === totalLength - 1) return 1 + if (slideIndex === 1) return totalLength - 1 return slideIndex } From 11c9e32f41520338355cdc7b0c8494a9c81933c2 Mon Sep 17 00:00:00 2001 From: Petr Konecny Date: Mon, 28 Jul 2025 19:14:52 +0200 Subject: [PATCH 3/4] chore: updated architecture --- README.md | 112 +++++++++++++++----- example/README.md | 6 +- example/examples/TimerPaginationExample.tsx | 4 +- example/examples/VideoCarouselExample.tsx | 4 +- src/components/HeroCarousel/index.tsx | 20 ++-- src/context/CarouselContext/index.tsx | 22 +++- 6 files changed, 120 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index b6a5b9e..509a930 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A highly customizable, performant carousel component for React Native with advanced animations, auto-scrolling capabilities, and infinite scrolling support. Built with React Native Reanimated for smooth, native-level performance. +**✨ Context-Based Configuration** - All carousel settings are configured through the context provider for a clean, centralized API. + ## Features ✨ **Auto-scrolling** with customizable intervals @@ -55,7 +57,7 @@ const Slide = ({ title, color }: { title: string; color: string }) => ( export default function BasicCarousel() { return ( - + {slides.map((slide) => ( @@ -90,38 +92,50 @@ const styles = StyleSheet.create({ #### `CarouselContextProvider` -The context provider that must wrap your carousel components. +The context provider that must wrap your carousel components. **All carousel configuration is passed here.** ```tsx withTiming(to, { duration })} // Custom animation > {children} ``` +**Props:** + +| Prop | Type | Default | Description | +| ----------------------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------- | +| `initialIndex` | `number` | `0` | Initial slide index to start from | +| `slideWidth` | `number` | screen width | Width of each slide in pixels | +| `interval` | `number \| ((index: number) => number)` | `3000` | Auto-scroll interval in milliseconds, or function returning interval for each slide | +| `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 | +| `children` | `React.ReactNode` | Required | Carousel content (should contain HeroCarousel component) | + #### `HeroCarousel` -The main carousel component with auto-scrolling functionality. +The main carousel component that renders slides. **Takes no configuration props** - all configuration is handled by the context. ```tsx - withTiming(to, { duration })} // Custom page transition animation -> - {children} + + {slides.map((slide) => ( + + ))} ``` **Props:** -| Prop | Type | Default | Description | -| ------------------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------- | --- | -| `interval` | `number \| ((index: number) => number)` | `3000` | Auto-scroll interval in milliseconds, or function returning interval for each slide | -| `disableAutoScroll` | `boolean` | `false` | Disable automatic scrolling | | -| `children` | `React.ReactNode[]` | Required | Array of slide components | +| Prop | Type | Description | +| ---------- | ------------------- | ------------------------- | +| `children` | `React.ReactNode[]` | Array of slide components | ### Hooks @@ -147,7 +161,7 @@ const { scrollValue, timeoutValue, slideWidth, userInteracted, setUserInteracted Get the current slide information and auto-scroll controls. ```tsx -const { index, total, runAutoScroll, goToPage } = useAutoCarouselSlideIndex() +const { index, total, runAutoScroll, goToPage } = useHeroCarouselSlideIndex() ``` **Returns:** @@ -232,32 +246,68 @@ All pagination components automatically sync with the carousel state and support ## Advanced Usage +### Configuration Examples + +Different carousel configurations using the context provider: + +```tsx +// Basic auto-scrolling carousel + + {slides} + + +// Video carousel without auto-scroll + + {videoSlides} + + +// Carousel with custom intervals per slide + (index + 1) * 2000}> + {slides} + + +// Carousel starting from specific slide + + {slides} + + +// Custom slide width and animation + withSpring(to, { damping: 15 })} +> + {slides} + +``` + ### Programmatic Navigation Control the carousel programmatically using the context: ```tsx const CarouselWithControls = () => { - const { scrollValue } = useCarouselContext() - const { runAutoScroll } = useAutoCarouselSlideIndex() + const { scrollValue, goToPage } = useCarouselContext() + const { runAutoScroll } = useHeroCarouselSlideIndex() const goToNext = () => { runAutoScroll(0) // Immediate transition } const goToSlide = (slideIndex: number) => { - scrollValue.value = withTiming(slideIndex, { duration: 500 }) + goToPage(slideIndex, 500) // Go to slide with 500ms animation } return ( - - {/* Your slides */} - - -