import { useLayoutEffect, useRef } from "react"; import { useAnimatedStyle, interpolate, useSharedValue, useAnimatedReaction, withTiming, withSpring, cancelAnimation, Easing, } from "react-native-reanimated"; import type { SharedValue, AnimatedRef } from "react-native-reanimated"; import type Animated from "react-native-reanimated"; import type { ViewStyle } from "react-native"; import { useRecyclingState } from "@shopify/flash-list"; import { useDragState } from "../../contexts/DragStateContext"; import { ANIMATION_TIMING, DRAG_THRESHOLDS, SPRING_CONFIGS, } from "../../constants/drag"; import type { UseDragAnimatedStyleResult } from "../../types"; const DISABLED_STYLE = {} as ViewStyle; /** * Hook that creates animated styles for drag operations. * * During drag: Smooth shift animations via animatedShiftY. * On drop START: Keep shifts frozen. * On registry change: Snap shifts to 0. * * @example * ```tsx * const { dragAnimatedStyle } = useDragAnimatedStyle( * item.id, isDragging, translateY, targetShiftY * ); * * * ... * * ``` */ export function useDragAnimatedStyle( itemId: string, isDragging: SharedValue, translateY: SharedValue, targetShiftY: SharedValue, containerRef: AnimatedRef, dragEnabled = true, ): UseDragAnimatedStyleResult & { hideOriginalOpacity?: number } { const wasEverEnabled = useRef(dragEnabled); if (dragEnabled) wasEverEnabled.current = true; if (!wasEverEnabled.current) { return { dragAnimatedStyle: DISABLED_STYLE }; } const { isDragging: globalIsDragging, draggedItemId, draggedIndex, draggedScale, config, scrollOffset, dragStartScrollOffset, isDropping, currentTranslateY, expectedDropIndex, dropMismatchOffset, itemIndexRegistry, overlayActive, overlayItemId, layoutCommitSignal, reorderRequestSignal, dropCommitVersion, dragStartIndexSnapshot, draggedItemSettled, shiftedOverlays, shiftedOverlaysActive, shiftedOverlaysRendered, pendingOverlayCount, reorderRequested, listTopY, overlayContainerY, overlayContainerYLayout, overlayContainerReady, // Crossfade support for hideOriginal opacity dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, } = useDragState(); void containerRef; const animatedShiftY = useSharedValue(0); const animatedShadowOpacity = useSharedValue(0.1); const animatedElevation = useSharedValue(4); const isSettling = useSharedValue(false); const dropStartIndex = useSharedValue(-1); // CRITICAL: Use FlashList's useRecyclingState to detect when cell is recycled const [prevItemId, setPrevItemId] = useRecyclingState(itemId, [itemId]); useLayoutEffect(() => { if (prevItemId !== itemId) { cancelAnimation(animatedShiftY); cancelAnimation(animatedElevation); animatedShiftY.value = 0; animatedShadowOpacity.value = 0.1; animatedElevation.value = 4; isSettling.value = false; dropStartIndex.value = -1; setPrevItemId(itemId); } }, [itemId, prevItemId, setPrevItemId, animatedShiftY, animatedShadowOpacity, animatedElevation, isSettling, dropStartIndex]); // Animate shift when target changes during drag useAnimatedReaction( () => ({ target: targetShiftY.value, isDragging: globalIsDragging.value, draggedId: draggedItemId.value, isDropping: isDropping.value, }), (state, prev) => { "worklet"; const isThisDraggedItem = state.draggedId === itemId; // EARLY EXIT: Skip computation when not dragging, not dropping, // and this item has no active shift animation. This reduces // worklet execution from O(n) to O(1) during idle periods. if ( !state.isDragging && !state.isDropping && !isThisDraggedItem && Math.abs(animatedShiftY.value) < 0.5 + DRAG_THRESHOLDS.FLOAT_EPSILON ) { return; } if (isThisDraggedItem) { if (Math.abs(animatedShiftY.value) > 0.5 + DRAG_THRESHOLDS.FLOAT_EPSILON) { cancelAnimation(animatedShiftY); animatedShiftY.value = 0; } return; } // When drop STARTS: Keep shifts frozen if (state.isDropping && !prev?.isDropping) { const t = state.target; if (Math.abs(t) > 0.5 + DRAG_THRESHOLDS.FLOAT_EPSILON) { cancelAnimation(animatedShiftY); animatedShiftY.value = t; } dropStartIndex.value = dragStartIndexSnapshot.value[itemId] ?? -1; return; } if (state.isDropping) { return; } if (prev?.isDropping && !state.isDropping) { dropStartIndex.value = -1; return; } if (state.isDragging) { if (state.target !== animatedShiftY.value) { cancelAnimation(animatedShiftY); animatedShiftY.value = withTiming(state.target, { duration: ANIMATION_TIMING.SHIFT_DURATION, easing: Easing.out(Easing.ease), }); } return; } if (Math.abs(animatedShiftY.value) > 0.5 + DRAG_THRESHOLDS.FLOAT_EPSILON) { cancelAnimation(animatedShiftY); animatedShiftY.value = 0; } }, ); // Snap shifts to 0 when registry changes during drop useAnimatedReaction( () => ({ registryIndex: itemIndexRegistry.value[itemId] ?? -1, isThisDragged: draggedItemId.value === itemId, }), (state, prev) => { "worklet"; if (prev === null) return; if (state.isThisDragged) return; if (prev.registryIndex !== state.registryIndex) { if (!globalIsDragging.value && Math.abs(animatedShiftY.value) > 0.5 + DRAG_THRESHOLDS.FLOAT_EPSILON) { cancelAnimation(animatedShiftY); animatedShiftY.value = 0; } if (shiftedOverlays.value[itemId]) { pendingOverlayCount.value -= 1; } } }, ); // Drop start handler - ALL items watch this (minimal deps for O(1) work per item) // Separated from dragged item coordinator to avoid O(n) worklet executions const MAX_SHIFTED_OVERLAYS = 10; useAnimatedReaction( () => ({ isDropping: isDropping.value, isThisDragged: draggedItemId.value === itemId, }), (state, prev) => { "worklet"; if (prev === null) return; // Handle drop START for ALL items if (state.isDropping && !prev.isDropping) { if (state.isThisDragged) { // Dragged item: start settling isSettling.value = true; } else { // Non-dragged items: capture shifted overlays const currentShift = targetShiftY.value; if (Math.abs(currentShift) < 0.5 - DRAG_THRESHOLDS.FLOAT_EPSILON) return; if (!overlayContainerReady.value) return; if (listTopY.value === 0 && overlayContainerY.value === 0) return; const registryIndex = itemIndexRegistry.value[itemId]; if (registryIndex === undefined) return; const currentOverlayCount = Object.keys(shiftedOverlays.value).length; if (currentOverlayCount >= MAX_SHIFTED_OVERLAYS) return; const nativeY = registryIndex * config.itemHeight; const currentScroll = scrollOffset.value; const listViewportTop = listTopY.value - overlayContainerYLayout.value; const visualY = nativeY - currentScroll + currentShift + listViewportTop; shiftedOverlays.value = { ...shiftedOverlays.value, [itemId]: { baseY: visualY, height: config.itemHeight, shift: currentShift, scrollAtCapture: currentScroll, }, }; pendingOverlayCount.value += 1; shiftedOverlaysActive.value = true; } return; } // Handle drop END (reset isSettling for dragged item) if (prev.isDropping && !state.isDropping && state.isThisDragged) { isSettling.value = false; } }, ); // Dragged item drop coordinator - ONLY runs for dragged item (many deps, but early exit for 14/15 items) useAnimatedReaction( () => ({ isThisDragged: draggedItemId.value === itemId, settling: isSettling.value, draggedSettled: draggedItemSettled.value, layoutCommit: layoutCommitSignal.value, pending: pendingOverlayCount.value, reorderReq: reorderRequested.value, }), (state, prev) => { "worklet"; // Early exit for non-dragged items - avoids O(n) worklet execution overhead if (!state.isThisDragged) return; if (prev === null) return; // Handle settling start if (state.settling && !prev.settling) { draggedItemSettled.value = false; currentTranslateY.value = withTiming(0, { duration: 150, easing: Easing.out(Easing.ease), }, (finished) => { 'worklet'; if (finished) { draggedItemSettled.value = true; } }); dropMismatchOffset.value = 0; } if (!isDropping.value) return; if (state.draggedSettled && !prev.draggedSettled) { const newSignal = reorderRequestSignal.value + 1; reorderRequestSignal.value = newSignal; isSettling.value = false; } const layoutCommitted = state.layoutCommit > dropCommitVersion.value; if (layoutCommitted && state.draggedSettled && !reorderRequested.value) { reorderRequested.value = true; } const reorderJustRequested = state.reorderReq && !prev.reorderReq; const pendingJustReached0 = state.pending <= 0 && prev.pending > 0; const shouldCleanup = state.reorderReq && state.pending <= 0 && (reorderJustRequested || pendingJustReached0); if (shouldCleanup) { shiftedOverlaysActive.value = false; shiftedOverlays.value = {}; pendingOverlayCount.value = 0; isDropping.value = false; reorderRequested.value = false; draggedIndex.value = -1; expectedDropIndex.value = -1; draggedItemId.value = ""; dropCommitVersion.value = 0; // Trigger crossfade fade-out. The completion callback in useOverlayCrossfade // will clear remaining overlay state after animation completes. overlayActive.value = false; } }, ); // Shadow opacity and elevation transitions (dragged item only) - combined for performance useAnimatedReaction( () => ({ isThisItemDragged: draggedItemId.value === itemId, scale: draggedScale.value, isDragging: globalIsDragging.value, isDropping: isDropping.value, }), (current, previous) => { "worklet"; // CRITICAL: Early exit for non-dragged items BEFORE any computation // This reduces worklet execution from O(n) to O(1) - only dragged/just-released item runs const wasEverDragged = current.isThisItemDragged || previous?.isThisItemDragged; if (!wasEverDragged) { return; // 14/15 items exit immediately } // Only dragged item (or just-released item) reaches here // === Shadow opacity logic === if (current.isThisItemDragged) { const targetOpacity = interpolate( current.scale, [1, config.dragScale], [0.1, config.dragShadowOpacity], ); animatedShadowOpacity.value = targetOpacity; } else { // Was dragged, now released - animate back animatedShadowOpacity.value = withTiming(0.1, { duration: ANIMATION_TIMING.SHADOW_FADE_DURATION, }); if (animatedElevation.value !== 4) { cancelAnimation(animatedElevation); animatedElevation.value = 4; } return; } // === Elevation logic (only for currently dragged item) === if (current.isDragging && !previous?.isDragging) { animatedElevation.value = withSpring(12, SPRING_CONFIGS.ELEVATION); return; } if (previous?.isDropping && !current.isDropping) { animatedElevation.value = withTiming(4, { duration: ANIMATION_TIMING.ELEVATION_DOWN_DURATION, }); return; } }, ); const dragAnimatedStyle = useAnimatedStyle(() => { // Fast path: On JS thread (initial render), return static fallback immediately. // The real calculations only run on UI thread where animations happen. // This avoids expensive work during initialization for ~60 worklets. if (!global._WORKLET) { return { transform: [{ translateY: 0 }, { scale: 1 }], zIndex: 0, shadowOpacity: 0.1, elevation: 4, opacity: 1, } as ViewStyle; } const isThisItemDragged = draggedItemId.value === itemId; const isOverlay = overlayActive.value && overlayItemId.value === itemId; if (isOverlay) { return { transform: [{ translateY: 0 }, { scale: 1 }], zIndex: 0, shadowOpacity: 0.1, elevation: 4, opacity: 1, } as ViewStyle; } let yOffset: number; if (isThisItemDragged) { if (isDragging.value) { const scrollDelta = scrollOffset.value - dragStartScrollOffset.value; yOffset = translateY.value + scrollDelta; } else { yOffset = currentTranslateY.value; } } else if (globalIsDragging.value || dropStartIndex.value !== -1) { yOffset = animatedShiftY.value; } else { yOffset = 0; } const isActivelyDragging = isThisItemDragged && isDragging.value; const shouldBeElevated = isThisItemDragged && (isDragging.value || isDropping.value); const baseZIndex = -(itemIndexRegistry.value[itemId] ?? 0); // === OPACITY CALCULATION (merged from hideOriginalStyle) === // Crossfade visibility: use inverse of overlay opacity for smooth transition // This ensures the original and overlay opacity always sum to 1, preventing // any frame where both are invisible (the "vanish" bug). let opacity = 1; const isThisFrozenOverlay = overlayFrozenItemId.value === itemId; // Crossfade path: applies during drag/drop OR while frozen overlay fades out if (isThisItemDragged || isThisFrozenOverlay) { // During drag/drop or while overlay is fading out, crossfade with overlay if (globalIsDragging.value || isDropping.value || isThisFrozenOverlay) { // FIX: Don't start fading out until overlay has rendered ON THE UI THREAD. // overlayRenderedOnUI is set from useAnimatedStyle (UI thread) in DragOverlay, // unlike overlayVisible which is set from useEffect (JS thread) causing races. // Without this guard, the original fades out before overlay appears = vanish. if (overlayRenderedOnUI.value) { // Always use crossfade - when overlay is at opacity X, // the original is at opacity (1-X), so they always sum to 1. opacity = 1 - dragOverlayOpacity.value; } } } else if ( // v41.4: Hide shifted items during drop ONLY after overlays have rendered. // Previously (v41.2) we hid immediately when shiftedOverlaysActive=true, // but overlays take ~2-5ms to render (React async), causing a blank flash. // The shift values are FROZEN during drop (v41.1), so originals stay correctly // positioned until overlays are ready. isDropping.value && shiftedOverlaysActive.value && shiftedOverlaysRendered.value && // Wait for React render shiftedOverlays.value[itemId] !== undefined ) { opacity = 0; } return { transform: [ { translateY: yOffset }, { scale: isActivelyDragging ? draggedScale.value : 1 }, ], zIndex: shouldBeElevated ? DRAG_THRESHOLDS.ELEVATED_Z_INDEX : baseZIndex, shadowOpacity: animatedShadowOpacity.value, elevation: isThisItemDragged ? animatedElevation.value : 4, opacity, } as ViewStyle; }); return { dragAnimatedStyle }; }