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 };
}