mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useCallback, useEffect} from 'react'
2import {NativeScrollEvent} from 'react-native'
3import {
4 cancelAnimation,
5 interpolate,
6 makeMutable,
7 useSharedValue,
8 withSpring,
9} from 'react-native-reanimated'
10import EventEmitter from 'eventemitter3'
11
12import {ScrollProvider} from '#/lib/ScrollContext'
13import {isNative, isWeb} from '#/platform/detection'
14import {useMinimalShellMode} from '#/state/shell'
15import {useShellLayout} from '#/state/shell/shell-layout'
16
17const WEB_HIDE_SHELL_THRESHOLD = 200
18
19function clamp(num: number, min: number, max: number) {
20 'worklet'
21 return Math.min(Math.max(num, min), max)
22}
23
24const V0 = makeMutable(
25 withSpring(0, {
26 overshootClamping: true,
27 }),
28)
29
30const V1 = makeMutable(
31 withSpring(1, {
32 overshootClamping: true,
33 }),
34)
35
36export function MainScrollProvider({children}: {children: React.ReactNode}) {
37 const {headerHeight} = useShellLayout()
38 const {headerMode} = useMinimalShellMode()
39 const startDragOffset = useSharedValue<number | null>(null)
40 const startMode = useSharedValue<number | null>(null)
41 const didJustRestoreScroll = useSharedValue<boolean>(false)
42
43 const setMode = React.useCallback(
44 (v: boolean) => {
45 'worklet'
46 cancelAnimation(headerMode)
47 headerMode.value = v ? V1.value : V0.value
48 },
49 [headerMode],
50 )
51
52 useEffect(() => {
53 if (isWeb) {
54 return listenToForcedWindowScroll(() => {
55 startDragOffset.value = null
56 startMode.value = null
57 didJustRestoreScroll.value = true
58 })
59 }
60 })
61
62 const snapToClosestState = useCallback(
63 (e: NativeScrollEvent) => {
64 'worklet'
65 if (isNative) {
66 if (startDragOffset.value === null) {
67 return
68 }
69 const didScrollDown = e.contentOffset.y > startDragOffset.value
70 startDragOffset.value = null
71 startMode.value = null
72 if (e.contentOffset.y < headerHeight.value) {
73 // If we're close to the top, show the shell.
74 setMode(false)
75 } else if (didScrollDown) {
76 // Showing the bar again on scroll down feels annoying, so don't.
77 setMode(true)
78 } else {
79 // Snap to whichever state is the closest.
80 setMode(Math.round(headerMode.value) === 1)
81 }
82 }
83 },
84 [startDragOffset, startMode, setMode, headerMode, headerHeight],
85 )
86
87 const onBeginDrag = useCallback(
88 (e: NativeScrollEvent) => {
89 'worklet'
90 if (isNative) {
91 startDragOffset.value = e.contentOffset.y
92 startMode.value = headerMode.value
93 }
94 },
95 [headerMode, startDragOffset, startMode],
96 )
97
98 const onEndDrag = useCallback(
99 (e: NativeScrollEvent) => {
100 'worklet'
101 if (isNative) {
102 if (e.velocity && e.velocity.y !== 0) {
103 // If we detect a velocity, wait for onMomentumEnd to snap.
104 return
105 }
106 snapToClosestState(e)
107 }
108 },
109 [snapToClosestState],
110 )
111
112 const onMomentumEnd = useCallback(
113 (e: NativeScrollEvent) => {
114 'worklet'
115 if (isNative) {
116 snapToClosestState(e)
117 }
118 },
119 [snapToClosestState],
120 )
121
122 const onScroll = useCallback(
123 (e: NativeScrollEvent) => {
124 'worklet'
125 if (isNative) {
126 if (startDragOffset.value === null || startMode.value === null) {
127 if (
128 headerMode.value !== 0 &&
129 e.contentOffset.y < headerHeight.value
130 ) {
131 // If we're close enough to the top, always show the shell.
132 // Even if we're not dragging.
133 setMode(false)
134 }
135 return
136 }
137
138 // The "mode" value is always between 0 and 1.
139 // Figure out how much to move it based on the current dragged distance.
140 const dy = e.contentOffset.y - startDragOffset.value
141 const dProgress = interpolate(
142 dy,
143 [-headerHeight.value, headerHeight.value],
144 [-1, 1],
145 )
146 const newValue = clamp(startMode.value + dProgress, 0, 1)
147 if (newValue !== headerMode.value) {
148 // Manually adjust the value. This won't be (and shouldn't be) animated.
149 // Cancel any any existing animation
150 cancelAnimation(headerMode)
151 headerMode.value = newValue
152 }
153 } else {
154 if (didJustRestoreScroll.value) {
155 didJustRestoreScroll.value = false
156 // Don't hide/show navbar based on scroll restoratoin.
157 return
158 }
159 // On the web, we don't try to follow the drag because we don't know when it ends.
160 // Instead, show/hide immediately based on whether we're scrolling up or down.
161 const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
162 startDragOffset.value = e.contentOffset.y
163
164 if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
165 setMode(false)
166 } else if (dy > 0) {
167 setMode(true)
168 }
169 }
170 },
171 [
172 headerHeight,
173 headerMode,
174 setMode,
175 startDragOffset,
176 startMode,
177 didJustRestoreScroll,
178 ],
179 )
180
181 return (
182 <ScrollProvider
183 onBeginDrag={onBeginDrag}
184 onEndDrag={onEndDrag}
185 onScroll={onScroll}
186 onMomentumEnd={onMomentumEnd}>
187 {children}
188 </ScrollProvider>
189 )
190}
191
192const emitter = new EventEmitter()
193
194if (isWeb) {
195 const originalScroll = window.scroll
196 window.scroll = function () {
197 emitter.emit('forced-scroll')
198 return originalScroll.apply(this, arguments as any)
199 }
200
201 const originalScrollTo = window.scrollTo
202 window.scrollTo = function () {
203 emitter.emit('forced-scroll')
204 return originalScrollTo.apply(this, arguments as any)
205 }
206}
207
208function listenToForcedWindowScroll(listener: () => void) {
209 emitter.addListener('forced-scroll', listener)
210 return () => {
211 emitter.removeListener('forced-scroll', listener)
212 }
213}