mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at ruby-v 212 lines 5.9 kB view raw
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}