import React, { createContext, useContext, useCallback, useRef, useMemo, } from "react"; import type { ReactNode } from "react"; import { useSharedValue, useAnimatedRef, type SharedValue, type AnimatedRef, } from "react-native-reanimated"; import type Animated from "react-native-reanimated"; import { scheduleOnUI } from "react-native-worklets"; import type { FlashListRef } from "@shopify/flash-list"; import type { DragConfig } from "../types"; import { DEFAULT_DRAG_CONFIG } from "../constants/drag"; /** * Worklet that updates the item index registry on the UI thread. * Using scheduleOnUI ensures the SharedValue modification is immediately * visible to other worklets (like onStart in useDragGesture). */ const updateRegistryOnUI = ( registry: SharedValue>, itemId: string, index: number, ) => { "worklet"; const existingIndex = registry.value[itemId]; if (existingIndex === undefined || existingIndex !== index) { registry.value = { ...registry.value, [itemId]: index }; } }; // In tests or non-worklet environments, scheduleOnUI may be undefined. Provide a safe fallback. const scheduleOnUIOrImmediate = ( fn: (...args: T) => void, ...args: T ) => { if (typeof scheduleOnUI === "function") { scheduleOnUI(fn, ...args); } else { fn(...args); } }; /** * Centralized drag state for coordinating animations across list items. * All animation values are Reanimated SharedValues for 60fps UI thread performance. * * Architecture: * - Single source of truth for all drag state (no per-item local state) * - Dragged item identified by draggedIndex, reads/writes centralized values * - Non-dragged items read draggedIndex/currentTranslateY to calculate shift * - Scroll state enables viewport-aware hover calculations and autoscroll */ export interface DragStateContextValue { /** Is any item currently being dragged? */ isDragging: SharedValue; /** Original index of the item being dragged (-1 if not dragging) */ draggedIndex: SharedValue; /** ID of the item being dragged (for stable identity across FlashList recycling) */ draggedItemId: SharedValue; /** Current Y translation of the dragged item (for calculating hover position) */ currentTranslateY: SharedValue; /** Scale of the dragged item (1.0 = normal, configured = dragging) */ draggedScale: SharedValue; /** Current scroll offset of the list (updated via onScroll) */ scrollOffset: SharedValue; /** Scroll offset when drag started (for scroll delta calculation during autoscroll) */ dragStartScrollOffset: SharedValue; /** Total content height of the list (updated via onContentSizeChange) */ contentHeight: SharedValue; /** Visible viewport height (updated via onLayout) */ visibleHeight: SharedValue; /** Y position of FlashList top on screen (for autoscroll coordinate conversion) */ listTopY: SharedValue; /** X position of FlashList left on screen (for overlay positioning) */ listLeftX: SharedValue; /** Measured height of the dragged item (for dynamic height calculations) */ measuredItemHeight: SharedValue; /** Flag to freeze shift values during drop transition */ isDropping: SharedValue; /** Counter incremented on each drag start, used to force shift recalculation */ dragSequence: SharedValue; /** Expected index after drop (for handoff detection) */ expectedDropIndex: SharedValue; /** Frozen mismatch offset computed at drop time to prevent double compensation */ dropMismatchOffset: SharedValue; /** Signal incremented when FlashList commits a layout pass */ layoutCommitSignal: SharedValue; /** UI-thread-only signal incremented when native layout commit is detected */ uiLayoutCommitSignal: SharedValue; /** Layout commit signal value captured when drop starts (for guarding original fade-in) */ dropStartCommitSignal: SharedValue; /** Register the FlashList ref for autoscroll operations */ setListRef: (ref: FlashListRef | null) => void; /** Scroll the list to a specific offset (for autoscroll during drag) */ scrollToOffset: (offset: number, animated?: boolean) => void; /** Ask FlashList to prepare cells for layout animation on the next render */ prepareForLayoutAnimationRender: () => void; /** Notify drag system that FlashList committed a layout pass */ notifyLayoutCommit: () => void; /** Reset drag state after drop animation completes */ resetDragState: () => void; /** Current drag configuration */ config: DragConfig; /** * Index registry for tracking itemId -> index mapping. * Updated on every item render to handle FlashList recycling. * Stored on UI thread as SharedValue for worklet access. */ itemIndexRegistry: SharedValue>; /** Update an item's index in the registry (call from JS thread) */ updateItemIndex: (itemId: string, index: number) => void; /** Get an item's index from the registry */ getItemIndex: (itemId: string) => number | undefined; /** * Snapshot of itemIndexRegistry taken at drag start. * Used during drag to ensure consistent index lookup regardless of * React re-renders or FlashList recycling. */ dragStartIndexSnapshot: SharedValue>; /** * Capture a snapshot of the current itemIndexRegistry for use during drag. * Call this at the start of a drag operation. */ snapshotRegistryForDrag: () => void; /** * Update registry after reorder to handle FlashList not re-rendering. * Call this when reorder completes to keep indices accurate. */ applyReorderToRegistry: ( fromId: string, fromIndex: number, toIndex: number, ) => void; /** Total number of items in the list (synced from data.length) */ totalItems: SharedValue; /** * Signal incremented after reorder callback completes and React has had * time to commit. useDragShift watches this to unfreeze shifted items. */ reorderCompleteSignal: SharedValue; /** Signal incremented when a drop is ready to reorder */ reorderRequestSignal: SharedValue; /** True while a reorder has been requested and layout commit is pending */ reorderInFlight: SharedValue; /** Layout commit version captured when reorder is requested */ dropCommitVersion: SharedValue; /** UI-thread layout commit version captured when reorder is requested */ dropCommitVersionUI: SharedValue; /** Current hovered index while dragging (UI-thread) */ hoveredIndex: SharedValue; /** True while a drag overlay should be shown */ overlayActive: SharedValue; /** Item id currently rendered in overlay */ overlayItemId: SharedValue; /** Overlay base X position (relative to list container) */ overlayBaseX: SharedValue; /** Overlay base Y position (relative to list container) */ overlayBaseY: SharedValue; /** Overlay width */ overlayWidth: SharedValue; /** Overlay height */ overlayHeight: SharedValue; /** Overlay translateY (finger translation) */ overlayTranslateY: SharedValue; /** Overlay ready flag (base position measured) */ overlayReady: SharedValue; /** Overlay visible flag (JS overlay rendered) */ overlayVisible: SharedValue; /** Global layout delta for drop commit timing */ layoutDeltaY: SharedValue; /** Overlay container X position on screen */ overlayContainerX: SharedValue; /** Overlay container Y position on screen */ overlayContainerY: SharedValue; /** Overlay container Y position from layout (measureInWindow value, for shifted overlay calculations) */ overlayContainerYLayout: SharedValue; /** Overlay container measured flag */ overlayContainerReady: SharedValue; /** Finger Y offset within the item at drag start (for absolute positioning) */ overlayTouchOffset: SharedValue; /** Current finger absoluteY during drag (for absolute positioning) */ overlayAbsoluteY: SharedValue; /** Animated ref for the drag container (for UI-thread measure()) */ dragContainerRef: AnimatedRef; /** Mark that a JS reorder callback just executed */ markReorderToken: () => void; /** Read the current reorder token (JS-only) */ getReorderToken: () => number; /** * Flag indicating the dragged item's settle animation has completed. * Used to know when it's safe to trigger reorder. */ draggedItemSettled: SharedValue; /** * Shifted item overlay positions captured at drop time. * Key is itemId, value is visual position info at capture time. * Used to render overlay copies that mask the FlashList repositioning glitch. */ shiftedOverlays: SharedValue< Record< string, { baseY: number; // Visual Y position at capture (viewport-relative) height: number; // Item height shift: number; // Shift value at capture (for validation) scrollAtCapture: number; // Scroll offset at capture (for scroll tracking) } > >; /** * Whether shifted item overlays are currently active. * When true, real shifted items are hidden and overlays are shown. */ shiftedOverlaysActive: SharedValue; /** * v40.6: Whether shifted item overlays have actually rendered (JS thread confirmation). * Only hide real items when this is true to prevent flicker. */ shiftedOverlaysRendered: SharedValue; /** * Counter for atomic unmask - tracks how many items have captured overlays. * Overlays are only cleared when this reaches 0 (all items have snapped). */ pendingOverlayCount: SharedValue; /** * v40.3: Flag indicating reorder has been requested after settle. * Phase gate: cleanup requires BOTH reorderRequested=true AND pendingOverlayCount=0. * This prevents premature cleanup before reorder actually executes. */ reorderRequested: SharedValue; /** * Crossfade opacity for drag overlay (0..1). * Animated on UI thread to create smooth crossfade between overlay and original. * Original item uses (1 - dragOverlayOpacity) for perfect crossfade. */ dragOverlayOpacity: SharedValue; /** * Frozen item ID that keeps overlay renderable during fade-out. * Set when fade-out starts, cleared when fade completes. * This prevents overlay from unmounting before crossfade finishes. */ overlayFrozenItemId: SharedValue; /** * UI-thread signal that is set to true when the overlay has actually rendered. * Set from useAnimatedStyle in DragOverlay (UI thread), not from useEffect (JS thread). * This prevents the original from fading out before the overlay appears. */ overlayRenderedOnUI: SharedValue; } const DragStateContext = createContext(null); /** * Hook to access shared drag state from context. * Must be used within DragStateProvider. */ export const useDragState = (): DragStateContextValue => { const context = useContext(DragStateContext); if (!context) { throw new Error("useDragState must be used within DragStateProvider"); } return context; }; interface DragStateProviderProps { children: ReactNode; /** Optional drag configuration overrides */ config?: Partial; } /** * Provider that creates shared Reanimated values for drag state. * * These values are shared across all list items: * - The dragged item writes to them during drag gestures * - Non-dragged items read them to calculate their shift offset * - Scroll state enables viewport-aware hover calculations * * Using SharedValues ensures animations run on the UI thread at 60fps. */ /** * Validates drag configuration values to prevent runtime errors. * Logs warnings in development for invalid configurations. */ function validateConfig(config: DragConfig): void { if (__DEV__) { if (config.itemHeight <= 0) { console.warn( "[AnimatedFlashList] Invalid itemHeight: must be positive. Got:", config.itemHeight, ); } if (config.longPressDuration <= 0) { console.warn( "[AnimatedFlashList] Invalid longPressDuration: must be positive. Got:", config.longPressDuration, ); } if (config.dragScale <= 0) { console.warn( "[AnimatedFlashList] Invalid dragScale: must be positive. Got:", config.dragScale, ); } if (config.edgeThreshold < 0) { console.warn( "[AnimatedFlashList] Invalid edgeThreshold: must be non-negative. Got:", config.edgeThreshold, ); } if (config.maxScrollSpeed <= 0) { console.warn( "[AnimatedFlashList] Invalid maxScrollSpeed: must be positive. Got:", config.maxScrollSpeed, ); } } } export const DragStateProvider: React.FC = ({ children, config: configOverrides, }) => { // Merge config with defaults and validate const config = useMemo(() => { const mergedConfig = { ...DEFAULT_DRAG_CONFIG, ...configOverrides }; validateConfig(mergedConfig); return mergedConfig; }, [configOverrides]); // Shared values are created once and persist for the lifetime of the provider const isDragging = useSharedValue(false); const draggedIndex = useSharedValue(-1); const draggedItemId = useSharedValue(""); const currentTranslateY = useSharedValue(0); const draggedScale = useSharedValue(1); // Scroll state for viewport-aware calculations and autoscroll const scrollOffset = useSharedValue(0); const dragStartScrollOffset = useSharedValue(0); const contentHeight = useSharedValue(0); const visibleHeight = useSharedValue(0); const listTopY = useSharedValue(0); const listLeftX = useSharedValue(0); // Measured height of dragged item (0 = use fallback from config) const measuredItemHeight = useSharedValue(0); // Flag to freeze shift values during drop transition const isDropping = useSharedValue(false); // Counter incremented on each drag start, used to force shift recalculation const dragSequence = useSharedValue(0); // Expected index after drop (for handoff detection) const expectedDropIndex = useSharedValue(-1); // Frozen mismatch offset computed at drop time to prevent double compensation // This ensures mismatchOffset + currentTranslateY remains constant even if // useAnimatedStyle evaluates between registry update and currentTranslateY adjustment const dropMismatchOffset = useSharedValue(0); const layoutCommitSignal = useSharedValue(0); const uiLayoutCommitSignal = useSharedValue(0); const dropStartCommitSignal = useSharedValue(0); // Index registry for tracking itemId -> index mapping // This handles FlashList's recycling behavior where components may not re-render // after data changes, leaving their index props stale const itemIndexRegistry = useSharedValue>({}); // Snapshot of registry taken at drag start for consistent index lookup during drag const dragStartIndexSnapshot = useSharedValue>({}); // Total number of items in the list (synced from data.length by InnerFlashList) const totalItems = useSharedValue(0); // Signal incremented after reorder callback completes and React has had // time to commit. useDragShift watches this to unfreeze shifted items. const reorderCompleteSignal = useSharedValue(0); // Signal incremented when a drop is ready to reorder (UI thread) const reorderRequestSignal = useSharedValue(0); const reorderInFlight = useSharedValue(false); const dropCommitVersion = useSharedValue(0); const dropCommitVersionUI = useSharedValue(0); const hoveredIndex = useSharedValue(-1); const overlayActive = useSharedValue(false); const overlayItemId = useSharedValue(""); const overlayBaseX = useSharedValue(0); const overlayBaseY = useSharedValue(0); const overlayWidth = useSharedValue(0); const overlayHeight = useSharedValue(0); const overlayTranslateY = useSharedValue(0); const overlayReady = useSharedValue(false); const overlayVisible = useSharedValue(false); const layoutDeltaY = useSharedValue(0); const overlayContainerX = useSharedValue(0); const overlayContainerY = useSharedValue(0); const overlayContainerYLayout = useSharedValue(0); const overlayContainerReady = useSharedValue(false); const overlayTouchOffset = useSharedValue(0); const overlayAbsoluteY = useSharedValue(0); const dragContainerRef = useAnimatedRef(); const reorderTokenRef = useRef(0); // Flag indicating dragged item's settle animation has completed const draggedItemSettled = useSharedValue(false); // Shifted item overlay positions captured at drop time const shiftedOverlays = useSharedValue< Record< string, { baseY: number; height: number; shift: number; scrollAtCapture: number; } > >({}); // Whether shifted item overlays are currently active const shiftedOverlaysActive = useSharedValue(false); // v40.6: Whether shifted item overlays have actually rendered (JS thread confirmation) const shiftedOverlaysRendered = useSharedValue(false); // Counter for atomic unmask - tracks pending overlay captures const pendingOverlayCount = useSharedValue(0); // v40.3: Flag indicating reorder has been requested after settle const reorderRequested = useSharedValue(false); // Crossfade opacity for drag overlay (0 = hidden, 1 = fully visible) const dragOverlayOpacity = useSharedValue(0); // Frozen item ID to keep overlay renderable during fade-out const overlayFrozenItemId = useSharedValue(""); // UI-thread signal that overlay has actually rendered const overlayRenderedOnUI = useSharedValue(false); // NOTE: The overlay crossfade reaction has been moved to useOverlayCrossfade hook. // It is called from the DragOverlay component for better separation of concerns. // The hook only uses UI-thread signals (overlayActive, overlayReady, overlayItemId) // and NOT overlayVisible which has JS-thread scheduling delays. // Ref to FlashList for autoscroll operations const listRef = useRef | null>(null); // Register the FlashList ref for autoscroll operations const setListRef = useCallback((ref: FlashListRef | null) => { listRef.current = ref; }, []); // Scroll to offset (called via scheduleOnRN from worklet for autoscroll) const scrollToOffset = useCallback((offset: number, animated = false) => { listRef.current?.scrollToOffset({ offset, animated }); }, []); // Request FlashList to prepare cells for layout animation on next render const prepareForLayoutAnimationRender = useCallback(() => { listRef.current?.prepareForLayoutAnimationRender?.(); }, []); const notifyLayoutCommit = useCallback(() => { layoutCommitSignal.value += 1; }, [layoutCommitSignal]); const markReorderToken = useCallback(() => { reorderTokenRef.current += 1; }, []); const getReorderToken = useCallback(() => { return reorderTokenRef.current; }, []); // Update an item's index in the registry // Uses scheduleOnUI to ensure SharedValue modifications are immediately visible // to UI thread worklets (fixing the registry sync bug). // Uses state-based guards (isDragging/isDropping) to prevent stale React props // from overwriting correct indices during active drag/drop operations. const updateItemIndex = useCallback( (itemId: string, index: number) => { // Read current value (may be slightly stale from JS thread, but OK for guard logic) const existingIndex = itemIndexRegistry.value[itemId]; // Add new items always - schedule on UI thread for immediate visibility (or synchronous fallback in tests) if (existingIndex === undefined) { scheduleOnUIOrImmediate( updateRegistryOnUI, itemIndexRegistry, itemId, index, ); return; } // Skip updates during active DRAG (not drop!) // During drag: stale React props could corrupt indices // During drop: we NEED updates to detect when FlashList repositions items // This is critical for flicker-free drop compensation if (isDragging.value) { return; } // Outside active drag/drop, update if value changed - schedule on UI thread if (existingIndex !== index) { scheduleOnUIOrImmediate( updateRegistryOnUI, itemIndexRegistry, itemId, index, ); } }, [itemIndexRegistry, isDragging], ); // Get an item's index from the registry const getItemIndex = useCallback( (itemId: string): number | undefined => { return itemIndexRegistry.value[itemId]; }, [itemIndexRegistry], ); // Capture a snapshot of the registry at drag start // This ensures consistent index lookup during the entire drag operation, // regardless of React re-renders or FlashList recycling const snapshotRegistryForDrag = useCallback(() => { dragStartIndexSnapshot.value = { ...itemIndexRegistry.value }; }, [dragStartIndexSnapshot, itemIndexRegistry]); // Update registry after reorder to handle FlashList not re-rendering const applyReorderToRegistry = useCallback( (fromId: string, fromIndex: number, toIndex: number) => { const registry = itemIndexRegistry.value; const newRegistry = { ...registry }; if (toIndex > fromIndex) { // Moving down: items between from and to shift UP (index decreases) for (const [id, idx] of Object.entries(registry)) { if (idx > fromIndex && idx <= toIndex) { newRegistry[id] = idx - 1; } } } else if (toIndex < fromIndex) { // Moving up: items between to and from shift DOWN (index increases) for (const [id, idx] of Object.entries(registry)) { if (idx >= toIndex && idx < fromIndex) { newRegistry[id] = idx + 1; } } } newRegistry[fromId] = toIndex; itemIndexRegistry.value = newRegistry; }, [itemIndexRegistry], ); // Reset drag state after drop // Note: We intentionally do NOT clear itemIndexRegistry here. // The registry persists across drags and is updated by: // 1. applyReorderToRegistry() in useDragGesture.onEnd after each reorder // 2. updateItemIndex() when items render // Clearing it would cause subsequent drags to fall back to stale React props. const resetDragState = useCallback(() => { isDragging.value = false; draggedIndex.value = -1; draggedItemId.value = ""; currentTranslateY.value = 0; draggedScale.value = 1; dragStartScrollOffset.value = 0; measuredItemHeight.value = 0; isDropping.value = false; expectedDropIndex.value = -1; dropMismatchOffset.value = 0; reorderInFlight.value = false; dropCommitVersion.value = 0; dropCommitVersionUI.value = 0; hoveredIndex.value = -1; overlayActive.value = false; overlayItemId.value = ""; overlayBaseX.value = 0; overlayBaseY.value = 0; overlayWidth.value = 0; overlayHeight.value = 0; overlayTranslateY.value = 0; overlayReady.value = false; overlayVisible.value = false; layoutDeltaY.value = 0; overlayContainerX.value = 0; overlayContainerY.value = 0; overlayContainerYLayout.value = 0; overlayContainerReady.value = false; overlayTouchOffset.value = 0; overlayAbsoluteY.value = 0; draggedItemSettled.value = false; shiftedOverlays.value = {}; shiftedOverlaysActive.value = false; shiftedOverlaysRendered.value = false; pendingOverlayCount.value = 0; reorderRequested.value = false; dragOverlayOpacity.value = 0; overlayFrozenItemId.value = ""; overlayRenderedOnUI.value = false; dropStartCommitSignal.value = 0; }, [ isDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, dragStartScrollOffset, measuredItemHeight, isDropping, expectedDropIndex, dropMismatchOffset, reorderInFlight, dropCommitVersion, dropCommitVersionUI, hoveredIndex, overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayWidth, overlayHeight, overlayTranslateY, overlayReady, overlayVisible, layoutDeltaY, overlayContainerX, overlayContainerY, overlayContainerYLayout, overlayContainerReady, overlayTouchOffset, overlayAbsoluteY, draggedItemSettled, shiftedOverlays, shiftedOverlaysActive, shiftedOverlaysRendered, pendingOverlayCount, reorderRequested, dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, dropStartCommitSignal, ]); // Context value is stable since SharedValue references don't change const value = useMemo( () => ({ isDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, scrollOffset, dragStartScrollOffset, contentHeight, visibleHeight, listTopY, listLeftX, measuredItemHeight, isDropping, dragSequence, expectedDropIndex, dropMismatchOffset, layoutCommitSignal, uiLayoutCommitSignal, setListRef, scrollToOffset, prepareForLayoutAnimationRender, notifyLayoutCommit, resetDragState, config, itemIndexRegistry, updateItemIndex, getItemIndex, dragStartIndexSnapshot, snapshotRegistryForDrag, applyReorderToRegistry, totalItems, reorderCompleteSignal, reorderRequestSignal, reorderInFlight, dropCommitVersion, dropCommitVersionUI, hoveredIndex, overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayWidth, overlayHeight, overlayTranslateY, overlayReady, overlayVisible, layoutDeltaY, overlayContainerX, overlayContainerY, overlayContainerYLayout, overlayContainerReady, overlayTouchOffset, overlayAbsoluteY, dragContainerRef, markReorderToken, getReorderToken, draggedItemSettled, shiftedOverlays, shiftedOverlaysActive, shiftedOverlaysRendered, pendingOverlayCount, reorderRequested, dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, dropStartCommitSignal, }), [ isDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, scrollOffset, dragStartScrollOffset, contentHeight, visibleHeight, listTopY, listLeftX, measuredItemHeight, isDropping, dragSequence, expectedDropIndex, dropMismatchOffset, layoutCommitSignal, uiLayoutCommitSignal, setListRef, scrollToOffset, prepareForLayoutAnimationRender, notifyLayoutCommit, resetDragState, config, itemIndexRegistry, updateItemIndex, getItemIndex, dragStartIndexSnapshot, snapshotRegistryForDrag, applyReorderToRegistry, totalItems, reorderCompleteSignal, reorderRequestSignal, reorderInFlight, dropCommitVersion, dropCommitVersionUI, hoveredIndex, overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayWidth, overlayHeight, overlayTranslateY, overlayReady, overlayVisible, layoutDeltaY, overlayContainerX, overlayContainerY, overlayContainerYLayout, overlayContainerReady, overlayTouchOffset, overlayAbsoluteY, dragContainerRef, markReorderToken, getReorderToken, draggedItemSettled, shiftedOverlays, shiftedOverlaysActive, shiftedOverlaysRendered, pendingOverlayCount, reorderRequested, dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, dropStartCommitSignal, ], ); return ( {children} ); };