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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QoggCVt9XgpKbjrTZjoJD9
This commit is contained in:
Claude
2026-09-07 22:07:03 +00:00
parent 64ae8d201a
commit 5453044368
6 changed files with 168 additions and 52 deletions
+17 -14
View File
@@ -14,6 +14,7 @@ import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder' import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {ScreenCoverageProvider} from '#/lib/hooks/useScreenPresence'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' import {Provider as TranslateOnDeviceProvider} from '#/lib/translation'
@@ -173,20 +174,22 @@ function InnerApp() {
<ServiceAccountManager> <ServiceAccountManager>
<EmailVerificationProvider> <EmailVerificationProvider>
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
<ComposePromptProvider> <ScreenCoverageProvider>
<GestureHandlerRootView <ComposePromptProvider>
style={a.h_full}> <GestureHandlerRootView
<GlobalGestureEventsProvider> style={a.h_full}>
<IntentDialogProvider> <GlobalGestureEventsProvider>
<TranslateOnDeviceProvider> <IntentDialogProvider>
<TestCtrls /> <TranslateOnDeviceProvider>
<Shell /> <TestCtrls />
<ToastOutlet /> <Shell />
</TranslateOnDeviceProvider> <ToastOutlet />
</IntentDialogProvider> </TranslateOnDeviceProvider>
</GlobalGestureEventsProvider> </IntentDialogProvider>
</GestureHandlerRootView> </GlobalGestureEventsProvider>
</ComposePromptProvider> </GestureHandlerRootView>
</ComposePromptProvider>
</ScreenCoverageProvider>
</HideBottomBarBorderProvider> </HideBottomBarBorderProvider>
</EmailVerificationProvider> </EmailVerificationProvider>
</ServiceAccountManager> </ServiceAccountManager>
+13 -10
View File
@@ -7,6 +7,7 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import {ScreenCoverageProvider} from '#/lib/hooks/useScreenPresence'
import {Provider as HotkeysProvider} from '#/lib/hotkeys' import {Provider as HotkeysProvider} from '#/lib/hotkeys'
import {QueryProvider} from '#/lib/react-query' import {QueryProvider} from '#/lib/react-query'
import {ThemeProvider} from '#/lib/ThemeContext' import {ThemeProvider} from '#/lib/ThemeContext'
@@ -154,16 +155,18 @@ function InnerApp() {
<ServiceConfigProvider> <ServiceConfigProvider>
<EmailVerificationProvider> <EmailVerificationProvider>
<HideBottomBarBorderProvider> <HideBottomBarBorderProvider>
<ComposePromptProvider> <ScreenCoverageProvider>
<IntentDialogProvider> <ComposePromptProvider>
<TranslateOnDeviceProvider> <IntentDialogProvider>
<HotkeysProvider> <TranslateOnDeviceProvider>
<Shell /> <HotkeysProvider>
<ToastOutlet /> <Shell />
</HotkeysProvider> <ToastOutlet />
</TranslateOnDeviceProvider> </HotkeysProvider>
</IntentDialogProvider> </TranslateOnDeviceProvider>
</ComposePromptProvider> </IntentDialogProvider>
</ComposePromptProvider>
</ScreenCoverageProvider>
</HideBottomBarBorderProvider> </HideBottomBarBorderProvider>
</EmailVerificationProvider> </EmailVerificationProvider>
</ServiceConfigProvider> </ServiceConfigProvider>
+41 -22
View File
@@ -20,7 +20,10 @@ import {scheduleOnRN} from 'react-native-worklets'
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs'
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder' 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' import {type ComposerOpts} from '#/state/shell/composer'
export type ComposePromptOpenOptions = Pick< export type ComposePromptOpenOptions = Pick<
@@ -49,15 +52,22 @@ export type ComposePromptConfig = {
type Entry = { type Entry = {
id: number id: number
/** /**
* The registering screen's own presence, 0..1, read directly so that two * The registering screen's own presence, 0..1.
* screens mid-transition are always sampled on the same frame.
*/ */
presence: SharedValue<number> presence: SharedValue<number>
/** /**
* 1 while registered, sprung to 0 on unregister so a screen that leaves * 1 once the screen has unregistered but is still fading out. A screen that
* without a transition still fades the pill out. * 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<number> leaving: SharedValue<number>
/**
* 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<number>
config: ComposePromptConfig config: ComposePromptConfig
} }
@@ -77,7 +87,8 @@ type StateContext = {
type ActionsContext = { type ActionsContext = {
register: ( register: (
presence: SharedValue<number>, presence: SharedValue<number>,
weight: SharedValue<number>, leaving: SharedValue<number>,
fade: SharedValue<number>,
config: ComposePromptConfig, config: ComposePromptConfig,
) => number ) => number
update: (id: number, config: ComposePromptConfig) => void update: (id: number, config: ComposePromptConfig) => void
@@ -94,14 +105,15 @@ let nextId = 0
export function Provider({children}: {children: React.ReactNode}) { export function Provider({children}: {children: React.ReactNode}) {
const [entries, setEntries] = useState<Entry[]>([]) const [entries, setEntries] = useState<Entry[]>([])
const [activeId, setActiveId] = useState<number | null>(null) const [activeId, setActiveId] = useState<number | null>(null)
const coverage = useScreenCoverage()
const visibility = useDerivedValue(() => { const visibility = useDerivedValue(() => {
let sum = 0 let sum = 0
for (const entry of entries) { for (const entry of entries) {
sum += entry.presence.get() * entry.weight.get() sum += entryPresence(entry, coverage.get())
} }
return clamp(sum, 0, 1) return clamp(sum, 0, 1)
}, [entries]) }, [entries, coverage])
/* /*
* The pill shows the label of whichever screen is the most present, so * 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 best: number | null = null
let bestPresence = 0 let bestPresence = 0
for (const entry of entries) { for (const entry of entries) {
const presence = entry.presence.get() * entry.weight.get() const presence = entryPresence(entry, coverage.get())
if (presence > bestPresence) { if (presence > bestPresence) {
best = entry.id best = entry.id
bestPresence = presence bestPresence = presence
} }
} }
return best return best
}, [entries]) }, [entries, coverage])
useAnimatedReaction( useAnimatedReaction(
() => mostPresentId.get(), () => mostPresentId.get(),
@@ -133,9 +145,9 @@ export function Provider({children}: {children: React.ReactNode}) {
const actions = useMemo<ActionsContext>( const actions = useMemo<ActionsContext>(
() => ({ () => ({
register(presence, weight, config) { register(presence, leaving, fade, config) {
const id = nextId++ const id = nextId++
setEntries(prev => [...prev, {id, presence, weight, config}]) setEntries(prev => [...prev, {id, presence, leaving, fade, config}])
return id return id
}, },
update(id, config) { 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. * What the pill should render right now. Used by the bottom bars.
*/ */
@@ -198,7 +218,8 @@ function useComposePromptActions() {
export function useComposePromptForScreen(config: ComposePromptConfig | null) { export function useComposePromptForScreen(config: ComposePromptConfig | null) {
const {register, update, unregister} = useComposePromptActions() const {register, update, unregister} = useComposePromptActions()
const {presence} = useScreenPresence() const {presence} = useScreenPresence()
const weight = useSharedValue(0) const leaving = useSharedValue(0)
const fade = useSharedValue(1)
const idRef = useRef<number | null>(null) const idRef = useRef<number | null>(null)
const enabled = config !== null const enabled = config !== null
@@ -210,16 +231,14 @@ export function useComposePromptForScreen(config: ComposePromptConfig | null) {
useEffect(() => { useEffect(() => {
const initial = getConfig() const initial = getConfig()
if (!initial) return if (!initial) return
weight.set(1) leaving.set(0)
const id = register(presence, weight, initial) fade.set(1)
const id = register(presence, leaving, fade, initial)
idRef.current = id idRef.current = id
return () => { return () => {
idRef.current = null idRef.current = null
/* leaving.set(1)
* Fade out before removing so that a screen removed without a fade.set(
* transition (or a config switched off) does not snap the pill away.
*/
weight.set(
withSpring(0, SHELL_SPRING_CONFIG, finished => { withSpring(0, SHELL_SPRING_CONFIG, finished => {
if (finished) { if (finished) {
scheduleOnRN(unregister, id) 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 label = config?.label
const accessibilityLabel = config?.accessibilityLabel const accessibilityLabel = config?.accessibilityLabel
+81 -2
View File
@@ -1,5 +1,10 @@
import {createContext, useContext} from 'react' import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {type SharedValue, useSharedValue} from 'react-native-reanimated' import {
type DerivedValue,
type SharedValue,
useDerivedValue,
useSharedValue,
} from 'react-native-reanimated'
export type ScreenPresence = { export type ScreenPresence = {
/** /**
@@ -28,3 +33,77 @@ export function useScreenPresence(): ScreenPresence {
const fallback = useSharedValue(1) const fallback = useSharedValue(1)
return context ?? {visibility: fallback, presence: fallback} return context ?? {visibility: fallback, presence: fallback}
} }
type CoverageEntry = {
id: number
presence: SharedValue<number>
}
const ScreenCoverageContext = createContext<DerivedValue<number> | null>(null)
ScreenCoverageContext.displayName = 'ScreenCoverageContext'
const ScreenCoverageRegisterContext = createContext<
((presence: SharedValue<number>) => () => 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<CoverageEntry[]>([])
const coverage = useDerivedValue(() => {
let sum = 0
for (const entry of entries) {
sum += entry.presence.get()
}
return sum
}, [entries])
const register = useMemo(
() => (presence: SharedValue<number>) => {
const id = nextCoverageId++
setEntries(prev => [...prev, {id, presence}])
return () => setEntries(prev => prev.filter(entry => entry.id !== id))
},
[],
)
return (
<ScreenCoverageRegisterContext.Provider value={register}>
<ScreenCoverageContext.Provider value={coverage}>
{children}
</ScreenCoverageContext.Provider>
</ScreenCoverageRegisterContext.Provider>
)
}
/**
* Counts a screen's presence towards `useScreenCoverage()` while mounted.
*/
export function useRegisterScreenCoverage(presence: SharedValue<number>) {
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<number> {
const coverage = useContext(ScreenCoverageContext)
const fallback = useSharedValue(1)
return coverage ?? fallback
}
+8 -2
View File
@@ -16,9 +16,14 @@ import {
} from '@react-navigation/native' } from '@react-navigation/native'
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' 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 * Swaps react-native-screens' screen implementation for one that pipes the
@@ -86,6 +91,7 @@ function NativeStackScreenPresenceProvider({
const presence = useDerivedValue(() => const presence = useDerivedValue(() =>
Math.min(visibility.get(), ancestorsFocused.get()), Math.min(visibility.get(), ancestorsFocused.get()),
) )
useRegisterScreenCoverage(presence)
return ( return (
<ScreenPresenceContext.Provider value={{visibility, presence}}> <ScreenPresenceContext.Provider value={{visibility, presence}}>
@@ -3,9 +3,14 @@ import {useSharedValue, withSpring} from 'react-native-reanimated'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs' 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. * No-op on web: there are no native stack transitions to track.
@@ -29,6 +34,7 @@ export function ScreenPresenceProvider({
}) { }) {
const visibility = useSharedValue(1) const visibility = useSharedValue(1)
const presence = useSharedValue(0) const presence = useSharedValue(0)
useRegisterScreenCoverage(presence)
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {