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}
}