Sync top/bottom bar disappearance to the scroll (#1855)

* Disable existing code that toggles shell

* Make shell mode a float

* Translate based on the gesture

* Track header and footer heights

* Add web support

* Fix types and cleanup

* Add back isScrolled logic

* Add comments
This commit is contained in:
dan 2023-11-09 20:15:05 +00:00 committed by GitHub
parent 1dcf882619
commit 7a55ca6133
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 183 additions and 107 deletions

View file

@ -1,45 +1,29 @@
import { import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
AnimatableValue,
interpolate,
useAnimatedStyle,
withTiming,
Easing,
} from 'react-native-reanimated'
import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode' import {useMinimalShellMode as useMinimalShellModeState} from '#/state/shell/minimal-mode'
import {useShellLayout} from '#/state/shell/shell-layout'
function withShellTiming<T extends AnimatableValue>(value: T): T {
'worklet'
return withTiming(value, {
duration: 125,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
}
export function useMinimalShellMode() { export function useMinimalShellMode() {
const mode = useMinimalShellModeState() const mode = useMinimalShellModeState()
const {footerHeight, headerHeight} = useShellLayout()
const footerMinimalShellTransform = useAnimatedStyle(() => { const footerMinimalShellTransform = useAnimatedStyle(() => {
return { return {
pointerEvents: mode.value ? 'none' : 'auto', pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: withShellTiming(interpolate(mode.value ? 1 : 0, [0, 1], [1, 0])), opacity: Math.pow(1 - mode.value, 2),
transform: [ transform: [
{ {
translateY: withShellTiming( translateY: interpolate(mode.value, [0, 1], [0, footerHeight.value]),
interpolate(mode.value ? 1 : 0, [0, 1], [0, 25]),
),
}, },
], ],
} }
}) })
const headerMinimalShellTransform = useAnimatedStyle(() => { const headerMinimalShellTransform = useAnimatedStyle(() => {
return { return {
pointerEvents: mode.value ? 'none' : 'auto', pointerEvents: mode.value === 0 ? 'auto' : 'none',
opacity: withShellTiming(interpolate(mode.value ? 1 : 0, [0, 1], [1, 0])), opacity: Math.pow(1 - mode.value, 2),
transform: [ transform: [
{ {
translateY: withShellTiming( translateY: interpolate(mode.value, [0, 1], [0, -headerHeight.value]),
interpolate(mode.value ? 1 : 0, [0, 1], [0, -25]),
),
}, },
], ],
} }
@ -48,9 +32,7 @@ export function useMinimalShellMode() {
return { return {
transform: [ transform: [
{ {
translateY: withShellTiming( translateY: interpolate(mode.value, [0, 1], [-44, 0]),
interpolate(mode.value ? 1 : 0, [0, 1], [-44, 0]),
),
}, },
], ],
} }

View file

@ -1,17 +1,19 @@
import {useState, useCallback, useRef} from 'react' import {useState, useCallback} from 'react'
import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native'
import {s} from 'lib/styles'
import {useWebMediaQueries} from './useWebMediaQueries'
import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {
useAnimatedScrollHandler,
useSharedValue,
interpolate,
runOnJS,
} from 'react-native-reanimated'
const Y_LIMIT = 10 function clamp(num: number, min: number, max: number) {
'worklet'
const useDeviceLimits = () => { return Math.min(Math.max(num, min), max)
const {isDesktop} = useWebMediaQueries()
return {
dyLimitUp: isDesktop ? 30 : 10,
dyLimitDown: isDesktop ? 150 : 10,
}
} }
export type OnScrollCb = ( export type OnScrollCb = (
@ -20,53 +22,82 @@ export type OnScrollCb = (
export type ResetCb = () => void export type ResetCb = () => void
export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] { export function useOnMainScroll(): [OnScrollCb, boolean, ResetCb] {
let lastY = useRef(0) const {headerHeight} = useShellLayout()
let [isScrolledDown, setIsScrolledDown] = useState(false) const [isScrolledDown, setIsScrolledDown] = useState(false)
const {dyLimitUp, dyLimitDown} = useDeviceLimits() const mode = useMinimalShellMode()
const minimalShellMode = useMinimalShellMode() const setMode = useSetMinimalShellMode()
const setMinimalShellMode = useSetMinimalShellMode() const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
return [ const scrollHandler = useAnimatedScrollHandler({
useCallback( onBeginDrag(e) {
(event: NativeSyntheticEvent<NativeScrollEvent>) => { startDragOffset.value = e.contentOffset.y
const y = event.nativeEvent.contentOffset.y startMode.value = mode.value
const dy = y - (lastY.current || 0) },
lastY.current = y onEndDrag(e) {
startDragOffset.value = null
if (!minimalShellMode.value && dy > dyLimitDown && y > Y_LIMIT) { startMode.value = null
setMinimalShellMode(true) if (e.contentOffset.y < headerHeight.value / 2) {
} else if ( // If we're close to the top, show the shell.
minimalShellMode.value && setMode(false)
(dy < dyLimitUp * -1 || y <= Y_LIMIT) } else {
) { // Snap to whichever state is the closest.
setMinimalShellMode(false) setMode(Math.round(mode.value) === 1)
}
if (
!isScrolledDown &&
event.nativeEvent.contentOffset.y > s.window.height
) {
setIsScrolledDown(true)
} else if (
isScrolledDown &&
event.nativeEvent.contentOffset.y < s.window.height
) {
setIsScrolledDown(false)
} }
}, },
[ onScroll(e) {
dyLimitDown, // Keep track of whether we want to show "scroll to top".
dyLimitUp, if (!isScrolledDown && e.contentOffset.y > s.window.height) {
isScrolledDown, runOnJS(setIsScrolledDown)(true)
minimalShellMode, } else if (isScrolledDown && e.contentOffset.y < s.window.height) {
setMinimalShellMode, runOnJS(setIsScrolledDown)(false)
], }
),
if (startDragOffset.value === null || startMode.value === null) {
if (mode.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
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag.
// When we get the first scroll event, we consider that the start.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
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 !== mode.value) {
// Manually adjust the value. This won't be (and shouldn't be) animated.
mode.value = newValue
}
if (isWeb) {
// On the web, there is no concept of "starting" the drag,
// so we don't have any specific anchor point to calculate the distance.
// Instead, update it continuosly along the way and diff with the last event.
startDragOffset.value = e.contentOffset.y
startMode.value = mode.value
}
},
})
return [
scrollHandler,
isScrolledDown, isScrolledDown,
useCallback(() => { useCallback(() => {
setIsScrolledDown(false) setIsScrolledDown(false)
setMinimalShellMode(false) setMode(false)
lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf }, [setMode]),
}, [setIsScrolledDown, setMinimalShellMode]),
] ]
} }

View file

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {Provider as ShellLayoutProvder} from './shell-layout'
import {Provider as DrawerOpenProvider} from './drawer-open' import {Provider as DrawerOpenProvider} from './drawer-open'
import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled' import {Provider as DrawerSwipableProvider} from './drawer-swipe-disabled'
import {Provider as MinimalModeProvider} from './minimal-mode' import {Provider as MinimalModeProvider} from './minimal-mode'
@ -16,6 +17,7 @@ export {useOnboardingState, useOnboardingDispatch} from './onboarding'
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
return ( return (
<ShellLayoutProvder>
<DrawerOpenProvider> <DrawerOpenProvider>
<DrawerSwipableProvider> <DrawerSwipableProvider>
<MinimalModeProvider> <MinimalModeProvider>
@ -25,5 +27,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
</MinimalModeProvider> </MinimalModeProvider>
</DrawerSwipableProvider> </DrawerSwipableProvider>
</DrawerOpenProvider> </DrawerOpenProvider>
</ShellLayoutProvder>
) )
} }

View file

@ -1,11 +1,16 @@
import React from 'react' import React from 'react'
import {useSharedValue, SharedValue} from 'react-native-reanimated' import {
Easing,
SharedValue,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
type StateContext = SharedValue<boolean> type StateContext = SharedValue<number>
type SetContext = (v: boolean) => void type SetContext = (v: boolean) => void
const stateContext = React.createContext<StateContext>({ const stateContext = React.createContext<StateContext>({
value: false, value: 0,
addListener() {}, addListener() {},
removeListener() {}, removeListener() {},
modify() {}, modify() {},
@ -13,10 +18,14 @@ const stateContext = React.createContext<StateContext>({
const setContext = React.createContext<SetContext>((_: boolean) => {}) const setContext = React.createContext<SetContext>((_: boolean) => {})
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const mode = useSharedValue(false) const mode = useSharedValue(0)
const setMode = React.useCallback( const setMode = React.useCallback(
(v: boolean) => { (v: boolean) => {
mode.value = v 'worklet'
mode.value = withTiming(v ? 1 : 0, {
duration: 400,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
})
}, },
[mode], [mode],
) )

View file

@ -0,0 +1,41 @@
import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
type StateContext = {
headerHeight: SharedValue<number>
footerHeight: SharedValue<number>
}
const stateContext = React.createContext<StateContext>({
headerHeight: {
value: 0,
addListener() {},
removeListener() {},
modify() {},
},
footerHeight: {
value: 0,
addListener() {},
removeListener() {},
modify() {},
},
})
export function Provider({children}: React.PropsWithChildren<{}>) {
const headerHeight = useSharedValue(0)
const footerHeight = useSharedValue(0)
const value = React.useMemo(
() => ({
headerHeight,
footerHeight,
}),
[headerHeight, footerHeight],
)
return <stateContext.Provider value={value}>{children}</stateContext.Provider>
}
export function useShellLayout() {
return React.useContext(stateContext)
}

View file

@ -182,7 +182,7 @@ export const FeedPage = observer(function FeedPageImpl({
feed={feed} feed={feed}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
onScroll={onMainScroll} onScroll={onMainScroll}
scrollEventThrottle={100} scrollEventThrottle={1}
renderEmptyState={renderEmptyState} renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed} renderEndOfFeed={renderEndOfFeed}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}

View file

@ -162,7 +162,7 @@ export const Feed = observer(function Feed({
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={0.6} onEndReachedThreshold={0.6}
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={100} scrollEventThrottle={1}
contentContainerStyle={s.contentContainer} contentContainerStyle={s.contentContainer}
// @ts-ignore our .web version only -prf // @ts-ignore our .web version only -prf
desktopFixedHeight desktopFixedHeight

View file

@ -10,6 +10,7 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useShellLayout} from '#/state/shell/shell-layout'
export const FeedsTabBar = observer(function FeedsTabBarImpl( export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
@ -31,11 +32,15 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl(
const items = useHomeTabs(store.preferences.pinnedFeeds) const items = useHomeTabs(store.preferences.pinnedFeeds)
const pal = usePalette('default') const pal = usePalette('default')
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()
const {headerHeight} = useShellLayout()
return ( return (
// @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
<Animated.View <Animated.View
style={[pal.view, styles.tabBar, headerMinimalShellTransform]}> style={[pal.view, styles.tabBar, headerMinimalShellTransform]}
onLayout={e => {
headerHeight.value = e.nativeEvent.layout.height
}}>
<TabBar <TabBar
key={items.join(',')} key={items.join(',')}
{...props} {...props}

View file

@ -18,6 +18,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import {useSetDrawerOpen} from '#/state/shell/drawer-open' import {useSetDrawerOpen} from '#/state/shell/drawer-open'
import {useShellLayout} from '#/state/shell/shell-layout'
export const FeedsTabBar = observer(function FeedsTabBarImpl( export const FeedsTabBar = observer(function FeedsTabBarImpl(
props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void},
@ -28,6 +29,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
const setDrawerOpen = useSetDrawerOpen() const setDrawerOpen = useSetDrawerOpen()
const items = useHomeTabs(store.preferences.pinnedFeeds) const items = useHomeTabs(store.preferences.pinnedFeeds)
const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3)
const {headerHeight} = useShellLayout()
const {headerMinimalShellTransform} = useMinimalShellMode() const {headerMinimalShellTransform} = useMinimalShellMode()
const onPressAvi = React.useCallback(() => { const onPressAvi = React.useCallback(() => {
@ -36,12 +38,10 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl(
return ( return (
<Animated.View <Animated.View
style={[ style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
pal.view, onLayout={e => {
pal.border, headerHeight.value = e.nativeEvent.layout.height
styles.tabBar, }}>
headerMinimalShellTransform,
]}>
<View style={[pal.view, styles.topBar]}> <View style={[pal.view, styles.topBar]}>
<View style={[pal.view]}> <View style={[pal.view]}>
<TouchableOpacity <TouchableOpacity

View file

@ -27,6 +27,7 @@ import {UserAvatar} from 'view/com/util/UserAvatar'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useShellLayout} from '#/state/shell/shell-layout'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
@ -39,6 +40,7 @@ export const BottomBar = observer(function BottomBarImpl({
const {_} = useLingui() const {_} = useLingui()
const safeAreaInsets = useSafeAreaInsets() const safeAreaInsets = useSafeAreaInsets()
const {track} = useAnalytics() const {track} = useAnalytics()
const {footerHeight} = useShellLayout()
const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} = const {isAtHome, isAtSearch, isAtFeeds, isAtNotifications, isAtMyProfile} =
useNavigationTabState() useNavigationTabState()
@ -88,7 +90,10 @@ export const BottomBar = observer(function BottomBarImpl({
pal.border, pal.border,
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)}, {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
footerMinimalShellTransform, footerMinimalShellTransform,
]}> ]}
onLayout={e => {
footerHeight.value = e.nativeEvent.layout.height
}}>
<Btn <Btn
testID="bottomBarHomeBtn" testID="bottomBarHomeBtn"
icon={ icon={