Sync the bottom bar to screen transitions and scroll
## Summary - Hook the bottom bar up to react-native-screens' transition progress. Every `Layout.Screen` now exposes a `presence` shared value (via `useScreenPresence`) that follows the native push/pop/swipe frame by frame, so screens with `minimalShell` hide and reveal the bar in sync with the transition instead of after it. Fixes the delayed tab bar when leaving a conversation. - Replace the minimal-shell and hide-border refcounts with a small registry that sums 0..1 contributions on the UI thread. The bottom bar border now fades with the screen rather than toggling. - Bring back the scroll-linked bottom bar on Home (removed in #5203). The header and footer share the same drag-linked value, and the bar is revealed on feed change, blur, and app foreground so badges aren't missed. Web included. ## Test plan - [ ] Swipe back from a conversation: bar tracks the swipe, including a cancelled swipe - [ ] Back button from a conversation: bar animates with the pop - [ ] Push a profile over a conversation and pop back - [ ] Post thread: bottom bar border fades in/out with the screen - [ ] Home: scroll down hides header and bar together, scroll up shows them - [ ] Home: scrolled down, swipe into a convo and back - no jump - [ ] Home: bar reappears on feed switch, on leaving Home, and on foregrounding the app - [ ] Deep link / notification into a conversation from another tab - [ ] Mobile web: bar hides/shows on scroll and on conversation screen - [ ] Rapid swipe-back and swipe-back while scrolling (software-mansion/react-native-reanimated#9402, software-mansion/react-native-screens#2659) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JWQciPDiipBhqiW922Nivs
This commit is contained in:
@@ -13,6 +13,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {EventEmitter} from 'eventemitter3'
|
||||
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {useMinimalShellScrollMode} from '#/state/shell/minimal-mode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env'
|
||||
|
||||
@@ -95,6 +96,7 @@ export function useHomeHeaderTransform() {
|
||||
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const headerMode = useHomeHeaderMode()
|
||||
const footerScrollMode = useMinimalShellScrollMode()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0
|
||||
const startDragOffset = useSharedValue<number | null>(null)
|
||||
@@ -104,14 +106,14 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
const setMode = useCallback(
|
||||
(v: boolean) => {
|
||||
'worklet'
|
||||
headerMode.set(
|
||||
withSpring(v ? 1 : 0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
const target = withSpring(v ? 1 : 0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
})
|
||||
headerMode.set(target)
|
||||
footerScrollMode.set(target)
|
||||
},
|
||||
[headerMode],
|
||||
[headerMode, footerScrollMode],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -216,6 +218,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
if (newValue !== headerMode.get()) {
|
||||
// Manually adjust the value. This won't be (and shouldn't be) animated.
|
||||
headerMode.set(newValue)
|
||||
footerScrollMode.set(newValue)
|
||||
}
|
||||
} else {
|
||||
if (didJustRestoreScroll.get()) {
|
||||
@@ -239,6 +242,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
headerHeight,
|
||||
headerPinnedHeight,
|
||||
headerMode,
|
||||
footerScrollMode,
|
||||
setMode,
|
||||
startDragOffset,
|
||||
startMode,
|
||||
|
||||
+29
-15
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'
|
||||
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||
import {ActivityIndicator, AppState, StyleSheet} from 'react-native'
|
||||
import {
|
||||
Reanimated3DefaultSpringConfig,
|
||||
withSpring,
|
||||
@@ -31,6 +31,7 @@ import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {type UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||
import {useMinimalShellScrollMode} from '#/state/shell/minimal-mode'
|
||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {FeedPage} from '#/view/com/feeds/FeedPage'
|
||||
import {HomeHeader} from '#/view/com/home/HomeHeader'
|
||||
@@ -156,20 +157,33 @@ function HomeScreenReady({
|
||||
|
||||
const {hasSession} = useSession()
|
||||
const headerMode = useHomeHeaderMode()
|
||||
const showHeader = useCallback(() => {
|
||||
const footerScrollMode = useMinimalShellScrollMode()
|
||||
const showShell = useCallback(() => {
|
||||
'worklet'
|
||||
headerMode.set(
|
||||
withSpring(0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
}, [headerMode])
|
||||
const shown = withSpring(0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
})
|
||||
headerMode.set(shown)
|
||||
footerScrollMode.set(shown)
|
||||
}, [headerMode, footerScrollMode])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
return () => showHeader()
|
||||
}, [showHeader]),
|
||||
return () => showShell()
|
||||
}, [showShell]),
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Reveal the bars on foreground so you don't miss notifications or messages.
|
||||
const listener = AppState.addEventListener('change', nextAppState => {
|
||||
if (nextAppState === 'active') {
|
||||
showShell()
|
||||
}
|
||||
})
|
||||
return () => listener.remove()
|
||||
}, [showShell]),
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
@@ -187,7 +201,7 @@ function HomeScreenReady({
|
||||
|
||||
const onPageSelected = useCallback(
|
||||
(index: number) => {
|
||||
showHeader()
|
||||
showShell()
|
||||
const maybeFeed = allFeeds[index]
|
||||
|
||||
// Mutate the ref before setting state to avoid the imperative syncing effect
|
||||
@@ -203,7 +217,7 @@ function HomeScreenReady({
|
||||
})
|
||||
}
|
||||
},
|
||||
[ax, setSelectedFeed, showHeader, allFeeds],
|
||||
[ax, setSelectedFeed, showShell, allFeeds],
|
||||
)
|
||||
|
||||
const onPressSelected = useCallback(() => {
|
||||
@@ -214,10 +228,10 @@ function HomeScreenReady({
|
||||
(state: 'idle' | 'dragging' | 'settling') => {
|
||||
'worklet'
|
||||
if (state === 'dragging') {
|
||||
showHeader()
|
||||
showShell()
|
||||
}
|
||||
},
|
||||
[showHeader],
|
||||
[showShell],
|
||||
)
|
||||
|
||||
const [demoMode] = useDemoMode()
|
||||
|
||||
@@ -11,7 +11,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
|
||||
import {BOTTOM_BAR_AVI} from '#/lib/demo'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useDedupe} from '#/lib/hooks/useDedupe'
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useBottomBarBorderStyle} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {useNavigationTabState} from '#/lib/hooks/useNavigationTabState'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
@@ -80,7 +80,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
const accountSwitchControl = useDialogControl()
|
||||
const messagesMenuControl = Menu.useMenuControl()
|
||||
const playHaptic = useHaptics()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const borderStyle = useBottomBarBorderStyle()
|
||||
const iconWidth = 28
|
||||
|
||||
const showSignIn = useCallback(() => {
|
||||
@@ -165,9 +165,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
style={[
|
||||
styles.bottomBar,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
borderStyle,
|
||||
{paddingBottom: clamp(safeAreaInsets.bottom, 15, 60)},
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigationState} from '@react-navigation/native'
|
||||
|
||||
import {useHideBottomBarBorder} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useBottomBarBorderStyle} from '#/lib/hooks/useHideBottomBarBorder'
|
||||
import {useMinimalShellFooterTransform} from '#/lib/hooks/useMinimalShellTransform'
|
||||
import {getCurrentRoute, isTab} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
@@ -57,7 +57,7 @@ export function BottomBarWeb() {
|
||||
const {requestSwitchToAccount} = useLoggedOutViewControls()
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const hideBorder = useHideBottomBarBorder()
|
||||
const borderStyle = useBottomBarBorderStyle()
|
||||
const accountSwitchControl = useDialogControl()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const iconWidth = 26
|
||||
@@ -92,9 +92,7 @@ export function BottomBarWeb() {
|
||||
styles.bottomBar,
|
||||
styles.bottomBarWeb,
|
||||
t.atoms.bg,
|
||||
hideBorder
|
||||
? {borderColor: t.atoms.bg.backgroundColor}
|
||||
: t.atoms.border_contrast_low,
|
||||
borderStyle,
|
||||
footerMinimalShellTransform,
|
||||
]}
|
||||
onLayout={event => footerHeight.set(event.nativeEvent.layout.height)}>
|
||||
|
||||
Reference in New Issue
Block a user