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:
+17
-14
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user