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