diff --git a/example/app.json b/example/app.json index 021dca1..92501e0 100644 --- a/example/app.json +++ b/example/app.json @@ -36,7 +36,8 @@ "resizeMode": "contain", "backgroundColor": "#ffffff" } - ] + ], + "expo-video" ], "experiments": { "typedRoutes": true diff --git a/example/app/(examples)/index.tsx b/example/app/(examples)/index.tsx index 996184b..128e158 100644 --- a/example/app/(examples)/index.tsx +++ b/example/app/(examples)/index.tsx @@ -24,6 +24,16 @@ const examples = [ description: 'Carousel with entering/exiting animations triggered by shared values', route: '/entering-animation' as const, }, + { + title: 'Timer Pagination Carousel', + description: 'Timer-based pagination with auto-slide progress indicator', + route: '/timer-pagination' as const, + }, + { + title: 'Video Carousel', + description: 'A carousel showcasing videos using expo-video', + route: '/video-carousel' as const, + }, ] export default function HomeScreen() { diff --git a/example/app/(examples)/timer-pagination.tsx b/example/app/(examples)/timer-pagination.tsx new file mode 100644 index 0000000..dd457c0 --- /dev/null +++ b/example/app/(examples)/timer-pagination.tsx @@ -0,0 +1,3 @@ +import TimerPaginationExample from '@/examples/TimerPaginationExample' + +export default TimerPaginationExample diff --git a/example/app/(examples)/video-carousel.tsx b/example/app/(examples)/video-carousel.tsx new file mode 100644 index 0000000..f16e314 --- /dev/null +++ b/example/app/(examples)/video-carousel.tsx @@ -0,0 +1,3 @@ +import VideoCarouselExample from '@/examples/VideoCarouselExample' + +export default VideoCarouselExample diff --git a/example/examples/EnteringAnimationExample.tsx b/example/examples/EnteringAnimationExample.tsx index 22729fd..0736309 100644 --- a/example/examples/EnteringAnimationExample.tsx +++ b/example/examples/EnteringAnimationExample.tsx @@ -33,9 +33,9 @@ const Slide = ({ image, title, index }: { image: string; title: string; index: n return ( - + - + {title} Animation: {animationNames[index % animationNames.length]} @@ -68,6 +68,9 @@ export default function EnteringAnimationExample() { } const styles = StyleSheet.create({ + textContainer: { + flex: 1, + }, container: { flex: 1, backgroundColor: '#fff', diff --git a/example/examples/TimerPaginationExample.tsx b/example/examples/TimerPaginationExample.tsx new file mode 100644 index 0000000..cb917c1 --- /dev/null +++ b/example/examples/TimerPaginationExample.tsx @@ -0,0 +1,174 @@ +import { + AutoCarousel, + CarouselContextProvider, + useAutoCarouselSlideIndex, +} from '@strv/react-native-hero-carousel' +import { SafeAreaView, StyleSheet, View, Text, Dimensions } from 'react-native' +import { Image } from 'expo-image' +import { LinearGradient } from 'expo-linear-gradient' +import { useEffect } from 'react' +import { BlurView } from 'expo-blur' +import { TimerPagination } from './components/TimerPagination' + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window') + +const getRandomImageUrl = () => { + return `https://picsum.photos/${SCREEN_WIDTH}/${SCREEN_HEIGHT}?random=${Math.floor(Math.random() * 1000)}` +} + +const images = Array.from({ length: 5 }, getRandomImageUrl) + +const Slide = ({ + image, + title, + getInterval, +}: { + image: string + title: string + index: number + getInterval: (index: number) => number +}) => { + const { index: currentIndex } = useAutoCarouselSlideIndex() + const interval = getInterval(currentIndex) + + return ( + + + + + + {title} + Slide change interval: {interval / 1000} s + + + + ) +} + +export default function TimerPaginationExample() { + // Preload all images when component mounts + useEffect(() => { + Image.prefetch(images) + }, []) + + const getInterval = (index: number) => { + 'worklet' + return index * 3000 + } + + return ( + + + + + {images.map((image, index) => ( + + ))} + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + slide: { + flex: 1, + width: '100%', + height: '100%', + overflow: 'hidden', + }, + image: { + width: '100%', + height: '100%', + transformOrigin: 'center', + transform: [{ scale: 1.6 }], + }, + gradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '50%', + justifyContent: 'flex-end', + padding: 20, + }, + topGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '20%', + }, + title: { + fontSize: 32, + lineHeight: 32, + fontWeight: 'bold', + color: 'white', + }, + subtitle: { + fontSize: 16, + lineHeight: 16, + fontWeight: '500', + color: 'white', + opacity: 0.8, + }, + paginationContainer: { + position: 'absolute', + top: 20, + left: 20, + right: 20, + overflow: 'hidden', + flexDirection: 'row', + gap: 8, + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + zIndex: 10, + }, + paginationDot: { + flex: 1, + height: 3, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + borderRadius: 2, + overflow: 'hidden', + }, + dotBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + dotProgress: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + backgroundColor: 'white', + borderRadius: 2, + }, + blurView: { + position: 'absolute', + bottom: 20, + padding: 20, + margin: 8, + borderRadius: 16, + gap: 8, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + overflow: 'hidden', + }, +}) diff --git a/example/examples/VideoCarouselExample.tsx b/example/examples/VideoCarouselExample.tsx new file mode 100644 index 0000000..9f43e98 --- /dev/null +++ b/example/examples/VideoCarouselExample.tsx @@ -0,0 +1,158 @@ +import { + AutoCarousel, + CarouselContextProvider, + useAutoCarouselSlideIndex, +} from '@strv/react-native-hero-carousel' +import { SafeAreaView, StyleSheet, View, Text, Pressable, Dimensions, Platform } from 'react-native' +import { useVideoPlayer, VideoView } from 'expo-video' +import { LinearGradient } from 'expo-linear-gradient' +import { useActiveSlideEffect, useIsActiveSlide } from '@/hooks/useActiveSlideEffect' +import { useEffect, useRef, useState } from 'react' +import { TimerPagination } from './components/TimerPagination' +import { useEvent, useEventListener } from 'expo' + +const { width, height } = Dimensions.get('window') +// Sample video URLs - these are publicly available videos that work well for testing +const videos = [ + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', +] + +const videoTitles = [ + 'For Bigger Blazes', + 'For Bigger Escapes', + 'For Bigger Fun', + 'Big Buck Bunny', + 'Elephants Dream', +] + +const Slide = ({ videoUri, title, index }: { videoUri: string; title: string; index: number }) => { + const player = useVideoPlayer(videoUri) + const { runAutoScroll } = useAutoCarouselSlideIndex() + const isActiveSlide = useIsActiveSlide() + const [duration, setDuration] = useState(0) + useActiveSlideEffect(() => { + player.currentTime = 0 + player.play() + return () => { + player.pause() + } + }) + + const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing }) + + useEventListener(player, 'statusChange', ({ status }) => { + if (status === 'readyToPlay') { + setDuration(player.duration) + } + }) + + const intervalRef = useRef | null>(null) + + useEffect(() => { + if (isActiveSlide && duration) { + intervalRef.current = runAutoScroll(duration * 1000) + } + }, [isActiveSlide, duration, runAutoScroll]) + + return ( + + { + if (isPlaying) { + player.pause() + intervalRef.current?.pause() + } else { + player.play() + intervalRef.current?.resume() + } + }} + > + + + + {title} + Swipe to navigate • Tap to play/pause + + + + ) +} + +export default function VideoCarouselExample() { + return ( + + + + + {videos.map((video, index) => ( + + ))} + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000', + }, + slide: { + flex: 1, + width: '100%', + height: '100%', + overflow: 'hidden', + }, + video: { + width: width, + height: height, + }, + gradient: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: '50%', + justifyContent: 'flex-end', + padding: 20, + }, + topGradient: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + height: '20%', + }, + title: { + fontSize: 32, + bottom: 60, + left: 20, + position: 'absolute', + lineHeight: 32, + fontWeight: 'bold', + color: 'white', + }, + subtitle: { + fontSize: 16, + bottom: 20, + left: 20, + position: 'absolute', + lineHeight: 20, + color: 'white', + opacity: 0.8, + }, +}) diff --git a/example/examples/components/TimerPagination.tsx b/example/examples/components/TimerPagination.tsx new file mode 100644 index 0000000..674b281 --- /dev/null +++ b/example/examples/components/TimerPagination.tsx @@ -0,0 +1,105 @@ +import { useCarouselContext } from '@strv/react-native-hero-carousel' +import { StyleSheet, View } from 'react-native' +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { BlurView } from 'expo-blur' + +// Individual pagination dot component +const PaginationDot = ({ + index, + hideProgressOnInteraction, +}: { + index: number + total: number + hideProgressOnInteraction: boolean +}) => { + const { scrollValue, timeoutValue, userInteracted } = useCarouselContext() + + const progressStyle = useAnimatedStyle(() => { + const isActive = scrollValue.value - 1 >= index && scrollValue.value - 1 < index + 1 + const wasActive = scrollValue.value - 1 > index + + if ((!isActive && !wasActive) || (hideProgressOnInteraction && userInteracted)) { + return { + width: 0, + } + } + return { + width: `${wasActive ? 100 : timeoutValue.value * 100}%`, + } + }) + + const dotStyle = useAnimatedStyle(() => { + const isActive = Math.round(scrollValue.value - 1) === index + const opacity = isActive ? withTiming(1) : withTiming(0.5) + return { + opacity, + } + }) + + return ( + + + + + ) +} + +export const TimerPagination = ({ + total, + hideProgressOnInteraction, +}: { + total: number + hideProgressOnInteraction: boolean +}) => { + return ( + + {Array.from({ length: total }).map((_, index) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + paginationContainer: { + position: 'absolute', + top: 20, + left: 20, + right: 20, + overflow: 'hidden', + flexDirection: 'row', + gap: 8, + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.05)', + zIndex: 10, + }, + paginationDot: { + flex: 1, + height: 3, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + borderRadius: 2, + overflow: 'hidden', + }, + dotBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(255, 255, 255, 0.5)', + }, + dotProgress: { + position: 'absolute', + top: 0, + left: 0, + bottom: 0, + backgroundColor: 'white', + borderRadius: 2, + }, +}) diff --git a/example/hooks/useActiveSlideEffect.ts b/example/hooks/useActiveSlideEffect.ts new file mode 100644 index 0000000..f9faf47 --- /dev/null +++ b/example/hooks/useActiveSlideEffect.ts @@ -0,0 +1,55 @@ +import { + interpolateInsideCarousel, + useAutoCarouselSlideIndex, + useCarouselContext, +} from '@strv/react-native-hero-carousel' +import { useState } from 'react' +import { runOnJS, useAnimatedReaction, useDerivedValue } from 'react-native-reanimated' + +export const useIsActiveSlide = () => { + const { index, total } = useAutoCarouselSlideIndex() + const { scrollValue } = useCarouselContext() + const [isActive, setIsActive] = useState(false) + useAnimatedReaction( + () => scrollValue.value, + (value) => { + const result = interpolateInsideCarousel(value, index, total, { + valueBefore: 0, + thisValue: 1, + valueAfter: 0, + }) + if (result === 1) { + runOnJS(setIsActive)(true) + } else { + runOnJS(setIsActive)(false) + } + }, + [index, total], + ) + return isActive +} + +export const useActiveSlideEffect = ( + effectFunc: () => () => void | undefined, + deps: any[] = [], +) => { + const { index, total } = useAutoCarouselSlideIndex() + const { scrollValue } = useCarouselContext() + const value = useDerivedValue(() => { + return interpolateInsideCarousel(scrollValue.value, index, total, { + valueBefore: 0, + thisValue: 1, + valueAfter: 0, + }) + }) + + useAnimatedReaction( + () => value.value, + (value) => { + if (value === 1) { + runOnJS(effectFunc)() + } + }, + deps, + ) +} diff --git a/example/package.json b/example/package.json index 2548c09..a103af2 100644 --- a/example/package.json +++ b/example/package.json @@ -15,11 +15,11 @@ "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", "expo": "~53.0.9", - "expo-blur": "~14.1.4", + "expo-blur": "~14.1.5", "expo-constants": "~17.1.6", "expo-font": "~13.3.1", "expo-haptics": "~14.1.4", - "expo-image": "~2.1.7", + "expo-image": "~2.3.0", "expo-linear-gradient": "^14.1.4", "expo-linking": "~7.1.5", "expo-router": "~5.0.6", @@ -27,6 +27,7 @@ "expo-status-bar": "~2.2.3", "expo-symbols": "~0.4.4", "expo-system-ui": "~5.0.7", + "expo-video": "~2.2.2", "expo-web-browser": "~14.1.6", "react": "19.0.0", "react-dom": "19.0.0", diff --git a/example/pnpm-lock.yaml b/example/pnpm-lock.yaml index a32ba2e..5eef554 100644 --- a/example/pnpm-lock.yaml +++ b/example/pnpm-lock.yaml @@ -23,7 +23,7 @@ importers: specifier: ~53.0.9 version: 53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) expo-blur: - specifier: ~14.1.4 + specifier: ~14.1.5 version: 14.1.5(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) expo-constants: specifier: ~17.1.6 @@ -35,8 +35,8 @@ importers: specifier: ~14.1.4 version: 14.1.4(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0)) expo-image: - specifier: ~2.1.7 - version: 2.1.7(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + specifier: ~2.3.0 + version: 2.3.0(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) expo-linear-gradient: specifier: ^14.1.4 version: 14.1.5(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) @@ -58,6 +58,9 @@ importers: expo-system-ui: specifier: ~5.0.7 version: 5.0.8(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)) + expo-video: + specifier: ~2.2.2 + version: 2.2.2(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) expo-web-browser: specifier: ~14.1.6 version: 14.1.6(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)) @@ -3224,10 +3227,10 @@ packages: peerDependencies: expo: '*' - expo-image@2.1.7: + expo-image@2.3.0: resolution: { - integrity: sha512-p2Gr8fP/YakFHHo4rbpJbRWwKNrZp1GzSD91WEG3ZYAbTVdTjheJ6gUxXgggFaxEbaY+4WeQ0c5j9tZq8+3cEg==, + integrity: sha512-muL8OSbgCskQJsyqenKPNULWXwRm5BY2ruS6WMo/EzFyI3iXI/0mXgb2J/NXUa8xCEYxSyoGkGZFyCBvGY1ofA==, } peerDependencies: expo: '*' @@ -3340,6 +3343,16 @@ packages: react-native-web: optional: true + expo-video@2.2.2: + resolution: + { + integrity: sha512-SJrW1CeiWO7WCaAVMbjqGlvOMJfU/x+d0g9izjsnEXdV/KE3NhuCI3Y/3zCcFiAoR+jrHEtlI8sPlkLx3dq8xw==, + } + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-web-browser@14.1.6: resolution: { @@ -9131,7 +9144,7 @@ snapshots: dependencies: expo: 53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) - expo-image@2.1.7(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + expo-image@2.3.0(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): dependencies: expo: 53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) react: 19.0.0 @@ -9235,6 +9248,12 @@ snapshots: transitivePeerDependencies: - supports-color + expo-video@2.2.2(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0): + dependencies: + expo: 53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-native: 0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0) + expo-web-browser@14.1.6(expo@53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)): dependencies: expo: 53.0.11(@babel/core@7.27.4)(@expo/metro-runtime@5.0.4(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0)))(react-native-webview@13.13.5(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0))(react-native@0.79.2(@babel/core@7.27.4)(@types/react@19.0.14)(react@19.0.0))(react@19.0.0) diff --git a/src/components/AutoCarousel/index.tsx b/src/components/AutoCarousel/index.tsx index 36ea7f6..c9ccb79 100644 --- a/src/components/AutoCarousel/index.tsx +++ b/src/components/AutoCarousel/index.tsx @@ -1,95 +1,46 @@ -import React, { useRef, useEffect, useCallback } from 'react' -import { runOnJS, useSharedValue, withTiming, useAnimatedReaction } from 'react-native-reanimated' +import React from 'react' +import { withTiming } from 'react-native-reanimated' -import { DEFAULT_INTERVAL, ROUNDING_PRECISION, TRANSITION_DURATION } from './index.preset' +import { DEFAULT_INTERVAL } from './index.preset' import { useCarouselContext } from '../../context/CarouselContext' import { AutoCarouselSlide } from '../AutoCarouselSlide' -import { customRound } from '../../utils/round' import { AutoCarouselAdapter } from '../AnimatedPagedView/Adapter' +import { useAutoScroll } from '../../hooks/useAutoScroll' +import { useCore } from '../../hooks/useCore' export type AutoCarouselProps = { - interval?: number + interval?: number | ((index: number) => number) children: React.ReactNode[] + goToPageAnimation?: (to: number, duration: number) => number + disableAutoScroll?: boolean } -export const AutoCarousel = ({ interval = DEFAULT_INTERVAL, children }: AutoCarouselProps) => { - const { scrollValue, userInteracted, slideWidth } = useCarouselContext() - const offset = useSharedValue({ value: slideWidth }) +export const AutoCarousel = ({ + interval = DEFAULT_INTERVAL, + children, + goToPageAnimation = (to, duration) => withTiming(to, { duration }), + disableAutoScroll = false, +}: AutoCarouselProps) => { + const { scrollValue, userInteracted, slideWidth, timeoutValue } = useCarouselContext() - const childrenArray = React.Children.toArray(children) + const { goToPage, paddedChildrenArray, offset } = useCore({ + children, + slideWidth, + goToPageAnimation, + scrollValue, + }) const autoScrollEnabled = !userInteracted - // need to clone first and last element to have infinite scrolling both ways - // if it gets to the end we switch back to the start without animation and vice versa - const paddedChildrenArray = [ - childrenArray[childrenArray.length - 1], - ...childrenArray, - childrenArray[0], - ] - - const goToPage = useCallback( - (page: number, duration = 0) => { - 'worklet' - const to = page * slideWidth - if (duration) { - offset.value = withTiming<{ value: number }>({ value: to }, { duration }) - } else { - offset.value = { value: to } - } - }, - [offset, slideWidth], - ) - - const timeoutRef = useRef | null>(null) - - const handleAutoScroll = () => { - const autoScroll = () => { - const offset = scrollValue.value - const nextIndex = offset + 1 - goToPage(nextIndex, TRANSITION_DURATION) - } - if (timeoutRef.current) clearTimeout(timeoutRef.current) - timeoutRef.current = setTimeout(autoScroll, interval) - } - - useEffect(() => { - if (!autoScrollEnabled && timeoutRef.current) clearTimeout(timeoutRef.current) - return () => { - if (!timeoutRef.current) return - clearTimeout(timeoutRef.current) - } - }, [autoScrollEnabled]) - - useAnimatedReaction( - () => scrollValue.value, - (offset) => { - if (slideWidth === 0) return - if (offset % 1 !== 0) return - if (!autoScrollEnabled) return - runOnJS(handleAutoScroll)() - }, - [scrollValue, slideWidth, autoScrollEnabled], - ) - - // This handles the infinite scrolling - useAnimatedReaction( - () => scrollValue.value, - (offset) => { - 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) { - 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 && activeIndex > -0.01) { - goToPage(paddedChildrenArray.length - 2, 0) - } - }, - [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue], - ) + const { runAutoScroll } = useAutoScroll({ + scrollValue, + slideWidth, + autoScrollEnabled, + disableAutoScroll, + interval, + goToPage, + timeoutValue, + }) return ( <> @@ -106,6 +57,8 @@ export const AutoCarousel = ({ interval = DEFAULT_INTERVAL, children }: AutoCaro key={index} index={index} total={paddedChildrenArray.length} + runAutoScroll={runAutoScroll} + goToPage={goToPage} > {child} diff --git a/src/components/AutoCarouselSlide/index.tsx b/src/components/AutoCarouselSlide/index.tsx index cf80ada..dc53600 100644 --- a/src/components/AutoCarouselSlide/index.tsx +++ b/src/components/AutoCarouselSlide/index.tsx @@ -1,20 +1,32 @@ import { View } from 'react-native' import { AutoCarouselSlideContext } from '../../context/SlideContext' +import { useAutoScroll } from '../../hooks/useAutoScroll' +import { useCore } from '../../hooks/useCore' +import { useMemo } from 'react' export const AutoCarouselSlide = ({ children, width, index, total, + runAutoScroll, + goToPage, }: { children: React.ReactNode width: number index: number total: number + runAutoScroll: ReturnType['runAutoScroll'] + goToPage: ReturnType['goToPage'] }) => { return ( - + ({ index, total, runAutoScroll, goToPage }), + [index, total, runAutoScroll, goToPage], + )} + > {children} diff --git a/src/components/SlideAnimatedView/index.tsx b/src/components/SlideAnimatedView/index.tsx index 5e2f49d..0572b99 100644 --- a/src/components/SlideAnimatedView/index.tsx +++ b/src/components/SlideAnimatedView/index.tsx @@ -16,6 +16,7 @@ type SlideAnimatedViewProps = { layout?: AnimatedProps['layout'] enteringThreshold?: number exitingThreshold?: number + style?: AnimatedProps['style'] } export const SlideAnimatedView = ({ @@ -25,6 +26,7 @@ export const SlideAnimatedView = ({ layout, enteringThreshold = 0.99, exitingThreshold = 0.01, + style, }: SlideAnimatedViewProps) => { const { index, total } = useAutoCarouselSlideIndex() const { scrollValue } = useCarouselContext() @@ -36,7 +38,7 @@ export const SlideAnimatedView = ({ thisValue: 1, valueAfter: 0, }) - }) + }, [index, total, scrollValue]) // Track when value becomes 1 to trigger entering animation useAnimatedReaction( @@ -57,6 +59,7 @@ export const SlideAnimatedView = ({ runOnJS(setShouldShow)(false) } }, + [enteringThreshold, exitingThreshold], ) if (!shouldShow) { @@ -64,7 +67,7 @@ export const SlideAnimatedView = ({ } return ( - + {children} ) diff --git a/src/context/CarouselContext/index.tsx b/src/context/CarouselContext/index.tsx index 61c5c43..0f6b3d3 100644 --- a/src/context/CarouselContext/index.tsx +++ b/src/context/CarouselContext/index.tsx @@ -7,6 +7,7 @@ const windowWidth = Dimensions.get('window').width export const CarouselContext = createContext<{ scrollValue: SharedValue + timeoutValue: SharedValue slideWidth: number userInteracted: boolean setUserInteracted: (interacted: boolean) => void @@ -30,13 +31,14 @@ export const CarouselContextProvider = ({ slideWidth?: number }) => { const scrollValue = useSharedValue(defaultScrollValue) + const timeoutValue = useSharedValue(0) const [userInteracted, setUserInteracted] = useState(false) return ( ({ scrollValue, userInteracted, setUserInteracted, slideWidth }), - [scrollValue, userInteracted, setUserInteracted, slideWidth], + () => ({ scrollValue, userInteracted, setUserInteracted, slideWidth, timeoutValue }), + [scrollValue, userInteracted, setUserInteracted, slideWidth, timeoutValue], )} > {children} diff --git a/src/context/SlideContext/index.tsx b/src/context/SlideContext/index.tsx index 13531e0..d6f6e36 100644 --- a/src/context/SlideContext/index.tsx +++ b/src/context/SlideContext/index.tsx @@ -1,6 +1,13 @@ import { createContext, useContext } from 'react' +import { useAutoScroll } from '../../hooks/useAutoScroll' +import { useCore } from '../../hooks/useCore' -export const AutoCarouselSlideContext = createContext<{ index: number; total: number } | null>(null) +export const AutoCarouselSlideContext = createContext<{ + index: number + total: number + runAutoScroll: ReturnType['runAutoScroll'] + goToPage: ReturnType['goToPage'] +} | null>(null) export const useAutoCarouselSlideIndex = () => { const context = useContext(AutoCarouselSlideContext) diff --git a/src/hooks/useAutoScroll.ts b/src/hooks/useAutoScroll.ts new file mode 100644 index 0000000..ad0f8d0 --- /dev/null +++ b/src/hooks/useAutoScroll.ts @@ -0,0 +1,161 @@ +import { useMemo, useRef, useEffect, useCallback } from 'react' +import { + runOnJS, + withTiming, + useAnimatedReaction, + Easing, + SharedValue, +} from 'react-native-reanimated' + +import { TRANSITION_DURATION } from '../components/AutoCarousel/index.preset' + +export class IntervalTimer { + callbackStartTime: number = 0 + remaining: number = 0 + paused: boolean = false + timerId: NodeJS.Timeout | null = null + onPause?: (remaining: number) => void = () => {} + onResume?: (remaining: number) => void = () => {} + _callback: () => void + _delay: number + + constructor( + callback: () => void, + delay: number, + onPause?: (remaining: number) => void, + onResume?: (remaining: number) => void, + ) { + this._callback = callback + this._delay = delay + this.onPause = onPause + this.onResume = onResume + } + + pause() { + if (!this.paused) { + this.clear() + this.remaining = this._delay - (new Date().getTime() - this.callbackStartTime) + this.paused = true + this.onPause?.(this.remaining) + } + } + + resume() { + if (this.paused) { + if (this.remaining) { + this.onResume?.(this.remaining) + this.paused = false + this.callbackStartTime = new Date().getTime() + setTimeout(() => { + this.run() + }, this.remaining) + } + } + } + + clear() { + if (this.timerId) { + clearTimeout(this.timerId) + } + } + + start() { + this.clear() + this.callbackStartTime = new Date().getTime() + this.timerId = setTimeout(() => { + this.run() + }, this._delay) + } + + run() { + this._callback() + } +} + +export const useAutoScroll = ({ + scrollValue, + slideWidth, + disableAutoScroll, + interval, + autoScrollEnabled, + goToPage, + timeoutValue, +}: { + scrollValue: SharedValue + slideWidth: number + autoScrollEnabled: boolean + disableAutoScroll: boolean + interval: number | ((index: number) => number) + goToPage: (page: number, duration?: number) => void + timeoutValue: SharedValue +}) => { + const timeoutRef = useRef(null) + + const clearCarouselTimeout = useCallback(() => { + if (timeoutRef.current) { + timeoutRef.current.clear() + timeoutValue.value = 0 + } + }, [timeoutValue]) + + const runAutoScroll = useCallback( + ( + interval: number, + onComplete = (nextIndex: number) => { + goToPage(nextIndex, TRANSITION_DURATION) + }, + ) => { + const offset = scrollValue.value + const nextIndex = offset + 1 + const autoScroll = () => { + onComplete(nextIndex) + } + clearCarouselTimeout() + timeoutValue.value = withTiming(1, { duration: interval, easing: Easing.linear }) + timeoutRef.current = new IntervalTimer( + autoScroll, + interval, + () => { + timeoutValue.value = timeoutValue.value + }, + (remaining) => { + timeoutValue.value = withTiming(1, { + duration: remaining, + easing: Easing.linear, + }) + }, + ) + timeoutRef.current.start() + return timeoutRef.current + }, + [clearCarouselTimeout, goToPage, scrollValue, timeoutValue], + ) + + useEffect(() => { + if (!autoScrollEnabled) clearCarouselTimeout() + return () => { + clearCarouselTimeout() + } + }, [autoScrollEnabled, clearCarouselTimeout]) + + useAnimatedReaction( + () => scrollValue.value, + (offset) => { + if (slideWidth === 0) return + if (offset % 1 !== 0) return + if (!autoScrollEnabled) return + timeoutValue.value = 0 + if (disableAutoScroll) return + runOnJS(runAutoScroll)(typeof interval === 'function' ? interval(offset) : interval) + }, + [scrollValue, slideWidth, autoScrollEnabled], + ) + + return useMemo( + () => ({ + timeoutValue, + runAutoScroll, + }), + [timeoutValue, runAutoScroll], + ) +} diff --git a/src/hooks/useCore.ts b/src/hooks/useCore.ts new file mode 100644 index 0000000..e8d23f8 --- /dev/null +++ b/src/hooks/useCore.ts @@ -0,0 +1,68 @@ +import React, { useCallback, useMemo } from 'react' +import { useSharedValue, useAnimatedReaction, SharedValue } from 'react-native-reanimated' + +import { customRound } from '../utils/round' +import { ROUNDING_PRECISION } from '../components/AutoCarousel/index.preset' + +export const useCore = ({ + children, + slideWidth, + goToPageAnimation, + scrollValue, +}: { + children: React.ReactNode[] + slideWidth: number + goToPageAnimation: (to: number, duration: number) => number + scrollValue: SharedValue +}) => { + const offset = useSharedValue({ value: slideWidth }) + + const goToPage = useCallback( + (page: number, duration = 0) => { + 'worklet' + const to = page * slideWidth + if (duration) { + offset.value = { value: goToPageAnimation(to, duration) } + } else { + offset.value = { value: to } + } + }, + [offset, slideWidth, goToPageAnimation], + ) + + const childrenArray = useMemo(() => React.Children.toArray(children), [children]) + + // need to clone first and last element to have infinite scrolling both ways + // if it gets to the end we switch back to the start without animation and vice versa + const paddedChildrenArray = useMemo( + () => [childrenArray[childrenArray.length - 1], ...childrenArray, childrenArray[0]], + [childrenArray], + ) + // This handles the infinite scrolling + useAnimatedReaction( + () => scrollValue.value, + (offset) => { + 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) { + 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 && activeIndex > -0.01) { + goToPage(paddedChildrenArray.length - 2, 0) + } + }, + [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue], + ) + + return useMemo( + () => ({ + goToPage, + paddedChildrenArray, + offset, + }), + [goToPage, paddedChildrenArray, offset], + ) +}