import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; import { type LayoutChangeEvent, type StyleProp, View, type ViewStyle } from 'react-native'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { EASE_OUT, springLayout } from '../../../lib/ease'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { PresenceContext } from '../../../moti/presence/animate-presence-context'; type Size = { width: number; height: number }; type IslandContextValue = { view: string | null }; const IslandContext = createContext(null); // iPhone pill dimensions. Also the shell's pre-measure size: if the first // commit already has a view active (e.g. a click replayed after hydration), // the shell renders the pill then blooms to the measured size via the layout // transition. It lives in static style, not `animate`/`from`, so there is no // entry animation and server/client markup agree. const PILL_WIDTH = 126; const PILL_HEIGHT = 37; // Constant radius — never animated. The layout clamps it to half the shell // height, so the pill→rounded-rect morph falls out of the resize for free. const RADIUS = 32; // Shell reads as one long, barely-bouncy glide (web: duration 0.8 / bounce 0.2). const SHELL_LAYOUT = springLayout({ stiffness: 200, damping: 24, mass: 1 }); // Content gets a touch more life than the shell (web: bounce 0.35). const CONTENT_SPRING = { type: 'spring', stiffness: 260, damping: 22, mass: 0.9 } as const; // Exit is sucked up into the pill — fast, before the shrinking shell can clip // it (web: 80ms EASE_OUT). const CONTENT_EXIT = { type: 'timing', duration: 80, easing: EASE_OUT } as const; // biome-ignore lint/style/useExportsLast: props type before internal Slot helper — collocated for readability export type DynamicIslandProps = { /** Active view id. `null` shows the compact pill. */ view: string | null; /** Compact pill content, shown when no view is active. */ compact?: ReactNode; /** DynamicIslandView elements. */ children?: ReactNode; className?: string; style?: StyleProp; accessibilityLabel?: string; testID?: string; }; type SlotProps = { keyId: string; children: ReactNode; className?: string; style?: StyleProp }; /** * Content slot for one island view. Reads the presence context to pop itself * out of flow while exiting — faking motion's `popLayout` (Moti's * AnimatePresence has no such mode) so the sizer measures only the incoming * slot during a cross-fade rather than both. The context is read directly * (`useContext`) instead of `usePresenceContext`: the latter registers a second * presence child that would never report completion and strand the key in its * exiting set, and MotiView already registers one internally. */ function Slot({ keyId, children, className, style }: SlotProps) { const reduce = useReducedMotion(); const presence = useContext(PresenceContext); const exiting = presence ? !presence.isPresent : false; return ( {children} ); } export function DynamicIsland({ view, compact, children, className, style, accessibilityLabel, testID }: DynamicIslandProps) { const reduce = useReducedMotion(); const [size, setSize] = useState(null); const expanded = view !== null; const contextValue = useMemo(() => ({ view }), [view]); // The sizer IS the content (single render, no offscreen duplicate). It sits // in-flow inside the shell but is never stretched (items-start on the shell) // and never shrunk (flexShrink: 0), so its natural size is independent of the // shell's animated dimensions — the shell springs toward it and clips the // rest with overflow-hidden. Only update when the size actually changes: the // shell spring re-centers the sizer every frame, firing onLayout with a new x // but the same w/h; returning `prev` lets React bail out and avoids a loop. const onMeasure = useCallback((e: LayoutChangeEvent) => { const { width, height } = e.nativeEvent.layout; setSize((prev) => (prev && prev.width === width && prev.height === height ? prev : { width, height })); }, []); return ( {!expanded && compact ? ( {compact} ) : null} {children} ); } export type DynamicIslandViewProps = { /** Matches the parent `view` prop when active. */ id: string; children: ReactNode; className?: string; style?: StyleProp; }; export function DynamicIslandView({ id, children, className, style }: DynamicIslandViewProps) { const ctx = useContext(IslandContext); if (!ctx) throw new Error('DynamicIslandView must be used inside '); const active = ctx.view === id; return ( {active ? ( {children} ) : null} ); }