diff --git a/example/app.json b/example/app.json index 09747de..021dca1 100644 --- a/example/app.json +++ b/example/app.json @@ -6,11 +6,12 @@ "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "example", - "userInterfaceStyle": "automatic", + "userInterfaceStyle": "dark", "newArchEnabled": true, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.petrkonecny2.example" + "bundleIdentifier": "com.petrkonecny2.example", + "appleTeamId": "EAVQTDVRT7" }, "android": { "adaptiveIcon": { diff --git a/example/app/(examples)/_layout.tsx b/example/app/(examples)/_layout.tsx new file mode 100644 index 0000000..42bc691 --- /dev/null +++ b/example/app/(examples)/_layout.tsx @@ -0,0 +1,32 @@ +import { Stack } from 'expo-router' + +export default function ExamplesLayout() { + return ( + + + + + + + ) +} diff --git a/example/app/(examples)/animated.tsx b/example/app/(examples)/animated.tsx new file mode 100644 index 0000000..736c14d --- /dev/null +++ b/example/app/(examples)/animated.tsx @@ -0,0 +1,3 @@ +import AnimatedExample from '@/examples/AnimatedExample' + +export default AnimatedExample diff --git a/example/app/(examples)/basic.tsx b/example/app/(examples)/basic.tsx new file mode 100644 index 0000000..9e2bb09 --- /dev/null +++ b/example/app/(examples)/basic.tsx @@ -0,0 +1,3 @@ +import BasicExample from '@/examples/BasicExample' + +export default BasicExample diff --git a/example/app/(examples)/index.tsx b/example/app/(examples)/index.tsx new file mode 100644 index 0000000..197a596 --- /dev/null +++ b/example/app/(examples)/index.tsx @@ -0,0 +1,75 @@ +import { ThemedText } from '@/components/ThemedText' +import { ThemedView } from '@/components/ThemedView' +import { StyleSheet, TouchableOpacity, ScrollView } from 'react-native' +import { Link } from 'expo-router' +import { SafeAreaView } from 'react-native-safe-area-context' +const examples = [ + { + title: 'Basic Carousel', + description: 'A simple carousel with basic animations and pagination', + route: '/basic' as const, + }, + { + title: 'Animated Carousel', + description: 'Advanced animations with custom transitions', + route: '/animated' as const, + }, +] + +export default function HomeScreen() { + return ( + + + + Carousel Examples + + + Select an example to view + + + {examples.map((example) => ( + + + {example.title} + {example.description} + + + ))} + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'black', + }, + scrollView: { + flex: 1, + padding: 16, + }, + title: { + textAlign: 'center', + }, + subtitle: { + textAlign: 'center', + opacity: 0.7, + }, + card: { + padding: 16, + borderRadius: 12, + marginBottom: 16, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }, + cardTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 8, + }, + cardDescription: { + fontSize: 14, + opacity: 0.7, + }, +}) diff --git a/example/app/(tabs)/_layout.tsx b/example/app/(tabs)/_layout.tsx deleted file mode 100644 index 5f83c98..0000000 --- a/example/app/(tabs)/_layout.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Tabs } from 'expo-router' -import React from 'react' -import { Platform } from 'react-native' - -import { HapticTab } from '@/components/HapticTab' -import { IconSymbol } from '@/components/ui/IconSymbol' -import TabBarBackground from '@/components/ui/TabBarBackground' -import { Colors } from '@/constants/Colors' -import { useColorScheme } from '@/hooks/useColorScheme' - -export default function TabLayout() { - const colorScheme = useColorScheme() - - return ( - - , - }} - /> - , - }} - /> - - ) -} diff --git a/example/app/(tabs)/explore.tsx b/example/app/(tabs)/explore.tsx deleted file mode 100644 index d0dfdd5..0000000 --- a/example/app/(tabs)/explore.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ThemedView } from '@/components/ThemedView' -import { Carousel } from '@strv/react-native-hero-carousel' -import { SafeAreaView } from 'react-native' -export default function TabTwoScreen() { - return ( - - - - - - ) -} diff --git a/example/app/(tabs)/index.tsx b/example/app/(tabs)/index.tsx deleted file mode 100644 index 7f867b8..0000000 --- a/example/app/(tabs)/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ThemedText } from '@/components/ThemedText' -import { ThemedView } from '@/components/ThemedView' -import { AutoCarousel } from '@strv/react-native-hero-carousel' -import { SafeAreaView, StyleSheet, Dimensions } from 'react-native' -import { Image } from 'expo-image' -import { LinearGradient } from 'expo-linear-gradient' - -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) - -export default function HomeScreen() { - return ( - - - - {images.map((image, index) => ( - - - - Slide {index + 1} - - - ))} - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - slide: { - flex: 1, - width: SCREEN_WIDTH, - height: SCREEN_HEIGHT * 0.7, - }, - image: { - width: '100%', - height: '100%', - }, - gradient: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - height: '50%', - justifyContent: 'flex-end', - padding: 20, - }, - title: { - fontSize: 32, - bottom: 100, - left: 20, - position: 'absolute', - lineHeight: 32, - fontWeight: 'bold', - color: 'white', - }, -}) diff --git a/example/app/+not-found.tsx b/example/app/+not-found.tsx index b7601ca..dea041f 100644 --- a/example/app/+not-found.tsx +++ b/example/app/+not-found.tsx @@ -8,7 +8,7 @@ import { ThemedView } from '@/components/ThemedView' export default function NotFoundScreen() { return ( <> - + This screen does not exist. diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 5ae251d..82ddcbf 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -1,31 +1,19 @@ -import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native' -import { useFonts } from 'expo-font' import { Stack } from 'expo-router' -import { StatusBar } from 'expo-status-bar' -import 'react-native-reanimated' import { GestureHandlerRootView } from 'react-native-gesture-handler' -import { useColorScheme } from '@/hooks/useColorScheme' +import { initialWindowMetrics, SafeAreaProvider } from 'react-native-safe-area-context' +import * as SystemUI from 'expo-system-ui' +import { DarkTheme, ThemeProvider } from '@react-navigation/native' -export default function RootLayout() { - const colorScheme = useColorScheme() - const [loaded] = useFonts({ - SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), - }) - - if (!loaded) { - // Async font loading only occurs in development. - return null - } +SystemUI.setBackgroundColorAsync('black') +export default function RootLayout() { return ( - - - - - - - + + + + + - + ) } diff --git a/example/examples/AnimatedExample.tsx b/example/examples/AnimatedExample.tsx new file mode 100644 index 0000000..aed7710 --- /dev/null +++ b/example/examples/AnimatedExample.tsx @@ -0,0 +1,117 @@ +import { + AutoCarousel, + interpolateInsideCarousel, + useCarouselContext, + useAutoCarouselSlideIndex, + CarouselContextProvider, +} 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 Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated' +import { Pagination } from './components/Pagination' + +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, index }: { image: string; title: string; index: number }) => { + const { scrollValue } = useCarouselContext() + const { index: slideIndex, total } = useAutoCarouselSlideIndex() + + const rStyle = useAnimatedStyle(() => { + const progress = interpolateInsideCarousel(scrollValue.value, slideIndex, total, { + slideBefore: 0, + thisSlide: 1, + slideAfter: 0, + offset: 0.2, + }) + + return { + flex: 1, + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + transformOrigin: 'center', + transform: [ + { + scale: interpolate(progress, [0, 1], [0.8, 1], Extrapolation.CLAMP), + }, + { + rotate: `${interpolate(progress, [0, 1], [-15, 0], Extrapolation.CLAMP)}deg`, + }, + ], + opacity: progress, + } + }) + + return ( + + + + + + {title} + + + ) +} + +export default function AnimatedExample() { + return ( + + + + + {images.map((image, index) => ( + + ))} + + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + 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, + }, + title: { + fontSize: 32, + bottom: 100, + left: 20, + position: 'absolute', + lineHeight: 32, + fontWeight: 'bold', + color: 'white', + }, +}) diff --git a/example/examples/BasicExample.tsx b/example/examples/BasicExample.tsx new file mode 100644 index 0000000..e74da3e --- /dev/null +++ b/example/examples/BasicExample.tsx @@ -0,0 +1,76 @@ +import { AutoCarousel, CarouselContextProvider } 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' + +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, index }: { image: string; title: string; index: number }) => { + return ( + + + + {title} + + + ) +} + +export default function BasicExample() { + return ( + + + + + {images.map((image, index) => ( + + ))} + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + 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, + }, + title: { + fontSize: 32, + bottom: 100, + left: 20, + position: 'absolute', + lineHeight: 32, + fontWeight: 'bold', + color: 'white', + }, +}) diff --git a/example/examples/components/CarouselBase.tsx b/example/examples/components/CarouselBase.tsx new file mode 100644 index 0000000..139710c --- /dev/null +++ b/example/examples/components/CarouselBase.tsx @@ -0,0 +1,28 @@ +import { + AutoCarousel, + AutoCarouselProps, + CarouselContextProvider, +} from '@strv/react-native-hero-carousel' +import { SafeAreaView, StyleSheet, View } from 'react-native' +import { Stack } from 'expo-router' +import { Pagination } from '@/examples/components/Pagination' + +export function CarouselBase({ children }: { children: AutoCarouselProps['children'] }) { + return ( + + + + + {children} + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}) diff --git a/example/examples/components/Pagination.tsx b/example/examples/components/Pagination.tsx new file mode 100644 index 0000000..9ed1fd2 --- /dev/null +++ b/example/examples/components/Pagination.tsx @@ -0,0 +1,56 @@ +import { useCarouselContext } from '@strv/react-native-hero-carousel' +import { StyleSheet, View } from 'react-native' +import Animated, { useAnimatedStyle, interpolate, Extrapolation } from 'react-native-reanimated' +import { useSafeAreaInsets } from 'react-native-safe-area-context' + +const PaginationDot = ({ index }: { index: number }) => { + const { scrollValue } = useCarouselContext() + + const animatedStyle = useAnimatedStyle(() => { + return { + width: interpolate( + scrollValue.value - 1, + [index - 1, index, index + 1], + [8, 24, 8], + Extrapolation.CLAMP, + ), + opacity: interpolate( + scrollValue.value - 1, + [index - 1, index, index + 1], + [0.5, 1, 0.5], + Extrapolation.CLAMP, + ), + } + }) + + return +} + +export const Pagination = ({ total }: { total: number }) => { + const { bottom } = useSafeAreaInsets() + + return ( + + {Array.from({ length: total }).map((_, index) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + pagination: { + position: 'absolute', + left: 0, + right: 0, + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 8, + }, + dot: { + height: 8, + borderRadius: 4, + backgroundColor: 'white', + }, +}) diff --git a/example/examples/utils.ts b/example/examples/utils.ts new file mode 100644 index 0000000..4ab3bf5 --- /dev/null +++ b/example/examples/utils.ts @@ -0,0 +1,10 @@ +import { Dimensions } from 'react-native' + +export const SCREEN_WIDTH = Dimensions.get('window').width +export const SCREEN_HEIGHT = Dimensions.get('window').height + +const getRandomImageUrl = () => { + return `https://picsum.photos/${SCREEN_WIDTH}/${SCREEN_HEIGHT}?random=${Math.floor(Math.random() * 1000)}` +} + +export const SLIDES = Array.from({ length: 5 }, getRandomImageUrl) diff --git a/src/components/AnimatedPagedView/styles.ts b/src/components/AnimatedPagedView/styles.ts index 93e6461..8685d74 100644 --- a/src/components/AnimatedPagedView/styles.ts +++ b/src/components/AnimatedPagedView/styles.ts @@ -4,6 +4,7 @@ export const styles = StyleSheet.create({ container: { flex: 1, width: '100%', + overflow: 'hidden', }, contentContainer: { flexDirection: 'row', diff --git a/src/components/AutoCarousel/index.preset.ts b/src/components/AutoCarousel/index.preset.ts index 15acf85..26e0b5e 100644 --- a/src/components/AutoCarousel/index.preset.ts +++ b/src/components/AutoCarousel/index.preset.ts @@ -1,4 +1,4 @@ -export const TRANSITION_DURATION = 600 +export const TRANSITION_DURATION = 1000 export const ROUNDING_PRECISION = 0.003 export const ANDROID_FALLBACK_DURATION = 0.00000000001 export const DEFAULT_INTERVAL = 5000 diff --git a/src/components/AutoCarousel/index.tsx b/src/components/AutoCarousel/index.tsx index da6db40..ae61584 100644 --- a/src/components/AutoCarousel/index.tsx +++ b/src/components/AutoCarousel/index.tsx @@ -2,20 +2,17 @@ import React, { useRef, useEffect, useCallback } from 'react' import { runOnJS, useSharedValue, withTiming, useAnimatedReaction } from 'react-native-reanimated' import { DEFAULT_INTERVAL, ROUNDING_PRECISION, TRANSITION_DURATION } from './index.preset' -import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' +import { useCarouselContext } from '../../context/CarouselContext' import { AutoCarouselSlide } from '../AutoCarouselSlide' import { customRound } from '../../utils/round' import { AutoCarouselAdapter } from '../AnimatedPagedView/Adapter' -type AutoCarouselProps = { +export type AutoCarouselProps = { interval?: number - children: JSX.Element | JSX.Element[] + children: JSX.Element[] } -export const AutoCarouselWithoutProvider = ({ - interval = DEFAULT_INTERVAL, - children, -}: AutoCarouselProps) => { +export const AutoCarousel = ({ interval = DEFAULT_INTERVAL, children }: AutoCarouselProps) => { const { scrollValue, userInteracted, slideWidth } = useCarouselContext() const offset = useSharedValue({ value: slideWidth }) @@ -95,26 +92,25 @@ export const AutoCarouselWithoutProvider = ({ ) return ( - { - 'worklet' - scrollValue.value = activeIndex - }} - > - {React.Children.map(paddedChildrenArray, (child, index) => ( - - {child} - - ))} - - ) -} - -export const AutoCarousel = ({ interval, children }: AutoCarouselProps) => { - return ( - - {children} - + <> + { + 'worklet' + scrollValue.value = activeIndex + }} + > + {React.Children.map(paddedChildrenArray, (child, index) => ( + + {child} + + ))} + + ) } diff --git a/src/components/AutoCarouselSlide/index.tsx b/src/components/AutoCarouselSlide/index.tsx index 8fc1e8f..cf80ada 100644 --- a/src/components/AutoCarouselSlide/index.tsx +++ b/src/components/AutoCarouselSlide/index.tsx @@ -1,11 +1,22 @@ import { View } from 'react-native' +import { AutoCarouselSlideContext } from '../../context/SlideContext' export const AutoCarouselSlide = ({ children, width, + index, + total, }: { children: React.ReactNode width: number + index: number + total: number }) => { - return {children} + return ( + + + {children} + + + ) } diff --git a/src/context/SlideContext/index.tsx b/src/context/SlideContext/index.tsx new file mode 100644 index 0000000..13531e0 --- /dev/null +++ b/src/context/SlideContext/index.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from 'react' + +export const AutoCarouselSlideContext = createContext<{ index: number; total: number } | null>(null) + +export const useAutoCarouselSlideIndex = () => { + const context = useContext(AutoCarouselSlideContext) + if (!context) { + throw new Error('useAutoCarouselSlideIndex must be used within a AutoCarouselSlide') + } + return context +} diff --git a/src/context/index.tsx b/src/context/index.tsx new file mode 100644 index 0000000..93a6354 --- /dev/null +++ b/src/context/index.tsx @@ -0,0 +1,2 @@ +export * from './CarouselContext' +export * from './SlideContext' diff --git a/src/index.tsx b/src/index.tsx index cb64ac1..1710fbf 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1 +1,3 @@ export * from './components' +export * from './context' +export * from './utils' diff --git a/src/utils/index.tsx b/src/utils/index.tsx new file mode 100644 index 0000000..9ab9f57 --- /dev/null +++ b/src/utils/index.tsx @@ -0,0 +1,2 @@ +export * from './interpolateInsideCarousel' +export * from './round' diff --git a/src/utils/interpolateInsideCarousel.ts b/src/utils/interpolateInsideCarousel.ts new file mode 100644 index 0000000..1cdaa59 --- /dev/null +++ b/src/utils/interpolateInsideCarousel.ts @@ -0,0 +1,39 @@ +import { Extrapolation, interpolate } from 'react-native-reanimated' + +export const interpolateInsideCarousel = ( + scrollValue: number, + slideIndex: number, + totalLength: number, + values: { + slideBefore: number + thisSlide: number + slideAfter: number + offset: number + }, +) => { + 'worklet' + const { offset, slideBefore: incoming, thisSlide: inside, slideAfter: outgoing } = values + let adjustedIndex = slideIndex + + if (slideIndex === 0) { + adjustedIndex = Math.max(totalLength - 2, 0) + } + + if (slideIndex === totalLength - 1) { + adjustedIndex = 1 + } + + const inputRange = [ + 0, + Math.min(1, adjustedIndex - 1) - offset, + adjustedIndex - 1 + offset, + adjustedIndex, + adjustedIndex + 1 - offset, + Math.max(totalLength - 2, adjustedIndex + 1) + offset, + totalLength - 1, + ] + + const outputValues = [inside, incoming, outgoing, inside, incoming, outgoing, inside] + + return interpolate(scrollValue, inputRange, outputValues, Extrapolation.CLAMP) +}