import { useCallback, useRef } from 'react'; import { useAnimatedReaction, withTiming, cancelAnimation, type SharedValue, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import { useDragState } from '../../contexts/DragStateContext'; import { TIMING_CONFIGS } from '../../constants/drag'; import { resetCoreDragState, resetOverlayState, resetDropState as resetDropStateUtil, } from '../../utils/dragStateReset'; /** * Configuration for useDropHandler hook. */ export interface UseDropHandlerConfig { /** Item ID for identity check */ itemId: string; /** This item's translateY SharedValue (from useDragGesture) */ translateY: SharedValue; } /** * Callbacks for useDropHandler. */ export interface UseDropHandlerCallbacks { /** Called when reorder should occur (with index delta) */ onReorderByDelta?: (itemId: string, delta: number) => void; /** Optional haptic feedback callback */ onHapticFeedback?: (type: 'light' | 'medium') => void; /** Called to clear the safety timeout */ clearSafetyTimeout: () => void; /** Called to start failsafe cleanup timeout */ failsafeCleanup: () => void; } /** * Return value from useDropHandler hook. */ export interface UseDropHandlerResult { /** Handle drag end - calculates position and triggers reorder */ handleDragEnd: (positionDelta: number, startIndex: number) => void; /** Reset drop state (called as safety fallback) */ resetDropState: () => void; } /** * Hook that handles drop logic, including position calculation, reorder triggering, * and state reset. * * This hook is responsible for: * - Calculating whether position actually changes on drop * - Storing pending reorder data for animation completion * - Triggering the reorder callback after animation * - Resetting drag state after drop completes * * @param config - Configuration with itemId and translateY * @param callbacks - Callbacks for reorder, haptic feedback, and cleanup * @returns Functions for handling drag end and resetting drop state */ export function useDropHandler( config: UseDropHandlerConfig, callbacks: UseDropHandlerCallbacks, ): UseDropHandlerResult { const { itemId, translateY } = config; const { onReorderByDelta, onHapticFeedback, clearSafetyTimeout, failsafeCleanup, } = callbacks; // Global drag state const { isDragging: globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, measuredItemHeight, isDropping, expectedDropIndex, reorderInFlight, dropCommitVersion, dragSequence, totalItems, overlayActive, overlayItemId, overlayTranslateY, overlayReady, overlayVisible, draggedItemSettled, reorderRequestSignal, prepareForLayoutAnimationRender, markReorderToken, } = useDragState(); // Store current callbacks in ref for stable gesture callbacks const callbacksRef = useRef({ itemId, onReorderByDelta, }); callbacksRef.current = { itemId, onReorderByDelta, }; // Store pending reorder data - called AFTER animation completes // This is the key fix: we delay the onReorder callback until the visual animation // is complete, preventing FlashList from re-rendering during the drop transition. const pendingReorderRef = useRef<{ itemId: string; positionDelta: number; } | null>(null); const reorderRequestedRef = useRef(false); // Callback to trigger pending reorder after registry confirmation const triggerPendingReorder = useCallback(() => { const pending = pendingReorderRef.current; if (!pending) return; const { onReorderByDelta: reorder } = callbacksRef.current; if (reorder) { // 1) Pause recycling for next frame prepareForLayoutAnimationRender(); // v36b: We do NOT call LayoutAnimation.configureNext() anymore. // LayoutAnimation only animates NATIVE layout changes, not Reanimated transforms. // Our shift changes are Reanimated transforms, so LayoutAnimation can't help. // Instead, we snap shifts to 0 when we detect registry changes (in useDragAnimatedStyle). // 2) Mark reorder token markReorderToken(); reorderRequestedRef.current = true; // 3) Call reorder - FlashList will reposition cells instantly reorder(pending.itemId, pending.positionDelta); // v40.3: Start failsafe timeout after reorder is called failsafeCleanup(); } pendingReorderRef.current = null; // NOTE: draggedItemId is NOT cleared here! // It must remain set so the layout commit reaction in useDragAnimatedStyle.ts // can correctly identify which item was dragged and clear ALL drag state // atomically after FlashList commits the new layout. }, [markReorderToken, prepareForLayoutAnimationRender, failsafeCleanup]); // Watch for reorder requests from UI thread and trigger the pending reorder useAnimatedReaction( () => reorderRequestSignal.value, (current, prev) => { 'worklet'; if (prev === null || current === prev) return; if (draggedItemId.value !== itemId) return; scheduleOnRN(triggerPendingReorder); }, ); // Callback to reset all drag state after drop completes // Called as safety fallback if animation callback doesn't fire const resetDropState = useCallback(() => { // Idempotency guard: only reset if still in dropping state if (!isDropping.value && !globalIsDragging.value) { return; } // Reset core drag state resetCoreDragState({ globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, measuredItemHeight, draggedItemSettled, }); // Reset drop state resetDropStateUtil({ isDropping, expectedDropIndex, reorderInFlight, dropCommitVersion, }); // Reset full overlay state (immediate cleanup, not crossfade) overlayActive.value = false; overlayItemId.value = ''; overlayTranslateY.value = 0; overlayReady.value = false; overlayVisible.value = false; }, [ globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, measuredItemHeight, isDropping, expectedDropIndex, reorderInFlight, dropCommitVersion, overlayActive, overlayItemId, overlayTranslateY, overlayReady, overlayVisible, draggedItemSettled, ]); // Calculate new position and call reorder callback const handleDragEnd = useCallback( (positionDelta: number, startIndex: number) => { const { itemId: currentItemId, onReorderByDelta: reorder, } = callbacksRef.current; const total = totalItems.value; // Use passed startIndex instead of reading from draggedIndex.value // This is necessary because the handoff in useDropCompensation may have // already reset draggedIndex.value to -1 by the time this runs const currentIndex = startIndex; if (currentIndex === -1) { // Drag was cancelled or reset return; } // Calculate if position actually changes const newIndex = Math.max( 0, Math.min(total - 1, currentIndex + positionDelta), ); const positionChanged = reorder && positionDelta !== 0 && newIndex !== currentIndex; if (positionChanged) { // Position changes - store pending reorder and let animation complete first // KEY FIX: We do NOT call reorder() immediately! // Instead, we store the pending reorder data and trigger it AFTER the // settle animation completes (when isDropping becomes false). // This prevents FlashList from re-rendering during the drop transition, // which was causing the flicker. // // NOTE: isDropping and expectedDropIndex are already set on the UI thread // in onEnd. We do NOT set them here on the JS thread because handleDragEnd // may run after the settle animation completes and clears isDropping, which // would re-set it permanently. onHapticFeedback?.('medium'); // Store pending reorder - will be triggered after animation completes pendingReorderRef.current = { itemId: currentItemId, positionDelta, }; reorderRequestedRef.current = false; // Safety timeout: If the animation reaction doesn't complete (edge case), // ensure state is reset and reorder is triggered after a generous delay. // Clear any previous safety timeout first. clearSafetyTimeout(); // NOTE: The caller (useDragGesture) is responsible for setting up the // actual safety timeout since it owns the timeout ref. } else { // Same position - animate back and reset state // FIX: Reset overlayTranslateY to align crossfade positions. // Without this, overlay stays at finger position while original // returns to slot position = position mismatch during crossfade. overlayTranslateY.value = 0; // Capture current sequence to guard callback against race conditions const currentSequence = dragSequence.value; // Cancel any existing animation before starting the return animation cancelAnimation(translateY); translateY.value = withTiming( 0, TIMING_CONFIGS.RETURN_TO_ORIGIN, (finished) => { 'worklet'; // Guard: Only reset if this is still the same drag operation if (dragSequence.value !== currentSequence) return; if (finished) { // Reset core drag state resetCoreDragState({ globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, measuredItemHeight, draggedItemSettled, }); // Trigger crossfade fade-out by setting overlayActive = false. // The crossfade completion callback in useOverlayCrossfade will clear // the remaining overlay state AFTER the fade-out animation completes. resetOverlayState({ overlayActive }); // NOTE: Do NOT clear overlayReady, overlayItemId, overlayTranslateY, // or overlayVisible here! The crossfade completion callback handles this. } }, ); } }, [ measuredItemHeight, translateY, globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, isDropping, onHapticFeedback, dragSequence, totalItems, clearSafetyTimeout, overlayActive, overlayTranslateY, draggedItemSettled, ], ); return { handleDragEnd, resetDropState }; }