/** * Presence (native). Mount/unmount transition primitive — same contract * as web: children stay mounted while the exit animation runs, then * unmount and `onExitComplete` fires. * * {panel} * {panel} * * Divergence from web: animates with RN Animated.timing, so the `easing` * prop (a CSS string on web) is ignored here — native uses Easing.out(cubic), * the closest match to the motion tokens' out curves. `duration` is honored. * Reduced motion (AccessibilityInfo) collapses to a fast opacity-only fade. */ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Animated, Easing, type ViewStyle } from "react-native"; import { motion } from "@plyxui/core"; import { useReducedMotion } from "@plyxui/hooks"; export interface PresenceFrom { opacity?: number; translateX?: number; translateY?: number; scale?: number; } export interface PresenceProps { present: boolean; /** Hidden pose the content enters from and exits to. Default `{ opacity: 0, translateY: 8 }`. */ from?: PresenceFrom; /** Override duration (ms) for both enter and exit. Defaults to the motion tokens. */ duration?: number; /** Accepted for API parity with web; ignored on native (see header). */ easing?: string; onExitComplete?: () => void; style?: ViewStyle; children?: ReactNode; } const DEFAULT_FROM: PresenceFrom = { opacity: 0, translateY: 8 }; export function Presence({ present, from = DEFAULT_FROM, duration, onExitComplete, style, children, }: PresenceProps) { const [mounted, setMounted] = useState(present); // 0 = hidden pose, 1 = identity. One driver interpolates every property. const progress = useRef(new Animated.Value(present ? 1 : 0)).current; const reduced = useReducedMotion(); const latest = useRef({ from, duration, onExitComplete, reduced }); latest.current = { from, duration, onExitComplete, reduced }; if (present && !mounted) setMounted(true); useEffect(() => { const l = latest.current; const base = present ? motion.overlayEnter.duration : motion.overlayExit.duration; const anim = Animated.timing(progress, { toValue: present ? 1 : 0, duration: l.reduced ? 50 : (l.duration ?? base), easing: Easing.out(Easing.cubic), useNativeDriver: true, }); anim.start(({ finished }) => { if (finished && !present) { setMounted(false); latest.current.onExitComplete?.(); } }); return () => anim.stop(); }, [present, progress]); if (!mounted) return null; const pose = latest.current.reduced ? { opacity: 0 } : from; const range = (hidden: number, shown: number) => progress.interpolate({ inputRange: [0, 1], outputRange: [hidden, shown] }); const transform: ViewStyle["transform"] = latest.current.reduced ? [] : [ ...(pose.translateX !== undefined ? [{ translateX: range(pose.translateX, 0) as unknown as number }] : []), ...(pose.translateY !== undefined ? [{ translateY: range(pose.translateY, 0) as unknown as number }] : []), ...(pose.scale !== undefined ? [{ scale: range(pose.scale, 1) as unknown as number }] : []), ]; return ( {children} ); }