import React, { createContext, useContext, useCallback, useRef, useMemo, useEffect, type ReactNode, } from 'react'; import { LayoutAnimation, Platform } from 'react-native'; import { useSharedValue, type SharedValue } from 'react-native-reanimated'; import type { AnimationDirection, ExitAnimationTrigger, PendingEntryAnimation, ExitingItemInfo, } from '../types'; /** * Context value for coordinating entry/exit animations across list items. * * This enables: * 1. Direct O(1) animation triggers from subscription handlers * 2. Entry animation coordination when items appear in new locations * 3. Layout animation coordination when items are removed * 4. Decoupled animation state from React render cycle */ export interface ListAnimationContextValue { /** * Register an exit animation trigger for an item. * Called by useListExitAnimation on mount. */ registerAnimationTrigger: (itemId: string, trigger: ExitAnimationTrigger) => void; /** * Unregister an exit animation trigger. * Called by useListExitAnimation on unmount. */ unregisterAnimationTrigger: (itemId: string) => void; /** * Trigger exit animation for a specific item. * Returns true if animation was triggered, false if item not found. */ triggerExitAnimation: ( itemId: string, direction: AnimationDirection, onComplete: () => void, ) => boolean; /** * Queue an entry animation for an item that's about to appear. * Call this before the item is added to the list. */ queueEntryAnimation: (itemId: string, direction: AnimationDirection) => void; /** * Check and claim a pending entry animation for an item. * Returns the animation info if found, null otherwise. * Calling this consumes the pending animation. */ claimEntryAnimation: (itemId: string) => PendingEntryAnimation | null; /** * Register an item that is currently exiting (for layout compensation). * Called when exit animation starts. */ registerExitingItem: (itemId: string, index: number, height: number) => void; /** * Unregister an exiting item (called when removal is complete). */ unregisterExitingItem: (itemId: string) => void; /** * Get info about items currently exiting above a given index. * Returns the total height of exiting items above. */ getExitingHeightAbove: (index: number) => number; /** * SharedValue that increments when exiting items change. * Used to trigger useDerivedValue re-evaluation. */ exitingItemsVersion: SharedValue; /** * Duration for layout animations (ms). */ layoutAnimationDuration: number; } const ListAnimationContext = createContext(null); /** * Hook to access list animation context. * Throws if used outside ListAnimationProvider. */ export const useListAnimation = (): ListAnimationContextValue => { const context = useContext(ListAnimationContext); if (!context) { throw new Error('useListAnimation must be used within ListAnimationProvider'); } return context; }; /** * Hook to optionally access list animation context. * Returns null if used outside ListAnimationProvider. * Useful for components that can work with or without animations. */ export const useListAnimationOptional = (): ListAnimationContextValue | null => { return useContext(ListAnimationContext); }; interface ListAnimationProviderProps { children: ReactNode; /** * How long pending entry animations are valid (ms) * @default 5000 */ entryAnimationTimeout?: number; /** * Duration for layout animations when items are removed (ms). * This controls how fast remaining items animate into their new positions * after an item is removed from the list. * Lower values = faster/snappier, higher values = smoother/more gradual * @default 300 */ layoutAnimationDuration?: number; /** * Whether to enable native LayoutAnimation for remaining items * @default true */ enableLayoutAnimation?: boolean; } /** * Provider for coordinating entry/exit animations across list items. * * Features: * - O(1) exit animation triggers via Map lookup * - Entry animation queuing with automatic expiration * - Layout animation coordination when items are removed * - Decoupled from React render cycle for performance */ export const ListAnimationProvider: React.FC = ({ children, entryAnimationTimeout = 5000, layoutAnimationDuration = 300, enableLayoutAnimation = true, }) => { // Map of itemId -> exit animation trigger function const animationTriggersRef = useRef>(new Map()); // Map of itemId -> pending entry animation const pendingEntriesRef = useRef>(new Map()); // Map of itemId -> exiting item info (for layout compensation) const exitingItemsRef = useRef>(new Map()); // Map of itemId -> timeout ID for cleanup tracking (prevents memory leaks) const timeoutIdsRef = useRef>>(new Map()); // SharedValue to trigger re-evaluation when exiting items change const exitingItemsVersion = useSharedValue(0); const registerAnimationTrigger = useCallback( (itemId: string, trigger: ExitAnimationTrigger) => { animationTriggersRef.current.set(itemId, trigger); }, [], ); const unregisterAnimationTrigger = useCallback((itemId: string) => { animationTriggersRef.current.delete(itemId); }, []); const triggerExitAnimation = useCallback( ( itemId: string, direction: AnimationDirection, onComplete: () => void, ): boolean => { const trigger = animationTriggersRef.current.get(itemId); if (trigger) { trigger(direction, onComplete); return true; } return false; }, [], ); const queueEntryAnimation = useCallback( (itemId: string, direction: AnimationDirection) => { const entry: PendingEntryAnimation = { itemId, direction, timestamp: Date.now(), }; pendingEntriesRef.current.set(itemId, entry); // Clear any existing timeout for this itemId to prevent duplicates const existingTimeout = timeoutIdsRef.current.get(itemId); if (existingTimeout) { clearTimeout(existingTimeout); } // Auto-expire after timeout (tracked for cleanup on unmount) const timeoutId = setTimeout(() => { const current = pendingEntriesRef.current.get(itemId); if (current && current.timestamp === entry.timestamp) { pendingEntriesRef.current.delete(itemId); } timeoutIdsRef.current.delete(itemId); }, entryAnimationTimeout); timeoutIdsRef.current.set(itemId, timeoutId); }, [entryAnimationTimeout], ); const claimEntryAnimation = useCallback( (itemId: string): PendingEntryAnimation | null => { const entry = pendingEntriesRef.current.get(itemId); if (entry) { pendingEntriesRef.current.delete(itemId); // Clear the auto-expire timeout since we're claiming it now const timeoutId = timeoutIdsRef.current.get(itemId); if (timeoutId) { clearTimeout(timeoutId); timeoutIdsRef.current.delete(itemId); } // Check if still valid (not expired) if (Date.now() - entry.timestamp < entryAnimationTimeout) { return entry; } } return null; }, [entryAnimationTimeout], ); // Register an exiting item for layout compensation const registerExitingItem = useCallback( (itemId: string, index: number, height: number) => { const info: ExitingItemInfo = { itemId, index, height, timestamp: Date.now(), }; exitingItemsRef.current.set(itemId, info); exitingItemsVersion.value = exitingItemsVersion.value + 1; // Configure native LayoutAnimation for remaining items if (enableLayoutAnimation) { // Use spring animation for smoother, more natural item transitions // Spring animations better handle position changes than timed animations // and provide a more "organic" feel to remaining items moving up LayoutAnimation.configureNext( LayoutAnimation.create( layoutAnimationDuration, LayoutAnimation.Types.easeOut, // Use opacity as the trigger property - this works better for // position-based animations than scaleY which can cause visual artifacts LayoutAnimation.Properties.opacity, ), ); } }, [enableLayoutAnimation, layoutAnimationDuration, exitingItemsVersion], ); // Unregister an exiting item const unregisterExitingItem = useCallback( (itemId: string) => { exitingItemsRef.current.delete(itemId); exitingItemsVersion.value = exitingItemsVersion.value + 1; }, [exitingItemsVersion], ); // Get total height of exiting items above a given index const getExitingHeightAbove = useCallback((index: number): number => { let totalHeight = 0; exitingItemsRef.current.forEach(info => { if (info.index < index) { totalHeight += info.height; } }); return totalHeight; }, []); const value = useMemo( () => ({ registerAnimationTrigger, unregisterAnimationTrigger, triggerExitAnimation, queueEntryAnimation, claimEntryAnimation, registerExitingItem, unregisterExitingItem, getExitingHeightAbove, exitingItemsVersion, layoutAnimationDuration, }), [ registerAnimationTrigger, unregisterAnimationTrigger, triggerExitAnimation, queueEntryAnimation, claimEntryAnimation, registerExitingItem, unregisterExitingItem, getExitingHeightAbove, exitingItemsVersion, layoutAnimationDuration, ], ); // Cleanup all Maps and timers on unmount to prevent memory leaks useEffect(() => { // Capture ref values at setup time to satisfy react-hooks/exhaustive-deps // This ensures cleanup uses the same Map instances that were set up const timeouts = timeoutIdsRef.current; const triggers = animationTriggersRef.current; const pending = pendingEntriesRef.current; const exiting = exitingItemsRef.current; return () => { // Clear all pending timeouts timeouts.forEach(timeoutId => clearTimeout(timeoutId)); timeouts.clear(); // Clear all Maps triggers.clear(); pending.clear(); exiting.clear(); }; }, []); return ( {children} ); };