import React, { useLayoutEffect, useMemo, useCallback, useRef, useEffect } from 'react'; import Animated, { useAnimatedRef, useSharedValue } from 'react-native-reanimated'; import type { LayoutChangeEvent } from 'react-native'; import { useDragGesture } from './hooks/drag/useDragGesture'; import { useDragShift } from './hooks/drag/useDragShift'; import { useDragAnimatedStyle } from './hooks/drag/useDragAnimatedStyle'; import { useDropCompensation } from './hooks/drag/useDropCompensation'; import { useListExitAnimation } from './hooks/animations/useListExitAnimation'; import { useListEntryAnimation } from './hooks/animations/useListEntryAnimation'; import { useListAnimationOptional } from './contexts/ListAnimationContext'; import { useDragState } from './contexts/DragStateContext'; import type { AnimatedListItem, AnimatedRenderItemInfo, HapticFeedbackType, } from './types'; interface AnimatedFlashListItemProps { /** The item data */ item: T; /** Current index in list */ index: number; /** Total number of items */ totalItems: number; /** Whether drag is enabled for this item */ isDragEnabled: boolean; /** Render function from parent */ renderItem: (info: AnimatedRenderItemInfo) => React.ReactElement; /** Callback when reorder occurs */ onReorderByDelta?: (itemId: string, delta: number) => void; /** Optional haptic feedback callback */ onHapticFeedback?: (type: HapticFeedbackType) => void; } /** * Internal item wrapper that provides all animation functionality. * * This component: * 1. Sets up drag gesture and shift animations * 2. Sets up entry/exit animations * 3. Combines all animated styles * 4. Passes everything to the consumer's renderItem function * * @internal */ function AnimatedFlashListItemInner({ item, index, totalItems, isDragEnabled, renderItem, onReorderByDelta, onHapticFeedback, }: AnimatedFlashListItemProps): React.ReactElement | null { // Create shared value for index (for UI-thread access in animations) // This allows worklets to read the current index without closure stale capture const indexShared = useSharedValue(index); // Register this item's index in the central registry // This handles FlashList's recycling behavior where components may not re-render // after data changes, leaving their index props stale const { updateItemIndex, isDragging: globalIsDragging, isDropping, } = useDragState(); // Sync index to SharedValue on every render, but skip during active drag/drop // This prevents stale React props from corrupting the index during the drop phase // when FlashList re-renders with new data order useLayoutEffect(() => { // Don't update during active drag/drop to prevent race conditions // The registry handles correct index lookup during these phases if (globalIsDragging.value || isDropping.value) { return; } indexShared.value = index; }, [index, indexShared, globalIsDragging, isDropping]); // Secondary sync: Catch the case where React re-rendered with new index during drop phase. // The primary useLayoutEffect bails out during drop (guards=true), and when guards are // released, it doesn't re-run because `index` didn't change. This effect forces the sync // on every render when guards are down and there's a mismatch. // // This effect intentionally has NO dependency array. Adding deps would defeat its purpose: // it needs to run on EVERY render to catch the window where guards drop but `index` hasn't // changed (so the primary useLayoutEffect won't re-fire). The body is lightweight (three // SharedValue reads + one conditional write) so the per-render cost is negligible. useEffect(() => { if (globalIsDragging.value || isDropping.value || indexShared.value === index) return; indexShared.value = index; }); // Update item index in useLayoutEffect to avoid side effects during render // useLayoutEffect runs synchronously after DOM mutations but before paint, // ensuring the registry is up-to-date before any visual updates useLayoutEffect(() => { updateItemIndex(item.id, index); }, [updateItemIndex, item.id, index]); // Animated ref for measuring item height on drag start const containerRef = useAnimatedRef(); // Track measured height for layout compensation const measuredHeightRef = useRef(0); // List animation context for subscription-triggered animations and layout compensation const animationContext = useListAnimationOptional(); // === DRAG HOOKS === // Pan gesture for drag-to-reorder // Note: totalItems is now accessed via SharedValue from DragStateContext const { panGesture, isDragging, translateY } = useDragGesture( { itemId: item.id, index, enabled: isDragEnabled, containerRef, }, { onReorderByDelta, onHapticFeedback, }, ); // Shift animation for non-dragged items const { shiftY } = useDragShift({ itemId: item.id, index, dragEnabled: isDragEnabled }); // Handle index changes after cache updates (drop compensation) useDropCompensation({ itemId: item.id, index, translateY, dragEnabled: isDragEnabled }); // Animated style for drag transforms const { dragAnimatedStyle } = useDragAnimatedStyle( item.id, isDragging, translateY, shiftY, containerRef, isDragEnabled, ); // === ANIMATION HOOKS === // Callbacks for layout compensation (register/unregister exiting items) const onExitStart = useCallback( (exitIndex: number, height: number) => { animationContext?.registerExitingItem(item.id, exitIndex, height); }, [animationContext, item.id], ); const onExitComplete = useCallback(() => { animationContext?.unregisterExitingItem(item.id); }, [animationContext, item.id]); // Exit animation for smooth slide-out (with layout compensation callbacks) const { exitAnimatedStyle, triggerExit, resetAnimation } = useListExitAnimation(item.id, { index, measuredHeight: measuredHeightRef.current, onExitStart, onExitComplete, }); // Entry animation for items appearing const { entryAnimatedStyle } = useListEntryAnimation(item.id); // Register exit animation trigger (O(1) direct calls from subscriptions) useLayoutEffect(() => { if (!animationContext) return; animationContext.registerAnimationTrigger(item.id, triggerExit); return () => animationContext.unregisterAnimationTrigger(item.id); }, [item.id, triggerExit, animationContext]); // Track measured height for layout compensation const handleLayout = useCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; measuredHeightRef.current = height; }, []); // Create drag handle props const dragHandleProps = useMemo( () => isDragEnabled ? { gesture: panGesture, isDragging, } : null, [isDragEnabled, panGesture, isDragging], ); // Trigger exit animation wrapper const triggerExitAnimation = useCallback( ( direction: 1 | -1, onComplete: () => void, preset?: 'default' | 'fast', ) => { triggerExit(direction, onComplete, preset); }, [triggerExit], ); // Create render info // Note: animatedStyle is empty because animations are applied to the wrapper View automatically. // This field is kept for backward compatibility with the AnimatedRenderItemInfo interface. const renderInfo = useMemo>( () => ({ item, index, totalItems, animatedStyle: {}, dragHandleProps, isDragging: false, // This is a SharedValue, consumer should use dragHandleProps.isDragging isDragEnabled, triggerExitAnimation, resetExitAnimation: resetAnimation, }), [ item, index, totalItems, dragHandleProps, isDragEnabled, triggerExitAnimation, resetAnimation, ], ); // Render the item with animations applied // The consumer's renderItem gets wrapped in our animated container const renderedItem = renderItem(renderInfo); return ( {renderedItem} ); } // Memoize to prevent unnecessary re-renders export const AnimatedFlashListItem = React.memo( AnimatedFlashListItemInner, ) as typeof AnimatedFlashListItemInner;