diff --git a/example/app.json b/example/app.json index 8400d70..09747de 100644 --- a/example/app.json +++ b/example/app.json @@ -17,7 +17,8 @@ "foregroundImage": "./assets/images/adaptive-icon.png", "backgroundColor": "#ffffff" }, - "edgeToEdgeEnabled": true + "edgeToEdgeEnabled": true, + "package": "com.petrkonecny2.example" }, "web": { "bundler": "metro", diff --git a/example/app/_layout.tsx b/example/app/_layout.tsx index 9f03fbe..5ae251d 100644 --- a/example/app/_layout.tsx +++ b/example/app/_layout.tsx @@ -3,7 +3,7 @@ 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' export default function RootLayout() { @@ -18,12 +18,14 @@ export default function RootLayout() { } return ( - - - - - - - + + + + + + + + + ) } diff --git a/package.json b/package.json index fcfb0c7..22af84d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "jest": "^29.7.0", "lint-staged": "^16.0.0", "prettier": "^3.5.3", + "react-native-gesture-handler": "^2.24.0", "typescript": "^5.0.0" }, "files": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7182ec9..5818559 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.5.3 + react-native-gesture-handler: + specifier: ^2.24.0 + version: 2.25.0(react-native@0.79.2(@babel/core@7.27.1)(@types/react@18.3.21)(react@19.1.0))(react@19.1.0) typescript: specifier: ^5.0.0 version: 5.8.3 @@ -489,6 +492,13 @@ packages: integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, } + '@egjs/hammerjs@2.0.17': + resolution: + { + integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==, + } + engines: { node: '>=0.8.0' } + '@emnapi/core@1.4.3': resolution: { @@ -973,6 +983,12 @@ packages: integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==, } + '@types/hammerjs@2.0.46': + resolution: + { + integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==, + } + '@types/istanbul-lib-coverage@2.0.6': resolution: { @@ -2706,6 +2722,12 @@ packages: integrity: sha512-nf8o+hE8g7UJWParnccljHumE9Vlq8F7MqIdeahl+4x0tvCUJYRrT0L7h0MMg/X9YJmkNwsfbaNNrzPtFXOscg==, } + hoist-non-react-statics@3.3.2: + resolution: + { + integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==, + } + html-escaper@2.0.2: resolution: { @@ -4166,6 +4188,15 @@ packages: integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, } + react-native-gesture-handler@2.25.0: + resolution: + { + integrity: sha512-NPjJi6mislXxvjxQPU9IYwBjb1Uejp8GvAbE1Lhh+xMIMEvmgAvVIp5cz1P+xAbV6uYcRRArm278+tEInGOqWg==, + } + peerDependencies: + react: '*' + react-native: '*' + react-native-is-edge-to-edge@1.1.7: resolution: { @@ -5451,6 +5482,10 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@egjs/hammerjs@2.0.17': + dependencies: + '@types/hammerjs': 2.0.46 + '@emnapi/core@1.4.3': dependencies: '@emnapi/wasi-threads': 1.0.2 @@ -5858,6 +5893,8 @@ snapshots: dependencies: '@types/node': 22.15.18 + '@types/hammerjs@2.0.46': {} + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -7093,6 +7130,10 @@ snapshots: dependencies: hermes-estree: 0.28.1 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-escaper@2.0.2: {} http-errors@2.0.0: @@ -8210,6 +8251,14 @@ snapshots: react-is@18.3.1: {} + react-native-gesture-handler@2.25.0(react-native@0.79.2(@babel/core@7.27.1)(@types/react@18.3.21)(react@19.1.0))(react@19.1.0): + dependencies: + '@egjs/hammerjs': 2.0.17 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.1.0 + react-native: 0.79.2(@babel/core@7.27.1)(@types/react@18.3.21)(react@19.1.0) + react-native-is-edge-to-edge@1.1.7(react-native@0.79.2(@babel/core@7.27.1)(@types/react@18.3.21)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 diff --git a/src/components/AnimatedPagedScrollView/Adapter.tsx b/src/components/AnimatedPagedScrollView/Adapter.tsx new file mode 100644 index 0000000..36e7301 --- /dev/null +++ b/src/components/AnimatedPagedScrollView/Adapter.tsx @@ -0,0 +1,44 @@ +import { AnimatedPagedScrollView } from './index' +import Animated, { + scrollTo, + useAnimatedReaction, + useAnimatedRef, + useAnimatedScrollHandler, +} from 'react-native-reanimated' +import { customRound } from '../../utils/round' +import { ROUNDING_PRECISION } from '../../components/AutoCarousel/index.preset' +import { useCarouselContext } from '../../context/CarouselContext' +import { AutoCarouselAdapterProps } from '../../components/AutoCarousel/types' + +export const AutoCarouselAdapter = ({ onScroll, children, offset }: AutoCarouselAdapterProps) => { + const scrollViewRef = useAnimatedRef() + const { slideWidth, setUserInteracted } = useCarouselContext() + + useAnimatedReaction( + () => offset.value.value, + (value) => { + scrollTo(scrollViewRef, value, 0, false) + }, + ) + + const scrollHandler = useAnimatedScrollHandler( + (event) => { + const activeIndex = customRound(event.contentOffset.x / slideWidth, ROUNDING_PRECISION) + if (event.contentOffset.x === 0) return + onScroll(activeIndex) + }, + [slideWidth], + ) + + return ( + { + setUserInteracted(true) + }} + > + {children} + + ) +} diff --git a/src/components/AnimatedPagedScrollView/index.tsx b/src/components/AnimatedPagedScrollView/index.tsx index d85722a..2da0c3b 100644 --- a/src/components/AnimatedPagedScrollView/index.tsx +++ b/src/components/AnimatedPagedScrollView/index.tsx @@ -14,7 +14,6 @@ export const AnimatedPagedScrollView = forwardRef( pagingEnabled scrollToOverflowEnabled showsHorizontalScrollIndicator={false} - scrollEventThrottle={16} {...props} /> diff --git a/src/components/AnimatedPagedScrollView/styles.ts b/src/components/AnimatedPagedScrollView/styles.ts index 3121e19..93e6461 100644 --- a/src/components/AnimatedPagedScrollView/styles.ts +++ b/src/components/AnimatedPagedScrollView/styles.ts @@ -5,4 +5,8 @@ export const styles = StyleSheet.create({ flex: 1, width: '100%', }, + contentContainer: { + flexDirection: 'row', + flex: 1, + }, }) diff --git a/src/components/AnimatedPagedView/Adapter.tsx b/src/components/AnimatedPagedView/Adapter.tsx new file mode 100644 index 0000000..e88746e --- /dev/null +++ b/src/components/AnimatedPagedView/Adapter.tsx @@ -0,0 +1,38 @@ +import { useAnimatedReaction } from 'react-native-reanimated' +import { useRef } from 'react' +import { AnimatedPagedScrollViewRef, AnimatedPagedView } from './index' +import { customRound } from '../../utils/round' +import { ROUNDING_PRECISION } from '../../components/AutoCarousel/index.preset' +import { useCarouselContext } from '../../context/CarouselContext' +import { AutoCarouselAdapterProps } from '../../components/AutoCarousel/types' + +export const AutoCarouselAdapter = ({ offset, onScroll, children }: AutoCarouselAdapterProps) => { + const scrollViewRef = useRef(null) + const { slideWidth, setUserInteracted } = useCarouselContext() + + useAnimatedReaction( + () => offset.value.value, + (value) => { + scrollViewRef.current?.scrollTo(value) + }, + ) + + const handleScroll = (value: number) => { + 'worklet' + const activeIndex = customRound(value / slideWidth, ROUNDING_PRECISION) + if (value === 0) return + onScroll(activeIndex) + } + + return ( + { + setUserInteracted(true) + }} + onScroll={handleScroll} + > + {children} + + ) +} diff --git a/src/components/AnimatedPagedView/index.tsx b/src/components/AnimatedPagedView/index.tsx new file mode 100644 index 0000000..73518d5 --- /dev/null +++ b/src/components/AnimatedPagedView/index.tsx @@ -0,0 +1,85 @@ +import { forwardRef, useImperativeHandle, type ForwardedRef } from 'react' +import { Dimensions } from 'react-native' +import Animated, { + useSharedValue, + useAnimatedStyle, + useAnimatedReaction, + runOnJS, + withTiming, +} from 'react-native-reanimated' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' + +import { styles } from './styles' + +const { width: SCREEN_WIDTH } = Dimensions.get('window') + +export type AnimatedPagedScrollViewRef = { + scrollTo: (value: number) => void +} + +export const AnimatedPagedView = forwardRef( + ( + props: { + onScroll: (value: number) => void + onScrollBeginDrag: () => void + children: React.ReactNode + }, + ref: ForwardedRef, + ) => { + const translateX = useSharedValue(0) + const context = useSharedValue({ x: 0 }) + + const gesture = Gesture.Pan() + .onStart(() => { + context.value = { x: translateX.value } + runOnJS(props.onScrollBeginDrag)() + }) + .onUpdate((event) => { + translateX.value = context.value.x - event.translationX + }) + .onEnd((event) => { + const velocity = event.velocityX + const currentPage = Math.round(translateX.value / SCREEN_WIDTH) + 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) > SCREEN_WIDTH / 2) { + translateX.value = withTiming(currentPage * SCREEN_WIDTH) + } else { + translateX.value = withTiming(targetPage * SCREEN_WIDTH) + } + }) + + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ translateX: -translateX.value }], + } + }, []) + + useAnimatedReaction( + () => translateX.value, + (value) => { + props.onScroll?.(value) + }, + ) + + useImperativeHandle(ref, () => ({ + scrollTo: (value: number) => { + 'worklet' + translateX.value = value + }, + })) + + return ( + + + + {props.children} + + + + ) + }, +) + +AnimatedPagedView.displayName = 'AnimatedPagedView' diff --git a/src/components/AnimatedPagedView/styles.ts b/src/components/AnimatedPagedView/styles.ts new file mode 100644 index 0000000..93e6461 --- /dev/null +++ b/src/components/AnimatedPagedView/styles.ts @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native' + +export const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + }, + contentContainer: { + flexDirection: 'row', + flex: 1, + }, +}) diff --git a/src/components/AutoCarousel/index.tsx b/src/components/AutoCarousel/index.tsx index 010023d..65e5289 100644 --- a/src/components/AutoCarousel/index.tsx +++ b/src/components/AutoCarousel/index.tsx @@ -1,25 +1,17 @@ import React, { useRef, useEffect, useCallback } from 'react' -import { Platform } from 'react-native' -import Animated, { +import { runOnJS, useAnimatedScrollHandler, useSharedValue, withTiming, useAnimatedReaction, - scrollTo, - useAnimatedRef, } from 'react-native-reanimated' -import { - ANDROID_FALLBACK_DURATION, - DEFAULT_INTERVAL, - ROUNDING_PRECISION, - TRANSITION_DURATION, -} from './index.preset' +import { DEFAULT_INTERVAL, ROUNDING_PRECISION, TRANSITION_DURATION } from './index.preset' import { CarouselContextProvider, useCarouselContext } from '../../context/CarouselContext' -import { AnimatedPagedScrollView } from '../AnimatedPagedScrollView' import { AutoCarouselSlide } from '../AutoCarouselSlide' import { customRound } from '../../utils/round' +import { AutoCarouselAdapter } from '../AnimatedPagedView/Adapter' type AutoCarouselProps = { interval?: number @@ -30,8 +22,7 @@ export const AutoCarouselWithoutProvider = ({ interval = DEFAULT_INTERVAL, children, }: AutoCarouselProps) => { - const { scrollValue, userInteracted, setUserInteracted, slideWidth } = useCarouselContext() - const scrollViewRef = useAnimatedRef() + const { scrollValue, userInteracted, slideWidth } = useCarouselContext() const offset = useSharedValue({ value: slideWidth }) const childrenArray = React.Children.toArray(children) @@ -102,13 +93,8 @@ export const AutoCarouselWithoutProvider = ({ } // 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) { - // weird issue when scrolling backwards on android we must set duration to a very small value - // otherwise with just 0 the list doesn't scroll - goToPage( - paddedChildrenArray.length - 2, - Platform.OS === 'android' ? ANDROID_FALLBACK_DURATION : 0, - ) + if (activeIndex < 0.01 && activeIndex > -0.01) { + goToPage(paddedChildrenArray.length - 2, 0) } }, [childrenArray.length, goToPage, paddedChildrenArray.length, slideWidth, scrollValue.value], @@ -123,27 +109,20 @@ export const AutoCarouselWithoutProvider = ({ [slideWidth, autoScrollEnabled], ) - useAnimatedReaction( - () => offset.value.value, - (value) => { - scrollTo(scrollViewRef, value, 0, false) - }, - ) - return ( - { - setUserInteracted(true) + { + 'worklet' + scrollValue.value = activeIndex }} - onScroll={scrollHandler} > {React.Children.map(paddedChildrenArray, (child, index) => ( {child} ))} - + ) } diff --git a/src/components/AutoCarousel/types.ts b/src/components/AutoCarousel/types.ts new file mode 100644 index 0000000..c3e9b4c --- /dev/null +++ b/src/components/AutoCarousel/types.ts @@ -0,0 +1,7 @@ +import { SharedValue } from 'react-native-reanimated' + +export type AutoCarouselAdapterProps = { + offset: SharedValue<{ value: number }> + onScroll: (value: number) => void + children: React.ReactNode +} diff --git a/src/components/AutoCarouselSlide/index.tsx b/src/components/AutoCarouselSlide/index.tsx index 87da0c2..8fc1e8f 100644 --- a/src/components/AutoCarouselSlide/index.tsx +++ b/src/components/AutoCarouselSlide/index.tsx @@ -7,5 +7,5 @@ export const AutoCarouselSlide = ({ children: React.ReactNode width: number }) => { - return {children} + return {children} }