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:
+27
-24
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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