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 {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() {
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<ComposePromptProvider>
<GestureHandlerRootView
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</ComposePromptProvider>
<ScreenCoverageProvider>
<ComposePromptProvider>
<GestureHandlerRootView
style={a.h_full}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</ComposePromptProvider>
</ScreenCoverageProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
+13 -10
View File
@@ -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() {
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<ComposePromptProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<HotkeysProvider>
<Shell />
<ToastOutlet />
</HotkeysProvider>
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</ComposePromptProvider>
<ScreenCoverageProvider>
<ComposePromptProvider>
<IntentDialogProvider>
<TranslateOnDeviceProvider>
<HotkeysProvider>
<Shell />
<ToastOutlet />
</HotkeysProvider>
</TranslateOnDeviceProvider>
</IntentDialogProvider>
</ComposePromptProvider>
</ScreenCoverageProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</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 {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<number>
/**
* 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<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
}
@@ -77,7 +87,8 @@ type StateContext = {
type ActionsContext = {
register: (
presence: SharedValue<number>,
weight: SharedValue<number>,
leaving: SharedValue<number>,
fade: SharedValue<number>,
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<Entry[]>([])
const [activeId, setActiveId] = useState<number | null>(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<ActionsContext>(
() => ({
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<number | null>(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
+81 -2
View File
@@ -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<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'
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 (
<ScreenPresenceContext.Provider value={{visibility, presence}}>
@@ -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(() => {