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