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(() => {