import { useCallback, useMemo, useEffect, useLayoutEffect, useRef, } from 'react'; import { useSharedValue, withSpring, withTiming, withSequence, cancelAnimation, makeMutable, Easing, type SharedValue, } from 'react-native-reanimated'; import { Gesture, type GestureType } from 'react-native-gesture-handler'; import { scheduleOnRN } from 'react-native-worklets'; import { useRecyclingState } from '@shopify/flash-list'; import { useDragState } from '../../contexts/DragStateContext'; import { DROP_DEBUG_LOGS, SPRING_CONFIGS, TIMING_CONFIGS, ANIMATION_TIMING, } from '../../constants/drag'; import { calculateHoverIndex, calculateDropIndex } from '../../utils/hoverCalculation'; import { resetCoreDragState, resetDropState as resetDropStateUtil, resetFullOverlayState, } from '../../utils/dragStateReset'; import { checkAutoscroll } from './useAutoscroll'; import { useDropHandler } from './useDropHandler'; import { useDragOverlay } from './useDragOverlay'; import { useDragCleanup } from './useDragCleanup'; import type { UseDragGestureConfig, UseDragGestureCallbacks, UseDragGestureResult, } from '../../types'; let DISABLED_SHARED_VALUE: SharedValue | null = null; let DISABLED_TRANSLATE_Y: SharedValue | null = null; function getDisabledSharedValue(): SharedValue { if (!DISABLED_SHARED_VALUE) { DISABLED_SHARED_VALUE = makeMutable(false); } return DISABLED_SHARED_VALUE; } function getDisabledTranslateY(): SharedValue { if (!DISABLED_TRANSLATE_Y) { DISABLED_TRANSLATE_Y = makeMutable(0); } return DISABLED_TRANSLATE_Y; } let cachedDisabledGesture: GestureType | null = null; function getDisabledGesture(): GestureType { if (!cachedDisabledGesture) { cachedDisabledGesture = Gesture.Pan().enabled(false); } return cachedDisabledGesture; } /** * Hook that encapsulates all drag gesture logic. * * Handles pan gesture with long press activation, autoscroll near viewport edges, * haptic feedback, scale animation, and drop position calculation. * * @example * ```tsx * const { panGesture, isDragging, translateY } = useDragGesture( * { itemId: item.id, index, totalItems, enabled: true, containerRef }, * { onReorderByDelta: handleReorder, onHapticFeedback: triggerHaptic } * ); * * * * * ``` */ export function useDragGesture( config: UseDragGestureConfig, callbacks: UseDragGestureCallbacks, ): UseDragGestureResult { const { itemId, index, enabled, containerRef } = config; const { onReorderByDelta, onHapticFeedback } = callbacks; const wasEverEnabled = useRef(enabled); if (enabled) wasEverEnabled.current = true; if (!wasEverEnabled.current) { return { panGesture: getDisabledGesture(), isDragging: getDisabledSharedValue(), translateY: getDisabledTranslateY(), }; } const isDragging = useSharedValue(false); const translateY = useSharedValue(0); const { isDragging: globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, draggedScale, scrollOffset, dragStartScrollOffset, contentHeight, visibleHeight, listTopY, measuredItemHeight, isDropping, expectedDropIndex, dropMismatchOffset, scrollToOffset, config: dragConfig, itemIndexRegistry, dragStartIndexSnapshot, dragSequence, totalItems, reorderInFlight, dropCommitVersion, dropCommitVersionUI, layoutCommitSignal, uiLayoutCommitSignal, hoveredIndex, overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayTranslateY, overlayReady, overlayVisible, overlayTouchOffset, overlayAbsoluteY, overlayContainerY, draggedItemSettled, dropStartCommitSignal, } = useDragState(); const { clearSafetyTimeout, failsafeCleanup, safetyTimeoutRef } = useDragCleanup(); const { measureAndSetupOverlay } = useDragOverlay({ containerRef }); const { handleDragEnd, resetDropState } = useDropHandler( { itemId, translateY }, { onReorderByDelta, onHapticFeedback, clearSafetyTimeout, failsafeCleanup, }, ); const lastSignificantY = useSharedValue(0); const lastScrollTime = useSharedValue(0); const currentIndex = useSharedValue(index); const [prevItemId, setPrevItemId] = useRecyclingState(itemId, [itemId]); useLayoutEffect(() => { if (prevItemId !== itemId) { cancelAnimation(translateY); translateY.value = 0; isDragging.value = false; currentIndex.value = index; setPrevItemId(itemId); } }, [itemId, prevItemId, setPrevItemId, translateY, isDragging, currentIndex, index]); useEffect(() => { currentIndex.value = index; }, [index, currentIndex]); const triggerLightHaptic = useCallback(() => { onHapticFeedback?.('light'); }, [onHapticFeedback]); const triggerMediumHaptic = useCallback(() => { if (dragConfig.dropHapticEnabled) { onHapticFeedback?.('medium'); } }, [onHapticFeedback, dragConfig.dropHapticEnabled]); const handleDragEndWithTimeout = useCallback( (positionDelta: number, startIndex: number) => { const total = totalItems.value; const currentIdx = startIndex; if (currentIdx === -1) return; const newIndex = Math.max(0, Math.min(total - 1, currentIdx + positionDelta)); const positionChanged = onReorderByDelta && positionDelta !== 0 && newIndex !== currentIdx; handleDragEnd(positionDelta, startIndex); if (positionChanged) { if (safetyTimeoutRef.current !== null) { clearTimeout(safetyTimeoutRef.current); } const safetyTimeoutMs = 500; safetyTimeoutRef.current = setTimeout(() => { safetyTimeoutRef.current = null; if (isDropping.value) { resetDropState(); } }, safetyTimeoutMs); } }, [handleDragEnd, totalItems, onReorderByDelta, isDropping, resetDropState, safetyTimeoutRef], ); const panGesture = useMemo( () => Gesture.Pan() .activateAfterLongPress(dragConfig.longPressDuration) .blocksExternalGesture() .enabled(enabled) .onBegin((event) => { 'worklet'; overlayAbsoluteY.value = event.absoluteY; }) .onStart((event) => { 'worklet'; scheduleOnRN(clearSafetyTimeout); if (isDropping.value) { isDropping.value = false; reorderInFlight.value = false; cancelAnimation(currentTranslateY); currentTranslateY.value = 0; draggedItemSettled.value = false; } cancelAnimation(translateY); cancelAnimation(draggedScale); translateY.value = 0; dragStartIndexSnapshot.value = { ...itemIndexRegistry.value }; dragSequence.value = dragSequence.value + 1; const measuredHeight = measureAndSetupOverlay({ absoluteY: event.absoluteY }); if (measuredHeight > 0) { measuredItemHeight.value = measuredHeight; } isDragging.value = true; globalIsDragging.value = true; const registryIndex = itemIndexRegistry.value[itemId]; const actualIndex = registryIndex !== undefined ? registryIndex : currentIndex.value; draggedIndex.value = actualIndex; currentIndex.value = actualIndex; draggedItemId.value = itemId; overlayItemId.value = itemId; overlayTranslateY.value = 0; dragStartScrollOffset.value = scrollOffset.value; currentTranslateY.value = 0; hoveredIndex.value = actualIndex; lastSignificantY.value = 0; lastScrollTime.value = Date.now(); draggedScale.value = withSpring(dragConfig.dragScale, SPRING_CONFIGS.DRAG_SCALE); scheduleOnRN(triggerLightHaptic); }) .onUpdate((event) => { 'worklet'; translateY.value = event.translationY; overlayTranslateY.value = event.translationY; if (overlayVisible.value) { overlayAbsoluteY.value = event.absoluteY; } const POSITION_THRESHOLD = 5; const deltaFromLast = Math.abs(event.translationY - lastSignificantY.value); if (deltaFromLast >= POSITION_THRESHOLD) { currentTranslateY.value = event.translationY; lastSignificantY.value = event.translationY; } // Always use config.itemHeight - it includes margins which matches FlashList positioning const itemHeight = dragConfig.itemHeight; const scrollDelta = scrollOffset.value - dragStartScrollOffset.value; const effectiveTranslateY = event.translationY + scrollDelta; const hoverResult = calculateHoverIndex({ effectiveTranslateY, itemHeight, draggedIndex: draggedIndex.value, totalItems: totalItems.value, currentHoveredIndex: hoveredIndex.value, }); if (__DEV__ && DROP_DEBUG_LOGS && hoverResult.proposedIndex !== hoveredIndex.value) { console.log( `[DragGesture:${itemId}] hover change: prev=${hoveredIndex.value} -> next=${hoverResult.proposedIndex} ` + `y=${effectiveTranslateY.toFixed(2)}`, ); } hoveredIndex.value = hoverResult.proposedIndex; checkAutoscroll( event.absoluteY, { scrollOffset, contentHeight, visibleHeight, listTopY, lastScrollTime }, { edgeThreshold: dragConfig.edgeThreshold, maxScrollSpeed: dragConfig.maxScrollSpeed }, scrollToOffset, ); }) .onEnd((event) => { 'worklet'; const scrollDelta = scrollOffset.value - dragStartScrollOffset.value; const finalY = event.translationY + scrollDelta; const squishScale = dragConfig.dropBounceScale; draggedScale.value = withSequence( withTiming(squishScale, { duration: ANIMATION_TIMING.DROP_SQUISH_DURATION, easing: Easing.out(Easing.ease), }), withSpring(1, SPRING_CONFIGS.DROP_BOUNCE), ); scheduleOnRN(triggerMediumHaptic); const currentIdx = draggedIndex.value; const total = totalItems.value; const itemHeight = dragConfig.itemHeight; const prevHoveredIndex = hoveredIndex.value; const hoveredIndexAtEnd = prevHoveredIndex >= 0 ? prevHoveredIndex : calculateDropIndex({ finalY, itemHeight, draggedIndex: currentIdx, totalItems: total, hoveredIndexAtEnd: currentIdx, }).snapIndex; const dropResult = calculateDropIndex({ finalY, itemHeight, draggedIndex: currentIdx, totalItems: total, hoveredIndexAtEnd, }); const { snapIndex, positionDelta, snappedFinalY, rawDropIndex } = dropResult; hoveredIndex.value = snapIndex; if (__DEV__ && DROP_DEBUG_LOGS) { console.log( `[DragGesture:${itemId}] drop math: currentIdx=${currentIdx} prevHover=${prevHoveredIndex} ` + `endHover=${hoveredIndexAtEnd} delta=${positionDelta} ` + `finalY=${finalY.toFixed(2)} raw=${rawDropIndex.toFixed(2)} ` + `snapY=${snappedFinalY.toFixed(2)} snapIndex=${snapIndex}`, ); if (snapIndex !== hoveredIndexAtEnd) { console.log( `[DragGesture:${itemId}] drop mismatch: hoverIndex=${hoveredIndexAtEnd} snapIndex=${snapIndex}`, ); } } // Continuity calculation for position-neutral formula switch const fingerPosition = overlayAbsoluteY.value - overlayTouchOffset.value - overlayContainerY.value; const currentScrollDelta = scrollOffset.value - dragStartScrollOffset.value; const continuityTranslateY = fingerPosition - overlayBaseY.value + currentScrollDelta; currentTranslateY.value = snappedFinalY; overlayTranslateY.value = continuityTranslateY; const newIdx = Math.max(0, Math.min(total - 1, currentIdx + positionDelta)); if (positionDelta !== 0 && newIdx !== currentIdx) { dropCommitVersion.value = layoutCommitSignal.value; dropCommitVersionUI.value = uiLayoutCommitSignal.value; dropStartCommitSignal.value = layoutCommitSignal.value; isDropping.value = true; expectedDropIndex.value = newIdx; isDragging.value = false; globalIsDragging.value = false; overlayTranslateY.value = withTiming(snappedFinalY, TIMING_CONFIGS.OVERLAY_SETTLE); const reactIndex = currentIndex.value; const mismatch = newIdx - reactIndex; const mismatchOffset = mismatch * dragConfig.itemHeight; dropMismatchOffset.value = mismatchOffset; } else { isDragging.value = false; overlayTranslateY.value = withTiming(snappedFinalY, TIMING_CONFIGS.OVERLAY_SETTLE); } const capturedIndex = currentIdx; scheduleOnRN(handleDragEndWithTimeout, positionDelta, capturedIndex); }) .onFinalize((_event, success) => { 'worklet'; if (!success) { translateY.value = withTiming(0, TIMING_CONFIGS.RETURN_TO_ORIGIN); isDragging.value = false; hoveredIndex.value = -1; draggedScale.value = 1; resetCoreDragState({ globalIsDragging, draggedIndex, draggedItemId, currentTranslateY, dragStartScrollOffset, measuredItemHeight, draggedItemSettled, }); resetDropStateUtil({ isDropping, expectedDropIndex, reorderInFlight, dropCommitVersion, }); resetFullOverlayState({ overlayActive, overlayItemId, overlayTranslateY, overlayReady, overlayVisible, }); } }), [ enabled, clearSafetyTimeout, triggerLightHaptic, triggerMediumHaptic, handleDragEndWithTimeout, measureAndSetupOverlay, itemId, dragConfig, dragStartIndexSnapshot, itemIndexRegistry, totalItems, isDropping, currentTranslateY, translateY, draggedScale, isDragging, globalIsDragging, draggedIndex, draggedItemId, dragStartScrollOffset, measuredItemHeight, reorderInFlight, dropCommitVersion, dropCommitVersionUI, layoutCommitSignal, uiLayoutCommitSignal, hoveredIndex, scrollOffset, visibleHeight, listTopY, contentHeight, lastSignificantY, lastScrollTime, currentIndex, dragSequence, expectedDropIndex, dropMismatchOffset, overlayActive, overlayItemId, overlayBaseX, overlayBaseY, overlayTranslateY, overlayReady, overlayTouchOffset, overlayAbsoluteY, overlayContainerY, overlayVisible, scrollToOffset, draggedItemSettled, dropStartCommitSignal, ], ); return { panGesture, isDragging, translateY }; }