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