Skip to content

Commit 937bfdb

Browse files
author
Petr Konecny
committed
feat: implemented custom scroller instead of scroll view
1 parent 09f919a commit 937bfdb

File tree

12 files changed

+274
-45
lines changed

12 files changed

+274
-45
lines changed

example/app.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"foregroundImage": "./assets/images/adaptive-icon.png",
1818
"backgroundColor": "#ffffff"
1919
},
20-
"edgeToEdgeEnabled": true
20+
"edgeToEdgeEnabled": true,
21+
"package": "com.petrkonecny2.example"
2122
},
2223
"web": {
2324
"bundler": "metro",

example/app/_layout.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useFonts } from 'expo-font'
33
import { Stack } from 'expo-router'
44
import { StatusBar } from 'expo-status-bar'
55
import 'react-native-reanimated'
6-
6+
import { GestureHandlerRootView } from 'react-native-gesture-handler'
77
import { useColorScheme } from '@/hooks/useColorScheme'
88

99
export default function RootLayout() {
@@ -18,12 +18,14 @@ export default function RootLayout() {
1818
}
1919

2020
return (
21-
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
22-
<Stack>
23-
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
24-
<Stack.Screen name="+not-found" />
25-
</Stack>
26-
<StatusBar style="auto" />
27-
</ThemeProvider>
21+
<GestureHandlerRootView style={{ flex: 1 }}>
22+
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
23+
<Stack>
24+
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
25+
<Stack.Screen name="+not-found" />
26+
</Stack>
27+
<StatusBar style="auto" />
28+
</ThemeProvider>
29+
</GestureHandlerRootView>
2830
)
2931
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"jest": "^29.7.0",
4343
"lint-staged": "^16.0.0",
4444
"prettier": "^3.5.3",
45+
"react-native-gesture-handler": "^2.24.0",
4546
"typescript": "^5.0.0"
4647
},
4748
"files": [

pnpm-lock.yaml

Lines changed: 49 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { AnimatedPagedScrollView } from './index'
2+
import Animated, {
3+
scrollTo,
4+
SharedValue,
5+
useAnimatedReaction,
6+
useAnimatedRef,
7+
useAnimatedScrollHandler,
8+
} from 'react-native-reanimated'
9+
import { customRound } from '../../utils/round'
10+
import { ROUNDING_PRECISION } from '../../components/AutoCarousel/index.preset'
11+
import { useCarouselContext } from '../../context/CarouselContext'
12+
13+
type Props = {
14+
onScroll: (value: number) => void
15+
children: React.ReactNode
16+
offset: SharedValue<{ value: number }>
17+
}
18+
19+
export const AutoCarouselAdapter = ({ onScroll, children, offset }: Props) => {
20+
const scrollViewRef = useAnimatedRef<Animated.ScrollView>()
21+
const { slideWidth, setUserInteracted } = useCarouselContext()
22+
23+
useAnimatedReaction(
24+
() => offset.value.value,
25+
(value) => {
26+
scrollTo(scrollViewRef, value, 0, false)
27+
},
28+
)
29+
30+
const scrollHandler = useAnimatedScrollHandler(
31+
(event) => {
32+
const activeIndex = customRound(event.contentOffset.x / slideWidth, ROUNDING_PRECISION)
33+
if (event.contentOffset.x === 0) return
34+
onScroll(activeIndex)
35+
},
36+
[slideWidth],
37+
)
38+
39+
return (
40+
<AnimatedPagedScrollView
41+
ref={scrollViewRef}
42+
onScroll={scrollHandler}
43+
onScrollBeginDrag={() => {
44+
setUserInteracted(true)
45+
}}
46+
>
47+
{children}
48+
</AnimatedPagedScrollView>
49+
)
50+
}

src/components/AnimatedPagedScrollView/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,15 @@ export const AnimatedPagedScrollView = forwardRef(
1414
pagingEnabled
1515
scrollToOverflowEnabled
1616
showsHorizontalScrollIndicator={false}
17-
scrollEventThrottle={16}
1817
{...props}
1918
/>
2019
</View>
2120
)
2221
},
2322
)
2423

24+
export type AnimatedPagedScrollViewRef = {
25+
scrollTo: (value: number) => void
26+
}
27+
2528
AnimatedPagedScrollView.displayName = 'AnimatedPagedScrollView'

src/components/AnimatedPagedScrollView/styles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ export const styles = StyleSheet.create({
55
flex: 1,
66
width: '100%',
77
},
8+
contentContainer: {
9+
flexDirection: 'row',
10+
flex: 1,
11+
},
812
})
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { SharedValue, useAnimatedReaction } from 'react-native-reanimated'
2+
import { useRef } from 'react'
3+
import { AnimatedPagedScrollViewRef, AnimatedPagedView } from './index'
4+
import { customRound } from '../../utils/round'
5+
import { ROUNDING_PRECISION } from '../../components/AutoCarousel/index.preset'
6+
import { useCarouselContext } from '../../context/CarouselContext'
7+
8+
type Props = {
9+
onScroll: (value: number) => void
10+
offset: SharedValue<{ value: number }>
11+
children: React.ReactNode
12+
}
13+
14+
export const AutoCarouselAdapter = ({ offset, onScroll, children }: Props) => {
15+
const scrollViewRef = useRef<AnimatedPagedScrollViewRef | null>(null)
16+
const { slideWidth, setUserInteracted } = useCarouselContext()
17+
18+
useAnimatedReaction(
19+
() => offset.value.value,
20+
(value) => {
21+
scrollViewRef.current?.scrollTo(value)
22+
},
23+
)
24+
25+
const handleScroll = (value: number) => {
26+
'worklet'
27+
const activeIndex = customRound(value / slideWidth, ROUNDING_PRECISION)
28+
if (value === 0) return
29+
onScroll(activeIndex)
30+
}
31+
32+
return (
33+
<AnimatedPagedView
34+
ref={scrollViewRef}
35+
onScrollBeginDrag={() => {
36+
setUserInteracted(true)
37+
}}
38+
onScroll={handleScroll}
39+
>
40+
{children}
41+
</AnimatedPagedView>
42+
)
43+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { forwardRef, useImperativeHandle, type ForwardedRef } from 'react'
2+
import { Dimensions } from 'react-native'
3+
import Animated, {
4+
useSharedValue,
5+
useAnimatedStyle,
6+
useAnimatedReaction,
7+
runOnJS,
8+
withTiming,
9+
} from 'react-native-reanimated'
10+
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
11+
12+
import { styles } from './styles'
13+
14+
const { width: SCREEN_WIDTH } = Dimensions.get('window')
15+
16+
export type AnimatedPagedScrollViewRef = {
17+
scrollTo: (value: number) => void
18+
}
19+
20+
export const AnimatedPagedView = forwardRef(
21+
(
22+
props: {
23+
onScroll: (value: number) => void
24+
onScrollBeginDrag: () => void
25+
children: React.ReactNode
26+
},
27+
ref: ForwardedRef<AnimatedPagedScrollViewRef>,
28+
) => {
29+
const translateX = useSharedValue(0)
30+
const context = useSharedValue({ x: 0 })
31+
32+
const gesture = Gesture.Pan()
33+
.onStart(() => {
34+
context.value = { x: translateX.value }
35+
runOnJS(props.onScrollBeginDrag)()
36+
})
37+
.onUpdate((event) => {
38+
translateX.value = context.value.x - event.translationX
39+
})
40+
.onEnd((event) => {
41+
const velocity = event.velocityX
42+
const currentPage = Math.round(translateX.value / SCREEN_WIDTH)
43+
const targetPage =
44+
velocity > 500 ? currentPage - 1 : velocity < -500 ? currentPage + 1 : currentPage
45+
// in case the gesture overshoots, snap to the nearest page
46+
if (Math.abs(context.value.x - translateX.value) > SCREEN_WIDTH / 2) {
47+
translateX.value = withTiming(currentPage * SCREEN_WIDTH)
48+
} else {
49+
translateX.value = withTiming(targetPage * SCREEN_WIDTH)
50+
}
51+
})
52+
53+
const animatedStyle = useAnimatedStyle(() => {
54+
return {
55+
transform: [{ translateX: -translateX.value }],
56+
}
57+
}, [])
58+
59+
useAnimatedReaction(
60+
() => translateX.value,
61+
(value) => {
62+
props.onScroll?.(value)
63+
},
64+
)
65+
66+
useImperativeHandle(ref, () => ({
67+
scrollTo: (value: number) => {
68+
'worklet'
69+
translateX.value = value
70+
},
71+
}))
72+
73+
return (
74+
<GestureDetector gesture={gesture}>
75+
<Animated.View style={[styles.container]}>
76+
<Animated.View style={[styles.contentContainer, animatedStyle]}>
77+
{props.children}
78+
</Animated.View>
79+
</Animated.View>
80+
</GestureDetector>
81+
)
82+
},
83+
)
84+
85+
AnimatedPagedView.displayName = 'AnimatedPagedView'
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { StyleSheet } from 'react-native'
2+
3+
export const styles = StyleSheet.create({
4+
container: {
5+
flex: 1,
6+
width: '100%',
7+
},
8+
contentContainer: {
9+
flexDirection: 'row',
10+
flex: 1,
11+
},
12+
})

0 commit comments

Comments
 (0)