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