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:
Claude
2026-09-07 19:40:57 +00:00
parent 08069e2877
commit 2dc7071e7d
12 changed files with 525 additions and 152 deletions
+11 -7
View File
@@ -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
View File
@@ -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()
+3 -5
View File
@@ -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,
]}
+3 -5
View File
@@ -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)}>