Files
bsky-social-app/src/view/com/util/MainScrollProvider.tsx
T
dan b8dbb71781 Fix fixed footer experiment (#4969)
* Split minimal shell mode into headerMode and footerMode

For now, we'll always write them in sync. When we read them, we'll use headerMode as source of truth. This will let us keep footerMode independent in a future commit.

* Remove fixed_bottom_bar special cases during calculation

This isn't the right time to determine special behavior. Instead we'll adjust footerMode itself conditionally on the gate.

* Copy-paste setMode into MainScrollProvider

This lets us fork the implementation later just for this case.

* Gate footer adjustment in MainScrollProvider

This is the final piece. Normal calls to setMode() keep setting both header and footer, but MainScrollProvider adjusts the footer conditionally.
2024-08-22 23:27:33 +01:00

218 lines
6.1 KiB
TypeScript

import React, {useCallback, useEffect} from 'react'
import {NativeScrollEvent} from 'react-native'
import {
cancelAnimation,
interpolate,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {useGate} from '#/lib/statsig/statsig'
import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
const WEB_HIDE_SHELL_THRESHOLD = 200
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
const {headerMode, footerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
const didJustRestoreScroll = useSharedValue<boolean>(false)
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const setMode = React.useCallback(
(v: boolean) => {
'worklet'
cancelAnimation(headerMode)
headerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
if (!isFixedBottomBar) {
cancelAnimation(footerMode)
footerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
}
},
[headerMode, footerMode, isFixedBottomBar],
)
useEffect(() => {
if (isWeb) {
return listenToForcedWindowScroll(() => {
startDragOffset.value = null
startMode.value = null
didJustRestoreScroll.value = true
})
}
})
const snapToClosestState = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
if (startDragOffset.value === null) {
return
}
const didScrollDown = e.contentOffset.y > startDragOffset.value
startDragOffset.value = null
startMode.value = null
if (e.contentOffset.y < headerHeight.value) {
// If we're close to the top, show the shell.
setMode(false)
} else if (didScrollDown) {
// Showing the bar again on scroll down feels annoying, so don't.
setMode(true)
} else {
// Snap to whichever state is the closest.
setMode(Math.round(headerMode.value) === 1)
}
}
},
[startDragOffset, startMode, setMode, headerMode, headerHeight],
)
const onBeginDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
startDragOffset.value = e.contentOffset.y
startMode.value = headerMode.value
}
},
[headerMode, startDragOffset, startMode],
)
const onEndDrag = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
if (e.velocity && e.velocity.y !== 0) {
// If we detect a velocity, wait for onMomentumEnd to snap.
return
}
snapToClosestState(e)
}
},
[snapToClosestState],
)
const onMomentumEnd = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
snapToClosestState(e)
}
},
[snapToClosestState],
)
const onScroll = useCallback(
(e: NativeScrollEvent) => {
'worklet'
if (isNative) {
if (startDragOffset.value === null || startMode.value === null) {
if (
headerMode.value !== 0 &&
e.contentOffset.y < headerHeight.value
) {
// If we're close enough to the top, always show the shell.
// Even if we're not dragging.
setMode(false)
}
return
}
// The "mode" value is always between 0 and 1.
// Figure out how much to move it based on the current dragged distance.
const dy = e.contentOffset.y - startDragOffset.value
const dProgress = interpolate(
dy,
[-headerHeight.value, headerHeight.value],
[-1, 1],
)
const newValue = clamp(startMode.value + dProgress, 0, 1)
if (newValue !== headerMode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
// Cancel any any existing animation
cancelAnimation(headerMode)
headerMode.value = newValue
if (!isFixedBottomBar) {
cancelAnimation(footerMode)
footerMode.value = newValue
}
}
} else {
if (didJustRestoreScroll.value) {
didJustRestoreScroll.value = false
// Don't hide/show navbar based on scroll restoratoin.
return
}
// On the web, we don't try to follow the drag because we don't know when it ends.
// Instead, show/hide immediately based on whether we're scrolling up or down.
const dy = e.contentOffset.y - (startDragOffset.value ?? 0)
startDragOffset.value = e.contentOffset.y
if (dy < 0 || e.contentOffset.y < WEB_HIDE_SHELL_THRESHOLD) {
setMode(false)
} else if (dy > 0) {
setMode(true)
}
}
},
[
headerHeight,
headerMode,
footerMode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
isFixedBottomBar,
],
)
return (
<ScrollProvider
onBeginDrag={onBeginDrag}
onEndDrag={onEndDrag}
onScroll={onScroll}
onMomentumEnd={onMomentumEnd}>
{children}
</ScrollProvider>
)
}
const emitter = new EventEmitter()
if (isWeb) {
const originalScroll = window.scroll
window.scroll = function () {
emitter.emit('forced-scroll')
return originalScroll.apply(this, arguments as any)
}
const originalScrollTo = window.scrollTo
window.scrollTo = function () {
emitter.emit('forced-scroll')
return originalScrollTo.apply(this, arguments as any)
}
}
function listenToForcedWindowScroll(listener: () => void) {
emitter.addListener('forced-scroll', listener)
return () => {
emitter.removeListener('forced-scroll', listener)
}
}