import type { ReactNode, RefObject } from 'react'; import { useRef } from 'react'; import type { ViewStyle } from 'react-native'; import { StyleSheet, View } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { withDelay, withTiming } from 'react-native-reanimated'; import { runOnJS, runOnUI } from 'react-native-worklets'; import type { FlattenedHoverStyles } from '../HoverLayer'; import { computeAbsolutePositionWorklet, getRelativePosition } from '../math'; import { defaultSnapbackDelay, defaultSnapbackDuration, } from '../params'; import type { DragPhase, DraxEventDraggedViewData, DraxEventReceiverViewData, DraxProviderDragEvent, DraxSnapbackTarget, DraxSnapEndEventData, DraxViewMeasurements, Position, SpatialEntry, ViewRegistryEntry, } from '../types'; import { DraxSnapbackTargetPreset, DraxViewDragStatus, DraxViewReceiveStatus, isPosition, } from '../types'; import { isDraggable } from './useSpatialIndex'; /** Style override to strip margins — hover is positioned via translateX/Y */ /** Styles to strip from the hover content — margins and absolute positioning * are not needed since hover is positioned via translateX/Y. */ const hoverResetStyle = { margin: 0, marginHorizontal: 0, marginVertical: 0, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0, position: 'relative', left: 0, top: 0, right: undefined, bottom: undefined, } as const; interface CallbackDispatchDeps { getViewEntry: (id: string) => ViewRegistryEntry | undefined; spatialIndexSV: SharedValue; scrollOffsetsSV: SharedValue; draggedIdSV: SharedValue; receiverIdSV: SharedValue; rejectedReceiverIdSV: SharedValue; dragPhaseSV: SharedValue; hoverPositionSV: SharedValue; grabOffsetSV: SharedValue; startPositionSV: SharedValue; setHoverContent: (content: ReactNode | null) => void; hoverReadySV: SharedValue; hoverClearDeferredRef: { current: boolean }; hoverStylesRef: RefObject; // Provider-level callbacks onProviderDragStart?: (event: DraxProviderDragEvent) => void; onProviderDrag?: (event: DraxProviderDragEvent) => void; onProviderDragEnd?: (event: DraxProviderDragEvent & { cancelled: boolean }) => void; // Dropped items tracking (for capacity) droppedItemsRef: RefObject>>; } /** * Provides JS-thread callback dispatch functions that are invoked via runOnJS * from gesture worklets. These handle ~5 calls per drag (start, receiver changes, end), * NOT per frame. */ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { const { getViewEntry, spatialIndexSV, scrollOffsetsSV, draggedIdSV, dragPhaseSV, hoverPositionSV, grabOffsetSV, startPositionSV, setHoverContent, hoverReadySV, onProviderDragStart, onProviderDrag, onProviderDragEnd, droppedItemsRef, } = deps; // Track current monitor ids for exit events const currentMonitorIdsRef = useRef([]); /** Build dragged view event data from current state */ const buildDraggedViewData = ( draggedId: string, absolutePosition: Position ): DraxEventDraggedViewData | undefined => { const entry = getViewEntry(draggedId); if (!entry) return undefined; const startPos = startPositionSV.value; const grabOffset = grabOffsetSV.value; const dragTranslation = { x: absolutePosition.x - startPos.x, y: absolutePosition.y - startPos.y, }; const measurements = entry.measurements; // Use || instead of ?? intentionally: zero dimensions would cause division by zero below const width = measurements?.width || 1; const height = measurements?.height || 1; return { id: draggedId, parentId: entry.parentId, payload: entry.props.dragPayload ?? entry.props.payload, measurements, dragTranslationRatio: { x: dragTranslation.x / width, y: dragTranslation.y / height, }, dragOffset: { x: absolutePosition.x - (measurements?.x ?? 0), y: absolutePosition.y - (measurements?.y ?? 0), }, grabOffset, grabOffsetRatio: { x: grabOffset.x / width, y: grabOffset.y / height, }, hoverPosition: hoverPositionSV.value, }; }; /** Build receiver view event data */ const buildReceiverViewData = ( receiverId: string, absolutePosition: Position ): DraxEventReceiverViewData | undefined => { const entry = getViewEntry(receiverId); if (!entry?.measurements) return undefined; // Compute absolute measurements of receiver const idx = entry.spatialIndex; const entries = spatialIndexSV.value; const offsets = scrollOffsetsSV.value; const absPos = computeAbsolutePositionWorklet(idx, entries, offsets); const absMeasurements: DraxViewMeasurements = { ...absPos, width: entry.measurements.width, height: entry.measurements.height, _transformDetected: 0, }; const { relativePosition, relativePositionRatio } = getRelativePosition( absolutePosition, absMeasurements ); return { id: receiverId, parentId: entry.parentId, payload: entry.props.receiverPayload ?? entry.props.payload, measurements: entry.measurements, receiveOffset: relativePosition, receiveOffsetRatio: relativePositionRatio, }; }; /** Called via runOnJS when drag starts */ const handleDragStart = ( draggedId: string, absolutePosition: Position, _grabOffset: Position ) => { const draggedEntry = getViewEntry(draggedId); if (!draggedEntry) return; const dragged = buildDraggedViewData(draggedId, absolutePosition); if (!dragged) return; const startPos = startPositionSV.value; const dragTranslation = { x: absolutePosition.x - startPos.x, y: absolutePosition.y - startPos.y, }; // Fire onDragStart callback draggedEntry.props.onDragStart?.({ dragAbsolutePosition: absolutePosition, dragTranslation, dragged, }); // Setup hover styles — set BEFORE setHoverContent so HoverLayer // captures them when it re-renders on hoverVersion change. deps.hoverStylesRef.current = { hoverStyle: flattenOrNull(draggedEntry.props.hoverStyle), hoverDraggingStyle: flattenOrNull(draggedEntry.props.hoverDraggingStyle), hoverDraggingWithReceiverStyle: flattenOrNull(draggedEntry.props.hoverDraggingWithReceiverStyle), hoverDraggingWithoutReceiverStyle: flattenOrNull(draggedEntry.props.hoverDraggingWithoutReceiverStyle), hoverDragReleasedStyle: flattenOrNull(draggedEntry.props.hoverDragReleasedStyle), }; // Setup hover content if (isDraggable(draggedEntry.props) && !draggedEntry.props.noHover) { const renderFn = draggedEntry.props.renderHoverContent ?? draggedEntry.props.renderContent; if (renderFn) { const content = renderFn({ viewState: { dragStatus: DraxViewDragStatus.Dragging, receiveStatus: DraxViewReceiveStatus.Inactive, grabOffset: dragged.grabOffset, grabOffsetRatio: dragged.grabOffsetRatio, }, trackingStatus: { dragging: true, receiving: false }, hover: true, children: null, dimensions: draggedEntry.measurements ? { width: draggedEntry.measurements.width, height: draggedEntry.measurements.height, } : undefined, }); setHoverContent(content); } else { // Default hover: wrap children with original view style and dimensions. // Strip margins since hover is positioned via translateX/Y. const dims = draggedEntry.measurements; const viewStyle = draggedEntry.props.style; setHoverContent( {draggedEntry.props.children} ); } } // Phase activation is handled by HoverLayer's useLayoutEffect — it fires // AFTER React commits the hover content, ensuring both opacity:1 and // draggingStyle apply on the same frame. See HoverLayer.tsx. // Fire provider-level onDragStart onProviderDragStart?.({ draggedId, position: absolutePosition }); // Fire monitor onMonitorDragStart callbacks currentMonitorIdsRef.current = []; }; /** Called via runOnJS on every gesture update for callback dispatch. * Handles: enter/exit (on receiver change), onDragOver/onReceiveDragOver * (continuous, same receiver), onDrag (continuous, no receiver), and monitors. */ const handleReceiverChange = ( oldReceiverId: string, newReceiverId: string, absolutePosition: Position, monitorIds?: string[] ) => { const draggedId = draggedIdSV.value; // Fast path: receiver unchanged, no monitors (now AND previously), // and no continuous callbacks → skip event data construction entirely. const newMonitorIds = monitorIds ?? []; const prevMonitorIds = currentMonitorIdsRef.current; if ( oldReceiverId === newReceiverId && newMonitorIds.length === 0 && prevMonitorIds.length === 0 ) { const draggedEntry = getViewEntry(draggedId); if (!draggedEntry) return; const hasOnDragOver = newReceiverId && draggedEntry.props.onDragOver; const receiverEntry = newReceiverId ? getViewEntry(newReceiverId) : undefined; const hasOnReceiveDragOver = newReceiverId && receiverEntry?.props.onReceiveDragOver; const hasOnDrag = !newReceiverId && (draggedEntry.props.onDrag || onProviderDrag); if (!hasOnDragOver && !hasOnReceiveDragOver && !hasOnDrag) return; } const dragged = buildDraggedViewData(draggedId, absolutePosition); if (!dragged) return; const draggedEntry = getViewEntry(draggedId); const draggedPayload = draggedEntry?.props.dragPayload ?? draggedEntry?.props.payload; const startPos = startPositionSV.value; const dragTranslation = { x: absolutePosition.x - startPos.x, y: absolutePosition.y - startPos.y, }; const baseEventData = { dragAbsolutePosition: absolutePosition, dragTranslation, dragged, }; // ── Check dynamicReceptiveCallback / acceptsDrag on new receiver ── let acceptedReceiverId = newReceiverId; if (newReceiverId && oldReceiverId !== newReceiverId) { const newReceiverEntry = getViewEntry(newReceiverId); if (newReceiverEntry) { // Check acceptsDrag first (simpler convenience prop) const acceptsDrag = newReceiverEntry.props.acceptsDrag; if (acceptsDrag && !acceptsDrag(draggedPayload)) { acceptedReceiverId = ''; } // Check capacity const capacity = newReceiverEntry.props.capacity; if (acceptedReceiverId && capacity !== undefined) { const droppedSet = droppedItemsRef.current.get(newReceiverId); const count = droppedSet ? droppedSet.size : 0; if (count >= capacity) { acceptedReceiverId = ''; } } // Check dynamicReceptiveCallback (more detailed) const dynamicCallback = newReceiverEntry.props.dynamicReceptiveCallback; if (acceptedReceiverId && dynamicCallback && newReceiverEntry.measurements) { const accepted = dynamicCallback({ targetId: newReceiverId, targetMeasurements: newReceiverEntry.measurements, draggedId, draggedPayload, }); if (!accepted) { acceptedReceiverId = ''; } } } // If rejected, tell the gesture worklet to skip this receiver on future frames. // Also clear receiverIdSV so animated styles don't flash the receiving state. if (!acceptedReceiverId) { runOnUI(( _receiverIdSV: typeof deps.receiverIdSV, _rejectedReceiverIdSV: typeof deps.rejectedReceiverIdSV, _rejectedId: string, ) => { 'worklet'; _receiverIdSV.value = ''; _rejectedReceiverIdSV.value = _rejectedId; })(deps.receiverIdSV, deps.rejectedReceiverIdSV, newReceiverId); } } // Fire exit on old receiver (only when receiver actually changed) if (oldReceiverId && oldReceiverId !== acceptedReceiverId) { const oldReceiverEntry = getViewEntry(oldReceiverId); const oldReceiverData = buildReceiverViewData( oldReceiverId, absolutePosition ); if (oldReceiverEntry && oldReceiverData) { // Dragged view: onDragExit draggedEntry?.props.onDragExit?.({ ...baseEventData, receiver: oldReceiverData, }); // Receiver view: onReceiveDragExit oldReceiverEntry.props.onReceiveDragExit?.({ ...baseEventData, receiver: oldReceiverData, cancelled: false, }); } } // Fire enter on new receiver (only when receiver actually changed) if (acceptedReceiverId && oldReceiverId !== acceptedReceiverId) { const newReceiverEntry = getViewEntry(acceptedReceiverId); const newReceiverData = buildReceiverViewData( acceptedReceiverId, absolutePosition ); if (newReceiverEntry && newReceiverData) { // Dragged view: onDragEnter draggedEntry?.props.onDragEnter?.({ ...baseEventData, receiver: newReceiverData, }); // Receiver view: onReceiveDragEnter newReceiverEntry.props.onReceiveDragEnter?.({ ...baseEventData, receiver: newReceiverData, }); } } // ── Continuous callbacks: onDragOver / onReceiveDragOver / onDrag ── if (acceptedReceiverId && oldReceiverId === acceptedReceiverId) { // Dragging over the same receiver — fire onDragOver + onReceiveDragOver const receiverEntry = getViewEntry(acceptedReceiverId); const receiverData = buildReceiverViewData(acceptedReceiverId, absolutePosition); if (receiverEntry && receiverData) { draggedEntry?.props.onDragOver?.({ ...baseEventData, receiver: receiverData, }); receiverEntry.props.onReceiveDragOver?.({ ...baseEventData, receiver: receiverData, }); } } else if (!acceptedReceiverId) { // No receiver — fire onDrag (continuous, not over any receiver) draggedEntry?.props.onDrag?.(baseEventData); } // ── Dispatch monitor events ────────────────────────────────────── const prevWasEmpty = prevMonitorIds.length === 0; // Build receiver data for monitor event payload (use accepted receiver, not raw hit-test) const receiverData = acceptedReceiverId ? buildReceiverViewData(acceptedReceiverId, absolutePosition) : undefined; // Fire events on current monitors (start/enter before over) for (const monitorId of newMonitorIds) { const monitorEntry = getViewEntry(monitorId); if (!monitorEntry?.measurements) continue; const { relativePosition: monitorOffset, relativePositionRatio: monitorOffsetRatio, } = getRelativePosition(absolutePosition, monitorEntry.measurements); const monitorEventData = { ...baseEventData, receiver: receiverData, monitorOffset, monitorOffsetRatio, }; const isNew = !prevMonitorIds.includes(monitorId); // First time we see any monitor after drag start → fire onMonitorDragStart if (isNew && prevWasEmpty) { monitorEntry.props.onMonitorDragStart?.(monitorEventData); } // New monitor → fire onMonitorDragEnter if (isNew) { monitorEntry.props.onMonitorDragEnter?.(monitorEventData); } // All current monitors → fire onMonitorDragOver monitorEntry.props.onMonitorDragOver?.(monitorEventData); } // Fire exit on monitors that are no longer hit for (const prevMonitorId of prevMonitorIds) { if (newMonitorIds.includes(prevMonitorId)) continue; const monitorEntry = getViewEntry(prevMonitorId); if (!monitorEntry?.measurements) continue; const { relativePosition: monitorOffset, relativePositionRatio: monitorOffsetRatio, } = getRelativePosition(absolutePosition, monitorEntry.measurements); monitorEntry.props.onMonitorDragExit?.({ ...baseEventData, receiver: receiverData, monitorOffset, monitorOffsetRatio, }); } currentMonitorIdsRef.current = newMonitorIds; // Fire provider-level onDrag (use acceptedReceiverId, not raw newReceiverId) onProviderDrag?.({ draggedId: draggedIdSV.value, receiverId: acceptedReceiverId || undefined, position: absolutePosition }); }; /** Called via runOnJS when drag ends or is cancelled */ const handleDragEnd = ( draggedId: string, receiverId: string, cancelled: boolean, finalMonitorIds?: string[] ) => { // receiverIdSV is already cleared on the UI thread in onDeactivate/onFinalize, // so the receiver's animated style resets immediately. const draggedEntry = getViewEntry(draggedId); if (!draggedEntry) { // Reset drag state atomically on UI thread to avoid one-frame flash runOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, _hoverPositionSV: typeof hoverPositionSV, ) => { 'worklet'; _hoverReadySV.value = false; _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); return; } const absolutePosition = { ...hoverPositionSV.value }; const dragged = buildDraggedViewData(draggedId, absolutePosition); if (!dragged) { runOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, _hoverPositionSV: typeof hoverPositionSV, ) => { 'worklet'; _hoverReadySV.value = false; _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); return; } const startPos = startPositionSV.value; const dragTranslation = { x: absolutePosition.x - startPos.x, y: absolutePosition.y - startPos.y, }; const baseEventData = { dragAbsolutePosition: absolutePosition, dragTranslation, dragged, }; let snapTarget: DraxSnapbackTarget = DraxSnapbackTargetPreset.Default; if (receiverId && !cancelled) { // Successful drop — default snap to receiver position const receiverEntry = getViewEntry(receiverId); const receiverData = buildReceiverViewData( receiverId, absolutePosition ); if (receiverData && receiverEntry) { // Compute receiver's absolute position and center the dragged item within it const receiverAbsPos = computeAbsolutePositionWorklet( receiverEntry.spatialIndex, spatialIndexSV.value, scrollOffsetsSV.value ); const draggedDims = draggedEntry.measurements; const receiverDims = receiverEntry.measurements; if (receiverDims && draggedDims) { snapTarget = { x: receiverAbsPos.x + (receiverDims.width - draggedDims.width) / 2, y: receiverAbsPos.y + (receiverDims.height - draggedDims.height) / 2, }; } else { snapTarget = receiverAbsPos; } // Fire onDragDrop on dragged (can override snap target) const dragDropResponse = draggedEntry.props.onDragDrop?.({ ...baseEventData, receiver: receiverData, }); if (dragDropResponse !== undefined) snapTarget = dragDropResponse as DraxSnapbackTarget; // Fire onReceiveDragDrop on receiver (can override snap target) const receiveDropResponse = receiverEntry.props.onReceiveDragDrop?.({ ...baseEventData, receiver: receiverData, }); if (receiveDropResponse !== undefined) snapTarget = receiveDropResponse as DraxSnapbackTarget; // Track the drop for capacity enforcement if (!droppedItemsRef.current.has(receiverId)) { droppedItemsRef.current.set(receiverId, new Set()); } droppedItemsRef.current.get(receiverId)!.add(draggedId); } } else { // No receiver or cancelled const dragEndResponse = draggedEntry.props.onDragEnd?.({ ...baseEventData, cancelled, }); if (dragEndResponse !== undefined) snapTarget = dragEndResponse as DraxSnapbackTarget; } // Fire monitor end events — use final hit-test monitors from onDeactivate // if available, falling back to tracked monitors from receiver changes. const monitorIdsToUse = finalMonitorIds ?? currentMonitorIdsRef.current; for (const monitorId of monitorIdsToUse) { const monitorEntry = getViewEntry(monitorId); if (!monitorEntry?.measurements) continue; const { relativePosition: monitorOffset, relativePositionRatio: monitorOffsetRatio, } = getRelativePosition(absolutePosition, monitorEntry.measurements); if (receiverId && !cancelled) { const receiverData = buildReceiverViewData( receiverId, absolutePosition ); if (receiverData) { const monitorDropResponse = monitorEntry.props.onMonitorDragDrop?.({ ...baseEventData, receiver: receiverData, monitorOffset, monitorOffsetRatio, }); if (monitorDropResponse !== undefined) snapTarget = monitorDropResponse as DraxSnapbackTarget; } } else { const monitorEndResponse = monitorEntry.props.onMonitorDragEnd?.({ ...baseEventData, monitorOffset, monitorOffsetRatio, cancelled, }); if (monitorEndResponse !== undefined) snapTarget = monitorEndResponse as DraxSnapbackTarget; } } // Resolve Default snap target to root-relative visual position. // Default triggers when monitors are empty or all callbacks return undefined. // draggedEntry.measurements are content-relative (from measureLayout), so we // use the spatial index to compute root-relative visual position instead. if (snapTarget === DraxSnapbackTargetPreset.Default) { const absPos = computeAbsolutePositionWorklet( draggedEntry.spatialIndex, spatialIndexSV.value, scrollOffsetsSV.value ); snapTarget = absPos; } // Handle snap-back animation performSnapback( snapTarget, draggedEntry, receiverId ? getViewEntry(receiverId) : undefined, hoverPositionSV, dragPhaseSV, draggedIdSV, hoverReadySV, setHoverContent, deps.hoverClearDeferredRef ); // Fire provider-level onDragEnd (use last known hover position) onProviderDragEnd?.({ draggedId, receiverId: receiverId || undefined, position: hoverPositionSV.value, cancelled }); }; return { handleDragStart, handleReceiverChange, handleDragEnd, }; }; /** * Perform the snap-back animation after drag ends. * * CRITICAL ORDERING: When the snap animation completes, we must: * 1. Fire onSnapEnd callbacks → triggers finalizeDrag → commits reorder / cancels drag * 2. THEN clear drag state (hover disappears, item becomes visible) * * This ordering ensures shifted items are cleaned up BEFORE the hover disappears. * Without this, there's a visible gap where shifted items are at shifted positions * but the hover is already gone (the "drop blink"). */ function performSnapback( target: DraxSnapbackTarget, draggedEntry: ViewRegistryEntry, receiverEntry: ViewRegistryEntry | undefined, hoverPositionSV: SharedValue, dragPhaseSV: SharedValue, draggedIdSV: SharedValue, hoverReadySV: SharedValue, setHoverContent: (content: ReactNode | null) => void, hoverClearDeferredRef: { current: boolean } ) { const animateSnap = draggedEntry.props.animateSnap ?? true; const snapDelay = draggedEntry.props.snapDelay ?? defaultSnapbackDelay; const snapDuration = draggedEntry.props.snapDuration ?? defaultSnapbackDuration; const snapAnimator = draggedEntry.props.snapAnimator; // Build snap event data for callbacks const snapEventData: DraxSnapEndEventData = { dragged: { id: draggedEntry.id, parentId: draggedEntry.parentId, payload: draggedEntry.props.dragPayload ?? draggedEntry.props.payload, }, receiver: receiverEntry ? { id: receiverEntry.id, parentId: receiverEntry.parentId, payload: receiverEntry.props.receiverPayload ?? receiverEntry.props.payload, } : undefined, }; /** * Called when snap animation completes. Fires callbacks FIRST (so finalizeDrag * can set permanent shifts + clear hover), THEN clears hover & drag state. * * For REORDER: finalizeDrag sets permanent shifts + clears hover via runOnUI * in a single atomic block. No FlatList data change, so no blink. * * For CANCEL: finalizeDrag → cancelDrag → reverts to committed shifts. * Then hover clears on next UI frame. Items at visual positions. No blink. */ const onSnapComplete = () => { // Reset the deferred flag before firing callbacks. // finalizeDrag (called via onSnapEnd) may set it to true for reorder. hoverClearDeferredRef.current = false; // Step 1: Fire callbacks → finalizeDrag runs synchronously. draggedEntry.props.onSnapEnd?.(snapEventData); receiverEntry?.props.onReceiveSnapEnd?.(snapEventData); // Step 2: Clear hover if NOT deferred by a sortable reorder. if (!hoverClearDeferredRef.current) { runOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, _hoverPositionSV: typeof hoverPositionSV, ) => { 'worklet'; _hoverReadySV.value = false; _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); } else { // Do NOT call setHoverContent(null) here — the hover must remain visible // until the FlatList re-renders. The deferred cleanup in useLayoutEffect // will clear SharedValues, and setHoverContent(null) is called there too. } }; if (target === DraxSnapbackTargetPreset.None || !animateSnap) { // No snap animation — run cleanup immediately onSnapComplete(); return; } // Determine snap-to position let toValue: Position; if (isPosition(target)) { toValue = target; } else { // Default: snap back to original view position toValue = draggedEntry.measurements ? { x: draggedEntry.measurements.x, y: draggedEntry.measurements.y } : { x: 0, y: 0 }; } if (snapAnimator) { // Custom snap animation snapAnimator({ hoverPosition: hoverPositionSV, toValue, delay: snapDelay, duration: snapDuration, finishedCallback: (finished: boolean) => { if (finished) { onSnapComplete(); } }, }); } else { // Default withTiming snap animation. // When animation finishes, bounce to JS for ordered cleanup. hoverPositionSV.value = withDelay( snapDelay, withTiming(toValue, { duration: snapDuration }, (finished) => { 'worklet'; if (finished) { runOnJS(onSnapComplete)(); } }) ); } } // ─── Helpers ─────────────────────────────────────────────────────────────── function flattenOrNull(s: unknown): ViewStyle | null { if (!s) return null; return StyleSheet.flatten(s as ViewStyle) ?? null; }