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
+27 -24
View File
@@ -28,6 +28,7 @@ import {
notificationToURL,
storePayloadForAccountSwitch,
} from '#/lib/hooks/useNotificationHandler'
import {ScreenTransitionProvider} from '#/lib/hooks/useScreenPresence'
import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration'
import {useCallOnce} from '#/lib/once'
import {buildStateObject, getCurrentRoute} from '#/lib/routes/helpers'
@@ -1052,30 +1053,32 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
})
return (
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
const currentScreen = getCurrentRouteName()
// do this before metric
setNavigationMetadata({
previousScreen: previousScreen.current,
currentScreen,
})
ax.metric('router:navigate', {from: previousScreen.current})
previousScreen.current = currentScreen
}}
onReady={onNavigationReady}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn
navigationInChildEnabled>
{children}
</NavigationContainer>
<ScreenTransitionProvider>
<NavigationContainer
ref={navigationRef}
linking={LINKING}
theme={theme}
onStateChange={() => {
const currentScreen = getCurrentRouteName()
// do this before metric
setNavigationMetadata({
previousScreen: previousScreen.current,
currentScreen,
})
ax.metric('router:navigate', {from: previousScreen.current})
previousScreen.current = currentScreen
}}
onReady={onNavigationReady}
// WARNING: Implicit navigation to nested navigators is depreciated in React Navigation 7.x
// However, there's a fair amount of places we do that, especially in when popping to the top of stacks.
// See BottomBar.tsx for an example of how to handle nested navigators in the tabs correctly.
// I'm scared of missing a spot (esp. with push notifications etc) so let's enable this legacy behaviour for now.
// We will need to confirm we handle nested navigators correctly by the time we migrate to React Navigation 8.x
// -sfn
navigationInChildEnabled>
{children}
</NavigationContainer>
</ScreenTransitionProvider>
)
}
+13 -4
View File
@@ -12,6 +12,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ScreenPresenceProvider} from '#/lib/hooks/useScreenPresence'
import {useEnableMinimalShellModeForScreen} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
@@ -52,10 +53,9 @@ export const Screen = memo(function Screen({
const {top} = useSafeAreaInsets()
const {isWithinSplitView} = useIsWithinSplitView()
useEnableMinimalShellModeForScreen({enabled: minimalShell})
return (
<>
<ScreenPresenceProvider>
<MinimalShell enabled={minimalShell} />
{IS_WEB && !isWithinSplitView && <WebCenterBorders />}
<View
style={[
@@ -66,10 +66,19 @@ export const Screen = memo(function Screen({
]}
{...props}
/>
</>
</ScreenPresenceProvider>
)
})
/**
* Rendered inside the presence provider so the shell follows this screen's
* transition rather than its focus state.
*/
function MinimalShell({enabled}: {enabled: boolean}) {
useEnableMinimalShellModeForScreen({enabled})
return null
}
export type ContentProps = AnimatedScrollViewProps & {
style?: StyleProp<ViewStyle>
contentContainerStyle?: StyleProp<ViewStyle>
+71
View File
@@ -0,0 +1,71 @@
import {useCallback, useState} from 'react'
import {
clamp,
Reanimated3DefaultSpringConfig,
type SharedValue,
useDerivedValue,
withSpring,
} from 'react-native-reanimated'
import {scheduleOnRN} from 'react-native-worklets'
type Contribution = {
id: number
value: SharedValue<number>
}
let nextId = 0
/**
* Sums any number of 0..1 shared values (plus an optional base value) into a
* single derived value clamped to 0..1, entirely on the UI thread.
*
* This replaces boolean refcounting for shell state: each contributor owns a
* shared value it can drive however it likes (a spring, a scroll gesture, a
* screen transition), and the sum is what the shell renders. Contributions
* are summed rather than maxed so that two overlapping contributors (e.g. one
* screen transitioning out while another transitions in) hold the total at 1
* instead of dipping in the middle.
*/
export function useContributionRegistry(base?: SharedValue<number>) {
const [contributions, setContributions] = useState<Contribution[]>([])
const total = useDerivedValue(() => {
let sum = base ? base.get() : 0
for (const contribution of contributions) {
sum += contribution.value.get()
}
return clamp(sum, 0, 1)
}, [contributions, base])
const remove = useCallback((id: number) => {
setContributions(prev => prev.filter(c => c.id !== id))
}, [])
/**
* Adds a contribution and returns a function to remove it. Removal animates
* the value to 0 first so that a contributor unmounting while still active
* (e.g. a screen removed without a transition) does not snap the total.
*/
const register = useCallback(
(value: SharedValue<number>) => {
const id = nextId++
setContributions(prev => [...prev, {id, value}])
return () => {
value.set(
withSpring(
0,
{...Reanimated3DefaultSpringConfig, overshootClamping: true},
finished => {
if (finished) {
scheduleOnRN(remove, id)
}
},
),
)
}
},
[remove],
)
return {total, register}
}
+69 -25
View File
@@ -1,51 +1,95 @@
import {createContext, useCallback, useContext, useState} from 'react'
import {useFocusEffect} from '@react-navigation/native'
import {createContext, useContext, useEffect} from 'react'
import {
type DerivedValue,
interpolateColor,
type SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
type HideBottomBarBorderSetter = () => () => void
import {useContributionRegistry} from '#/lib/hooks/useContributionRegistry'
import {useScreenPresence} from '#/lib/hooks/useScreenPresence'
import {useTheme} from '#/alf'
const HideBottomBarBorderContext = createContext<boolean>(false)
type Register = (contribution: SharedValue<number>) => () => void
const HideBottomBarBorderContext = createContext<DerivedValue<number> | null>(
null,
)
HideBottomBarBorderContext.displayName = 'HideBottomBarBorderContext'
const HideBottomBarBorderSetterContext =
createContext<HideBottomBarBorderSetter | null>(null)
const HideBottomBarBorderSetterContext = createContext<Register | null>(null)
HideBottomBarBorderSetterContext.displayName =
'HideBottomBarBorderSetterContext'
export function useHideBottomBarBorderSetter() {
const hideBottomBarBorder = useContext(HideBottomBarBorderSetterContext)
if (!hideBottomBarBorder) {
function useHideBottomBarBorderSetter() {
const register = useContext(HideBottomBarBorderSetterContext)
if (!register) {
throw new Error(
'useHideBottomBarBorderSetter must be used within a HideBottomBarBorderProvider',
)
}
return hideBottomBarBorder
return register
}
/**
* Hides the bottom bar's top border while the surrounding screen is present,
* fading it with the screen transition.
*/
export function useHideBottomBarBorderForScreen() {
const hideBorder = useHideBottomBarBorderSetter()
const register = useHideBottomBarBorderSetter()
const {presence} = useScreenPresence()
const contribution = useSharedValue(0)
useFocusEffect(
useCallback(() => {
const cleanup = hideBorder()
return () => cleanup()
}, [hideBorder]),
useAnimatedReaction(
() => presence.get(),
(current, previous) => {
if (current !== previous) {
contribution.set(current)
}
},
)
useEffect(() => register(contribution), [register, contribution])
}
/**
* How hidden the bottom bar border is, 0 (visible) to 1 (hidden).
*/
export function useHideBottomBarBorder() {
return useContext(HideBottomBarBorderContext)
const value = useContext(HideBottomBarBorderContext)
if (!value) {
throw new Error(
'useHideBottomBarBorder must be used within a HideBottomBarBorderProvider',
)
}
return value
}
/**
* Animated border color for the bottom bar, blending the border into the
* background as screens that hide it come and go.
*/
export function useBottomBarBorderStyle() {
const t = useTheme()
const hideBorder = useHideBottomBarBorder()
const visibleColor = t.atoms.border_contrast_low.borderColor
const hiddenColor = t.atoms.bg.backgroundColor
return useAnimatedStyle(() => ({
borderColor: interpolateColor(
hideBorder.get(),
[0, 1],
[visibleColor, hiddenColor],
),
}))
}
export function Provider({children}: {children: React.ReactNode}) {
const [refCount, setRefCount] = useState(0)
const setter = useCallback(() => {
setRefCount(prev => prev + 1)
return () => setRefCount(prev => prev - 1)
}, [])
const {total, register} = useContributionRegistry()
return (
<HideBottomBarBorderSetterContext.Provider value={setter}>
<HideBottomBarBorderContext.Provider value={refCount > 0}>
<HideBottomBarBorderSetterContext.Provider value={register}>
<HideBottomBarBorderContext.Provider value={total}>
{children}
</HideBottomBarBorderContext.Provider>
</HideBottomBarBorderSetterContext.Provider>
@@ -0,0 +1,30 @@
import {createContext, useContext} from 'react'
import {type SharedValue, useSharedValue} from 'react-native-reanimated'
export type ScreenPresence = {
/**
* How visible this screen is on screen, 0..1, tracking the native stack
* transition frame by frame (including interactive swipe-back). Always 1 on
* web and for screens that are not inside a native stack.
*/
visibility: SharedValue<number>
/**
* `visibility`, but also 0 when the screen's tab (or any other ancestor
* navigator) is not focused. This is the value shell UI should follow: it is
* 1 exactly when the screen is what the user is looking at.
*/
presence: SharedValue<number>
}
export const ScreenPresenceContext = createContext<ScreenPresence | null>(null)
ScreenPresenceContext.displayName = 'ScreenPresenceContext'
/**
* Reads the presence of the nearest `Layout.Screen`. Outside of one, both
* values are a constant 1.
*/
export function useScreenPresence(): ScreenPresence {
const context = useContext(ScreenPresenceContext)
const fallback = useSharedValue(1)
return context ?? {visibility: fallback, presence: fallback}
}
+151
View File
@@ -0,0 +1,151 @@
import {useContext, useEffect} from 'react'
import {
Reanimated3DefaultSpringConfig,
type SharedValue,
useDerivedValue,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {
ReanimatedScreenProvider,
useReanimatedTransitionProgress,
} from 'react-native-screens/reanimated'
import {
NavigationContext,
type NavigationProp,
type ParamListBase,
} from '@react-navigation/native'
import {ScreenPresenceContext} from './context'
export {type ScreenPresence, useScreenPresence} from './context'
/**
* Swaps react-native-screens' screen implementation for one that pipes the
* native transition progress event into Reanimated shared values on the UI
* thread. Must wrap the navigation tree.
*/
export function ScreenTransitionProvider({
children,
}: {
children: React.ReactNode
}) {
return <ReanimatedScreenProvider>{children}</ReanimatedScreenProvider>
}
/**
* Provides `useScreenPresence()` to a screen's subtree. Rendered by
* `Layout.Screen`.
*/
export function ScreenPresenceProvider({
children,
}: {
children: React.ReactNode
}) {
const navigation = useContext(NavigationContext)
/*
* The transition progress context only exists for routes rendered by a
* native stack navigator, and the hook that reads it throws otherwise. Every
* stack navigator in the app is a native stack, so the navigator type tells
* us whether it is safe to read.
*/
const isNativeStackRoute = navigation?.getState().type === 'stack'
if (isNativeStackRoute) {
return (
<NativeStackScreenPresenceProvider navigation={navigation}>
{children}
</NativeStackScreenPresenceProvider>
)
}
return <StaticScreenPresenceProvider>{children}</StaticScreenPresenceProvider>
}
function NativeStackScreenPresenceProvider({
navigation,
children,
}: {
navigation: NavigationProp<ParamListBase>
children: React.ReactNode
}) {
/*
* react-native-screens types these with `Animated.SharedValue`, a Reanimated
* 3 alias that no longer exists in Reanimated 4, so the values come through
* untyped.
*/
const {progress, closing} = useReanimatedTransitionProgress() as {
progress: SharedValue<number>
closing: SharedValue<number>
}
const ancestorsFocused = useAncestorsFocused(navigation)
const visibility = useDerivedValue(() => {
const value = progress.get()
return closing.get() ? 1 - value : value
})
const presence = useDerivedValue(() =>
Math.min(visibility.get(), ancestorsFocused.get()),
)
return (
<ScreenPresenceContext.Provider value={{visibility, presence}}>
{children}
</ScreenPresenceContext.Provider>
)
}
function StaticScreenPresenceProvider({children}: {children: React.ReactNode}) {
const one = useSharedValue(1)
return (
<ScreenPresenceContext.Provider value={{visibility: one, presence: one}}>
{children}
</ScreenPresenceContext.Provider>
)
}
/**
* Whether every navigator above this screen has it in focus, as a 0/1 shared
* value that springs between the two. Stack transitions are covered by the
* transition progress, so this only needs to react to changes that do not
* animate a native stack, i.e. switching tabs.
*/
function useAncestorsFocused(navigation: NavigationProp<ParamListBase>) {
const focused = useSharedValue(areAncestorsFocused(navigation) ? 1 : 0)
useEffect(() => {
const ancestors = getAncestors(navigation)
if (ancestors.length === 0) return
const update = () => {
focused.set(
withSpring(areAncestorsFocused(navigation) ? 1 : 0, {
...Reanimated3DefaultSpringConfig,
overshootClamping: true,
}),
)
}
update()
const unsubscribes = ancestors.flatMap(ancestor => [
ancestor.addListener('focus', update),
ancestor.addListener('blur', update),
])
return () => unsubscribes.forEach(unsubscribe => unsubscribe())
}, [navigation, focused])
return focused
}
function getAncestors(navigation: NavigationProp<ParamListBase>) {
const ancestors: NavigationProp<ParamListBase>[] = []
let parent = navigation.getParent()
while (parent) {
ancestors.push(parent)
parent = parent.getParent()
}
return ancestors
}
function areAncestorsFocused(navigation: NavigationProp<ParamListBase>) {
// isFocused() already walks up the tree, so the nearest ancestor is enough
const parent = navigation.getParent()
return parent ? parent.isFocused() : true
}
@@ -0,0 +1,53 @@
import {useCallback} from 'react'
import {
Reanimated3DefaultSpringConfig,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {ScreenPresenceContext} from './context'
export {type ScreenPresence, useScreenPresence} from './context'
/**
* No-op on web: there are no native stack transitions to track.
*/
export function ScreenTransitionProvider({
children,
}: {
children: React.ReactNode
}) {
return children
}
/**
* On web, screens do not animate in or out, so presence is simply focus, with
* a spring so that dependent shell UI still animates.
*/
export function ScreenPresenceProvider({
children,
}: {
children: React.ReactNode
}) {
const visibility = useSharedValue(1)
const presence = useSharedValue(0)
useFocusEffect(
useCallback(() => {
const spring = (to: number) =>
withSpring(to, {
...Reanimated3DefaultSpringConfig,
overshootClamping: true,
})
presence.set(spring(1))
return () => presence.set(spring(0))
}, [presence]),
)
return (
<ScreenPresenceContext.Provider value={{visibility, presence}}>
{children}
</ScreenPresenceContext.Provider>
)
}
+65 -67
View File
@@ -1,25 +1,29 @@
import {createContext, useContext, useEffect, useMemo} from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react'
import {
type DerivedValue,
Reanimated3DefaultSpringConfig,
type SharedValue,
useAnimatedReaction,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native'
import {useContributionRegistry} from '#/lib/hooks/useContributionRegistry'
import {useScreenPresence} from '#/lib/hooks/useScreenPresence'
type StateContext = {
footerMode: SharedValue<number>
/**
* How hidden the bottom bar is, 0 (fully visible) to 1 (fully hidden). The
* sum of `scrollMode` and every active screen contribution, clamped.
*/
footerMode: DerivedValue<number>
/**
* The scroll-linked part of `footerMode`, driven by `MainScrollProvider`.
*/
scrollMode: SharedValue<number>
}
type SetContext = {
add: () => void
subtract: () => void
register: (contribution: SharedValue<number>) => () => void
}
const stateContext = createContext<StateContext | null>(null)
@@ -28,50 +32,13 @@ const setContext = createContext<SetContext | null>(null)
setContext.displayName = 'MinimalModeSetContext'
export function Provider({children}: React.PropsWithChildren<{}>) {
const footerMode = useSharedValue(0)
const setModeWorklet = useCallback(
(v: boolean) => {
'worklet'
footerMode.set(
withSpring(v ? 1 : 0, {
...Reanimated3DefaultSpringConfig,
overshootClamping: true,
}),
)
},
[footerMode],
)
// defaults to "visible", if the count is >0 it gets hidden
const countRef = useRef(0)
const add = useCallback(() => {
// 0 -> 1 = hide
if (countRef.current === 0) setModeWorklet(true)
countRef.current += 1
}, [setModeWorklet])
const subtract = useCallback(() => {
// 1 -> 0 = show
if (countRef.current === 1) setModeWorklet(false)
// count must never go below 0
if (countRef.current > 0) countRef.current -= 1
}, [setModeWorklet])
const setters = useMemo(
() => ({
add,
subtract,
}),
[add, subtract],
)
const scrollMode = useSharedValue(0)
const {total: footerMode, register} = useContributionRegistry(scrollMode)
const setters = useMemo(() => ({register}), [register])
const value = useMemo(
() => ({
footerMode,
}),
[footerMode],
() => ({footerMode, scrollMode}),
[footerMode, scrollMode],
)
return (
<stateContext.Provider value={value}>
@@ -89,6 +56,14 @@ export function useMinimalShellMode() {
return context
}
/**
* The scroll-linked part of the bottom bar's hidden state, for scroll
* providers to drive directly. Reset to 0 to reveal the bar.
*/
export function useMinimalShellScrollMode() {
return useMinimalShellMode().scrollMode
}
export function useMinimalShellModeSetters() {
const context = useContext(setContext)
if (!context)
@@ -98,26 +73,49 @@ export function useMinimalShellModeSetters() {
return context
}
/**
* Hides the bottom bar for as long as the calling component is mounted and
* `enabled`, independent of navigation. Prefer `Layout.Screen`'s
* `minimalShell` prop for screens, which tracks the screen transition.
*/
export function useEnableMinimalShellMode({enabled} = {enabled: true}) {
const setters = useMinimalShellModeSetters()
const {register} = useMinimalShellModeSetters()
const contribution = useSharedValue(0)
useEffect(() => {
if (enabled) {
setters.add()
return () => setters.subtract()
}
}, [enabled, setters])
if (!enabled) return
const unregister = register(contribution)
contribution.set(
withSpring(1, {
...Reanimated3DefaultSpringConfig,
overshootClamping: true,
}),
)
return unregister
}, [enabled, register, contribution])
}
/**
* Hides the bottom bar while the surrounding screen is present, following its
* transition in and out frame by frame. Used by `Layout.Screen`.
*/
export function useEnableMinimalShellModeForScreen(
{enabled} = {enabled: true},
) {
const setters = useMinimalShellModeSetters()
useFocusEffect(
useCallback(() => {
if (enabled) {
setters.add()
return () => setters.subtract()
const {register} = useMinimalShellModeSetters()
const {presence} = useScreenPresence()
const contribution = useSharedValue(0)
useAnimatedReaction(
() => presence.get(),
(current, previous) => {
if (current !== previous) {
contribution.set(current)
}
}, [enabled, setters]),
},
)
useEffect(() => {
if (!enabled) return
return register(contribution)
}, [enabled, register, contribution])
}
+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)}>