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