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