mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at verify-code 6.2 kB view raw
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}