import { useAnimatedReaction } from "react-native-reanimated"; import { useDragState } from "../../contexts/DragStateContext"; /** * Centralized hook that drives overlay crossfade on UI thread. * Call this from DragOverlay component (mounted once). * * IMPORTANT: Only uses UI-thread signals (overlayActive, overlayReady, overlayItemId, * overlayRenderedOnUI). Does NOT use overlayVisible which is set from JS-thread via * scheduleOnRN → useEffect, causing scheduling delays that can result in visibility * race conditions. * * The crossfade uses INSTANT swaps (not animated) because: * - With correct overlay positioning (exact match to original), instant swap is imperceptible * - Animated fades (e.g. 50ms) caused visible flicker because the animation started before * the overlay was actually painted to screen, causing the original to dim with no replacement * * The crossfade ensures: * 1. Overlay becomes visible instantly when ready (active + ready + has ID + rendered on UI) * 2. Overlay becomes invisible instantly when drag/drop ends (active goes false) * 3. Original item uses inverse opacity (1 - dragOverlayOpacity) for perfect crossfade * 4. overlayFrozenItemId keeps overlay renderable during state transitions * 5. Overlay state is cleared atomically on deactivation * 6. Swap only happens when overlayRenderedOnUI is true, preventing the original from * becoming invisible before the overlay has actually rendered (the "vanish" bug fix) */ export function useOverlayCrossfade(): void { const { overlayActive, overlayReady, overlayItemId, overlayTranslateY, overlayVisible, dragOverlayOpacity, overlayFrozenItemId, overlayRenderedOnUI, } = useDragState(); useAnimatedReaction( () => ({ active: overlayActive.value, ready: overlayReady.value, id: overlayItemId.value, frozen: overlayFrozenItemId.value, rendered: overlayRenderedOnUI.value, }), (state, prev) => { "worklet"; // shouldShow uses ONLY UI-thread signals (no overlayVisible) // Now also requires overlayRenderedOnUI to be true before fade-in starts const shouldShow = state.active && state.ready && !!state.id && state.rendered; // Instant fade IN when overlay becomes ready AND has actually rendered if (shouldShow) { if (state.frozen !== state.id) { overlayFrozenItemId.value = state.id; } // Instant swap instead of 50ms animation. // With correct overlay positioning, instant swap is imperceptible. // The timed fade caused visible flicker during the animation window // because it started before the overlay was actually painted to screen. dragOverlayOpacity.value = 1; return; } // Instant fade OUT when overlay deactivates (only on edge transition) // CRITICAL FIX: Only start fade-out if overlay was actually rendered. // This prevents the race condition where overlayActive goes false before // the overlay has rendered, which would cause the original to be invisible // (from crossfade logic) while the overlay never became visible = vanish. if (prev?.active && !state.active && prev?.rendered) { // Instant swap - clear state immediately instead of animated fade. dragOverlayOpacity.value = 0; overlayFrozenItemId.value = ""; overlayReady.value = false; overlayItemId.value = ""; overlayTranslateY.value = 0; overlayVisible.value = false; overlayRenderedOnUI.value = false; } }, [], ); }