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