From 545304436810692c743f3aedd3bfe349c1f846ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 22:07:03 +0000 Subject: [PATCH] Hold the compose pill steady between two screens that both show it Tapping back pops the route from JS, so react-native-screens swaps the outgoing screen for a snapshot for the pop animation and it stops reporting transition progress. The pill entry for that screen was then faded out on its own spring, which ran ahead of the native pop, so the summed visibility dipped below the glass threshold and the pill dissolved and re-materialized during every pop between two pill screens. Swipe-back never showed it because the screen stays mounted through the gesture. Add a screen coverage registry to the presence system: the summed presence of every mounted native-stack screen, which is exactly 1 at rest and while all screens in a transition are still mounted. A pill entry that has left fills in whatever coverage is missing, which is the incoming screen's complement, so the total stays pinned at 1 between two pill screens and follows the real transition when leaving for a screen without one. Its own spring remains as a floor for a screen whose config switches off while still on screen. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9 --- src/App.tsx | 31 +++---- src/App.web.tsx | 23 ++--- src/features/composePrompt/context.tsx | 63 +++++++++----- src/lib/hooks/useScreenPresence/context.tsx | 83 ++++++++++++++++++- src/lib/hooks/useScreenPresence/index.tsx | 10 ++- src/lib/hooks/useScreenPresence/index.web.tsx | 10 ++- 6 files changed, 168 insertions(+), 52 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0589d2e7c0..5b2a56b2d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' +import {ScreenCoverageProvider} from '#/lib/hooks/useScreenPresence' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' @@ -173,20 +174,22 @@ function InnerApp() { - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/App.web.tsx b/src/App.web.tsx index d2ddb443e8..aad878c56f 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -7,6 +7,7 @@ import {SafeAreaProvider} from 'react-native-safe-area-context' import {useLingui} from '@lingui/react/macro' import * as Sentry from '@sentry/react-native' +import {ScreenCoverageProvider} from '#/lib/hooks/useScreenPresence' import {Provider as HotkeysProvider} from '#/lib/hotkeys' import {QueryProvider} from '#/lib/react-query' import {ThemeProvider} from '#/lib/ThemeContext' @@ -154,16 +155,18 @@ function InnerApp() { - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/features/composePrompt/context.tsx b/src/features/composePrompt/context.tsx index d2d8b32537..4088ef2c48 100644 --- a/src/features/composePrompt/context.tsx +++ b/src/features/composePrompt/context.tsx @@ -20,7 +20,10 @@ import {scheduleOnRN} from 'react-native-worklets' import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' -import {useScreenPresence} from '#/lib/hooks/useScreenPresence' +import { + useScreenCoverage, + useScreenPresence, +} from '#/lib/hooks/useScreenPresence' import {type ComposerOpts} from '#/state/shell/composer' export type ComposePromptOpenOptions = Pick< @@ -49,15 +52,22 @@ export type ComposePromptConfig = { type Entry = { id: number /** - * The registering screen's own presence, 0..1, read directly so that two - * screens mid-transition are always sampled on the same frame. + * The registering screen's own presence, 0..1. */ presence: SharedValue /** - * 1 while registered, sprung to 0 on unregister so a screen that leaves - * without a transition still fades the pill out. + * 1 once the screen has unregistered but is still fading out. A screen that + * is popped from JS is unmounted at the start of the pop and replaced by a + * native snapshot, so its own presence stops updating; while leaving, the + * entry instead contributes whatever share of the screen no mounted screen + * covers yet, which is exactly the incoming screen's complement. */ - weight: SharedValue + leaving: SharedValue + /** + * Springs 1 to 0 after unregistering. Fades a screen that leaves while + * still fully on screen (e.g. its config switched off) and times removal. + */ + fade: SharedValue config: ComposePromptConfig } @@ -77,7 +87,8 @@ type StateContext = { type ActionsContext = { register: ( presence: SharedValue, - weight: SharedValue, + leaving: SharedValue, + fade: SharedValue, config: ComposePromptConfig, ) => number update: (id: number, config: ComposePromptConfig) => void @@ -94,14 +105,15 @@ let nextId = 0 export function Provider({children}: {children: React.ReactNode}) { const [entries, setEntries] = useState([]) const [activeId, setActiveId] = useState(null) + const coverage = useScreenCoverage() const visibility = useDerivedValue(() => { let sum = 0 for (const entry of entries) { - sum += entry.presence.get() * entry.weight.get() + sum += entryPresence(entry, coverage.get()) } return clamp(sum, 0, 1) - }, [entries]) + }, [entries, coverage]) /* * The pill shows the label of whichever screen is the most present, so @@ -112,14 +124,14 @@ export function Provider({children}: {children: React.ReactNode}) { let best: number | null = null let bestPresence = 0 for (const entry of entries) { - const presence = entry.presence.get() * entry.weight.get() + const presence = entryPresence(entry, coverage.get()) if (presence > bestPresence) { best = entry.id bestPresence = presence } } return best - }, [entries]) + }, [entries, coverage]) useAnimatedReaction( () => mostPresentId.get(), @@ -133,9 +145,9 @@ export function Provider({children}: {children: React.ReactNode}) { const actions = useMemo( () => ({ - register(presence, weight, config) { + register(presence, leaving, fade, config) { const id = nextId++ - setEntries(prev => [...prev, {id, presence, weight, config}]) + setEntries(prev => [...prev, {id, presence, leaving, fade, config}]) return id }, update(id, config) { @@ -167,6 +179,14 @@ export function Provider({children}: {children: React.ReactNode}) { ) } +function entryPresence(entry: Entry, coverage: number) { + 'worklet' + if (!entry.leaving.get()) { + return entry.presence.get() + } + return Math.max(1 - coverage, entry.presence.get() * entry.fade.get()) +} + /** * What the pill should render right now. Used by the bottom bars. */ @@ -198,7 +218,8 @@ function useComposePromptActions() { export function useComposePromptForScreen(config: ComposePromptConfig | null) { const {register, update, unregister} = useComposePromptActions() const {presence} = useScreenPresence() - const weight = useSharedValue(0) + const leaving = useSharedValue(0) + const fade = useSharedValue(1) const idRef = useRef(null) const enabled = config !== null @@ -210,16 +231,14 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) { useEffect(() => { const initial = getConfig() if (!initial) return - weight.set(1) - const id = register(presence, weight, initial) + leaving.set(0) + fade.set(1) + const id = register(presence, leaving, fade, initial) idRef.current = id return () => { idRef.current = null - /* - * Fade out before removing so that a screen removed without a - * transition (or a config switched off) does not snap the pill away. - */ - weight.set( + leaving.set(1) + fade.set( withSpring(0, SHELL_SPRING_CONFIG, finished => { if (finished) { scheduleOnRN(unregister, id) @@ -227,7 +246,7 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) { }), ) } - }, [enabled, register, unregister, weight, presence]) + }, [enabled, register, unregister, leaving, fade, presence]) const label = config?.label const accessibilityLabel = config?.accessibilityLabel diff --git a/src/lib/hooks/useScreenPresence/context.tsx b/src/lib/hooks/useScreenPresence/context.tsx index a7368bc074..fa6e8898b3 100644 --- a/src/lib/hooks/useScreenPresence/context.tsx +++ b/src/lib/hooks/useScreenPresence/context.tsx @@ -1,5 +1,10 @@ -import {createContext, useContext} from 'react' -import {type SharedValue, useSharedValue} from 'react-native-reanimated' +import {createContext, useContext, useEffect, useMemo, useState} from 'react' +import { + type DerivedValue, + type SharedValue, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated' export type ScreenPresence = { /** @@ -28,3 +33,77 @@ export function useScreenPresence(): ScreenPresence { const fallback = useSharedValue(1) return context ?? {visibility: fallback, presence: fallback} } + +type CoverageEntry = { + id: number + presence: SharedValue +} + +const ScreenCoverageContext = createContext | null>(null) +ScreenCoverageContext.displayName = 'ScreenCoverageContext' +const ScreenCoverageRegisterContext = createContext< + ((presence: SharedValue) => () => void) | null +>(null) +ScreenCoverageRegisterContext.displayName = 'ScreenCoverageRegisterContext' + +let nextCoverageId = 0 + +/** + * Sums the presence of every mounted screen that reports one. Exactly one + * screen is fully present at rest, so this is 1 whenever every screen taking + * part in a transition is still mounted, and falls short by exactly the share + * of a screen that has already been unmounted (a JS-initiated pop swaps the + * outgoing screen for a snapshot, which stops reporting progress). Shell UI + * that belonged to such a screen can fill that gap to stay in sync with the + * incoming screen's transition. + */ +export function ScreenCoverageProvider({ + children, +}: { + children: React.ReactNode +}) { + const [entries, setEntries] = useState([]) + + const coverage = useDerivedValue(() => { + let sum = 0 + for (const entry of entries) { + sum += entry.presence.get() + } + return sum + }, [entries]) + + const register = useMemo( + () => (presence: SharedValue) => { + const id = nextCoverageId++ + setEntries(prev => [...prev, {id, presence}]) + return () => setEntries(prev => prev.filter(entry => entry.id !== id)) + }, + [], + ) + + return ( + + + {children} + + + ) +} + +/** + * Counts a screen's presence towards `useScreenCoverage()` while mounted. + */ +export function useRegisterScreenCoverage(presence: SharedValue) { + const register = useContext(ScreenCoverageRegisterContext) + useEffect(() => register?.(presence), [register, presence]) +} + +/** + * Total presence of all mounted screens, see `ScreenCoverageProvider`. A + * constant 1 outside the provider. + */ +export function useScreenCoverage(): SharedValue { + const coverage = useContext(ScreenCoverageContext) + const fallback = useSharedValue(1) + return coverage ?? fallback +} diff --git a/src/lib/hooks/useScreenPresence/index.tsx b/src/lib/hooks/useScreenPresence/index.tsx index 6ac1c1a361..e2f6566698 100644 --- a/src/lib/hooks/useScreenPresence/index.tsx +++ b/src/lib/hooks/useScreenPresence/index.tsx @@ -16,9 +16,14 @@ import { } from '@react-navigation/native' import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' -import {ScreenPresenceContext} from './context' +import {ScreenPresenceContext, useRegisterScreenCoverage} from './context' -export {type ScreenPresence, useScreenPresence} from './context' +export { + ScreenCoverageProvider, + type ScreenPresence, + useScreenCoverage, + useScreenPresence, +} from './context' /** * Swaps react-native-screens' screen implementation for one that pipes the @@ -86,6 +91,7 @@ function NativeStackScreenPresenceProvider({ const presence = useDerivedValue(() => Math.min(visibility.get(), ancestorsFocused.get()), ) + useRegisterScreenCoverage(presence) return ( diff --git a/src/lib/hooks/useScreenPresence/index.web.tsx b/src/lib/hooks/useScreenPresence/index.web.tsx index cd3ed9c5ca..3e01fac09f 100644 --- a/src/lib/hooks/useScreenPresence/index.web.tsx +++ b/src/lib/hooks/useScreenPresence/index.web.tsx @@ -3,9 +3,14 @@ import {useSharedValue, withSpring} from 'react-native-reanimated' import {useFocusEffect} from '@react-navigation/native' import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' -import {ScreenPresenceContext} from './context' +import {ScreenPresenceContext, useRegisterScreenCoverage} from './context' -export {type ScreenPresence, useScreenPresence} from './context' +export { + ScreenCoverageProvider, + type ScreenPresence, + useScreenCoverage, + useScreenPresence, +} from './context' /** * No-op on web: there are no native stack transitions to track. @@ -29,6 +34,7 @@ export function ScreenPresenceProvider({ }) { const visibility = useSharedValue(1) const presence = useSharedValue(0) + useRegisterScreenCoverage(presence) useFocusEffect( useCallback(() => {