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