5453044368
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
272 lines
7.4 KiB
TypeScript
272 lines
7.4 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useEffectEvent,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import {
|
|
clamp,
|
|
type DerivedValue,
|
|
type SharedValue,
|
|
useAnimatedReaction,
|
|
useDerivedValue,
|
|
useSharedValue,
|
|
withSpring,
|
|
} from 'react-native-reanimated'
|
|
import {scheduleOnRN} from 'react-native-worklets'
|
|
|
|
import {SHELL_SPRING_CONFIG} from '#/lib/custom-animations/springs'
|
|
import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
|
|
import {
|
|
useScreenCoverage,
|
|
useScreenPresence,
|
|
} from '#/lib/hooks/useScreenPresence'
|
|
import {type ComposerOpts} from '#/state/shell/composer'
|
|
|
|
export type ComposePromptOpenOptions = Pick<
|
|
ComposerOpts,
|
|
'imageUris' | 'openGallery'
|
|
>
|
|
|
|
export type ComposePromptConfig = {
|
|
/**
|
|
* Placeholder text shown in the pill, e.g. "What's up?"
|
|
*/
|
|
label: string
|
|
/**
|
|
* Accessibility label and hint for the pill's main press target.
|
|
*/
|
|
accessibilityLabel: string
|
|
accessibilityHint: string
|
|
/**
|
|
* Opens the composer. Media picked from the pill's camera and gallery
|
|
* buttons is passed through so the screen can merge it with its own
|
|
* options (reply target, mention, log context).
|
|
*/
|
|
open: (options?: ComposePromptOpenOptions) => void
|
|
}
|
|
|
|
type Entry = {
|
|
id: number
|
|
/**
|
|
* The registering screen's own presence, 0..1.
|
|
*/
|
|
presence: SharedValue<number>
|
|
/**
|
|
* 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.
|
|
*/
|
|
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
|
|
}
|
|
|
|
type StateContext = {
|
|
/**
|
|
* How visible the pill should be, 0 (hidden) to 1 (shown): the summed
|
|
* presence of every screen that shows it, clamped.
|
|
*/
|
|
visibility: DerivedValue<number>
|
|
/**
|
|
* Config of the screen that is currently the most present, or undefined
|
|
* when no screen shows the pill.
|
|
*/
|
|
config: ComposePromptConfig | undefined
|
|
}
|
|
|
|
type ActionsContext = {
|
|
register: (
|
|
presence: SharedValue<number>,
|
|
leaving: SharedValue<number>,
|
|
fade: SharedValue<number>,
|
|
config: ComposePromptConfig,
|
|
) => number
|
|
update: (id: number, config: ComposePromptConfig) => void
|
|
unregister: (id: number) => void
|
|
}
|
|
|
|
const stateContext = createContext<StateContext | null>(null)
|
|
stateContext.displayName = 'ComposePromptStateContext'
|
|
const actionsContext = createContext<ActionsContext | null>(null)
|
|
actionsContext.displayName = 'ComposePromptActionsContext'
|
|
|
|
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 += entryPresence(entry, coverage.get())
|
|
}
|
|
return clamp(sum, 0, 1)
|
|
}, [entries, coverage])
|
|
|
|
/*
|
|
* The pill shows the label of whichever screen is the most present, so
|
|
* that during a push or swipe-back the text switches at the midpoint of
|
|
* the transition rather than when the outgoing screen unmounts.
|
|
*/
|
|
const mostPresentId = useDerivedValue(() => {
|
|
let best: number | null = null
|
|
let bestPresence = 0
|
|
for (const entry of entries) {
|
|
const presence = entryPresence(entry, coverage.get())
|
|
if (presence > bestPresence) {
|
|
best = entry.id
|
|
bestPresence = presence
|
|
}
|
|
}
|
|
return best
|
|
}, [entries, coverage])
|
|
|
|
useAnimatedReaction(
|
|
() => mostPresentId.get(),
|
|
(current, previous) => {
|
|
// keep the last label while everything fades out
|
|
if (current !== previous && current !== null) {
|
|
scheduleOnRN(setActiveId, current)
|
|
}
|
|
},
|
|
)
|
|
|
|
const actions = useMemo<ActionsContext>(
|
|
() => ({
|
|
register(presence, leaving, fade, config) {
|
|
const id = nextId++
|
|
setEntries(prev => [...prev, {id, presence, leaving, fade, config}])
|
|
return id
|
|
},
|
|
update(id, config) {
|
|
setEntries(prev =>
|
|
prev.map(entry => (entry.id === id ? {...entry, config} : entry)),
|
|
)
|
|
},
|
|
unregister(id) {
|
|
setEntries(prev => prev.filter(entry => entry.id !== id))
|
|
},
|
|
}),
|
|
[],
|
|
)
|
|
|
|
const config = (
|
|
entries.find(entry => entry.id === activeId) ?? entries.at(-1)
|
|
)?.config
|
|
const state = useMemo<StateContext>(
|
|
() => ({visibility, config}),
|
|
[visibility, config],
|
|
)
|
|
|
|
return (
|
|
<stateContext.Provider value={state}>
|
|
<actionsContext.Provider value={actions}>
|
|
{children}
|
|
</actionsContext.Provider>
|
|
</stateContext.Provider>
|
|
)
|
|
}
|
|
|
|
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.
|
|
*/
|
|
export function useComposePromptState() {
|
|
const context = useContext(stateContext)
|
|
if (!context) {
|
|
throw new Error(
|
|
'useComposePromptState must be used within a ComposePromptProvider',
|
|
)
|
|
}
|
|
return context
|
|
}
|
|
|
|
function useComposePromptActions() {
|
|
const context = useContext(actionsContext)
|
|
if (!context) {
|
|
throw new Error(
|
|
'useComposePromptActions must be used within a ComposePromptProvider',
|
|
)
|
|
}
|
|
return context
|
|
}
|
|
|
|
/**
|
|
* Shows the compose pill in the bottom bar while the surrounding
|
|
* `Layout.Screen` is present, following its transition in and out frame by
|
|
* frame. Pass `null` to show nothing (e.g. when replies are disabled).
|
|
*/
|
|
export function useComposePromptForScreen(config: ComposePromptConfig | null) {
|
|
const {register, update, unregister} = useComposePromptActions()
|
|
const {presence} = useScreenPresence()
|
|
const leaving = useSharedValue(0)
|
|
const fade = useSharedValue(1)
|
|
const idRef = useRef<number | null>(null)
|
|
const enabled = config !== null
|
|
|
|
// the bar's top border would cut across the pill's gradient
|
|
useHideBottomBarBorderForScreen({enabled})
|
|
|
|
const getConfig = useEffectEvent(() => config)
|
|
|
|
useEffect(() => {
|
|
const initial = getConfig()
|
|
if (!initial) return
|
|
leaving.set(0)
|
|
fade.set(1)
|
|
const id = register(presence, leaving, fade, initial)
|
|
idRef.current = id
|
|
return () => {
|
|
idRef.current = null
|
|
leaving.set(1)
|
|
fade.set(
|
|
withSpring(0, SHELL_SPRING_CONFIG, finished => {
|
|
if (finished) {
|
|
scheduleOnRN(unregister, id)
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}, [enabled, register, unregister, leaving, fade, presence])
|
|
|
|
const label = config?.label
|
|
const accessibilityLabel = config?.accessibilityLabel
|
|
const accessibilityHint = config?.accessibilityHint
|
|
const open = config?.open
|
|
useEffect(() => {
|
|
if (
|
|
idRef.current !== null &&
|
|
label &&
|
|
accessibilityLabel &&
|
|
accessibilityHint &&
|
|
open
|
|
) {
|
|
update(idRef.current, {
|
|
label,
|
|
accessibilityLabel,
|
|
accessibilityHint,
|
|
open,
|
|
})
|
|
}
|
|
}, [label, accessibilityLabel, accessibilityHint, open, update])
|
|
}
|