From 86bf07a0ccf98586a824eea3742265e1b7994b86 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:35:48 +0200 Subject: [PATCH 01/12] fix: mixed-size grid rendering and drag overflow - Add flex:1 to inner wrapper View so tile content fills cells vertically - Use itemDimensionsRef height in visibility check for multi-row items - Grow totalContentSize during drag reorder to prevent item clipping - Add testIDs to mixed-grid example tiles Fixes #217 (reported: packGrid overflow clipping items after reorder) Co-Authored-By: Claude Opus 4.6 (1M context) --- example/screens/mixed-grid.tsx | 9 ++++++--- src/DraxList.tsx | 14 ++++++++++++-- src/hooks/useSortableList.ts | 4 ++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/example/screens/mixed-grid.tsx b/example/screens/mixed-grid.tsx index 4272aa4..65612d1 100644 --- a/example/screens/mixed-grid.tsx +++ b/example/screens/mixed-grid.tsx @@ -88,9 +88,12 @@ export default function MixedGrid() { longPressDelay={200} onReorder={({ data: newData }) => setData(newData)} renderItem={({ item }) => ( - + {item.label} diff --git a/src/DraxList.tsx b/src/DraxList.tsx index b3dbcea..80a1eac 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -380,7 +380,10 @@ export const DraxList = (props: DraxListProps) => { if (visualX + w >= visibleStart && visualX <= visibleEnd) visibleKeys.add(key); } else { - const h = heights.get(key) ?? estimatedItemSize; + const h = + int.itemDimensionsRef.current.get(key)?.height ?? + heights.get(key) ?? + estimatedItemSize; const visualY = basePos.y + (shift?.y ?? 0); if (visualY + h >= visibleStart && visualY <= visibleEnd) visibleKeys.add(key); @@ -697,8 +700,14 @@ export const DraxList = (props: DraxListProps) => { absPos.y - containerMeas.y + (horizontal ? 0 : scrollOffset); targetSlot = int.getSlotFromPosition(contentX, contentY); if (targetSlot !== int.currentSlotRef.current) { + const prevSize = int.totalContentSizeRef.current; gridResult = int.recomputeShiftsForReorder(dragKey, targetSlot); int.currentSlotRef.current = targetSlot; + // Re-render if content area grew (prevents clipping shifted items) + if (int.totalContentSizeRef.current > prevSize) { + updateVisibleCells(int.scrollOffsetSV.value); + forceRender(); + } } } @@ -784,6 +793,7 @@ export const DraxList = (props: DraxListProps) => { horizontal, startAutoScroll, stopAutoScroll, + updateVisibleCells, renderDropIndicator, dropIndicatorPositionSV, dropIndicatorVisibleSV, @@ -1032,7 +1042,7 @@ export const DraxList = (props: DraxListProps) => { }} > { // Cross-axis from inner wrapper (doesn't stretch — card's natural size) const cross = horizontal diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 3dbda42..aee77e5 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -678,6 +678,10 @@ export const useSortableList = ( } } shiftsSV.value = newShifts; // Keep for JS-thread reads (visibility, snap) + // Grow content area during drag so shifted items aren't clipped + if (result.totalHeight > totalContentSizeRef.current) { + totalContentSizeRef.current = result.totalHeight; + } return result; }, [reorderStrategy, shiftsSV]); From 71e4fa52f250546d15a61e63e309ccbab31c1433 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Fri, 27 Mar 2026 09:48:57 +0200 Subject: [PATCH 02/12] fix: gate fillStyle on numColumns > 1 for correctness fillStyle (flex:1) should only apply when getItemSpan AND numColumns > 1, matching how computeGridPositions uses packGrid. Prevents layout issues if getItemSpan is passed to a single-column list. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DraxList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 80a1eac..c1364cf 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -999,8 +999,8 @@ export const DraxList = (props: DraxListProps) => { : getItemSpan ? dims?.height : undefined; - // flex:1 only for mixed-size grids (getItemSpan provided) - const fillStyle = getItemSpan ? { flex: 1 } : undefined; + // flex:1 for mixed-size grids (cells have explicit height from packGrid) + const fillStyle = getItemSpan && numColumns > 1 ? { flex: 1 } : undefined; return ( Date: Fri, 27 Mar 2026 13:06:47 +0200 Subject: [PATCH 03/12] feat: virtual slot detection + snap offset fix for mixed-size grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packGrid returns cellOwners flat array for O(1) cell→item lookup - Virtual slot algorithm: pack grid WITHOUT dragged item at drag start (gap layout). Finger → gap cell → item key → insertion index. Gap layout frozen for entire drag = zero oscillation. - Snap target accounts for ScrollView offset within padded DraxView - Slot detection subtracts scrollContainerOffset for correct cell mapping - freezeSlotBoundaries tracks dragged key to avoid stale gap layout Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- src/DraxList.tsx | 28 ++++++-- src/hooks/useSortableList.ts | 133 +++++++++++++++++++++++++++++++---- src/math.ts | 30 +++++--- 4 files changed, 166 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2a0ada9..175abe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ The composable API (`useSortableList` + `SortableContainer` + `SortableItem`) is ### Mixed-Size Grid (Non-Uniform Spans) - `getItemSpan` prop on `useSortableList` — returns `{ colSpan, rowSpan }` per item -- `packGrid` utility — bin-packing algorithm placing items left-to-right, top-to-bottom into a 2D occupancy grid +- `packGrid` utility — bin-packing algorithm placing items left-to-right, top-to-bottom into a 2D occupancy grid. Returns `cellOwners` flat array for O(1) cell→item lookup during drag. - Grid geometry (cell size + gaps) derived automatically from item measurements - `computeShiftsForOrder` uses `packGrid` to compute target positions for non-uniform items - `getSlotFromPosition` maps finger position to grid cell, then to display index via cell→owner map diff --git a/src/DraxList.tsx b/src/DraxList.tsx index c1364cf..107024c 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -291,9 +291,11 @@ export const DraxList = (props: DraxListProps) => { // ── Container layout ── const handleContainerLayout = useCallback( (event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; + const { width, height, x, y } = event.nativeEvent.layout; const cw = horizontal ? height : width; int.containerWidthRef.current = cw; + // ScrollView's offset within the monitoring DraxView (accounts for padding) + int.scrollContainerOffsetRef.current = { x, y }; int.recomputeBasePositionsAndClearShifts(); // Rebind cells with new positions (grid positions change after container measured) updateVisibleCells(int.scrollOffsetSV.value); @@ -540,6 +542,17 @@ export const DraxList = (props: DraxListProps) => { // Sync to worklet SVs for UI-thread slot detection int.syncRefsToWorklet(); + // For mixed-size grids: set hover dimensions from computed cell size. + // The default hover auto-sizes from DraxView measurements, but flex:1 items + // may have stale measurements if the cell was recently recycled. The computed + // dimensions from packGrid are always authoritative. + if (getItemSpan && numColumns > 1) { + const dims = int.itemDimensionsRef.current.get(itemKey); + if (dims) { + hoverDimsSV.value = { x: dims.width, y: dims.height }; + } + } + // Fire user callback onDragStartProp?.({ index: originalIndex, item: item as T }); @@ -589,6 +602,9 @@ export const DraxList = (props: DraxListProps) => { [ int, keyExtractor, + getItemSpan, + numColumns, + hoverDimsSV, renderDropIndicator, dropIndicatorPositionSV, dropIndicatorVisibleSV, @@ -694,12 +710,13 @@ export const DraxList = (props: DraxListProps) => { } else { // JS handles slot detection + shifts (grids, or no worklet) const scrollOffset = int.scrollOffsetSV.value; + const scOffset = int.scrollContainerOffsetRef.current; const contentX = - absPos.x - containerMeas.x + (horizontal ? scrollOffset : 0); + absPos.x - containerMeas.x - scOffset.x + (horizontal ? scrollOffset : 0); const contentY = - absPos.y - containerMeas.y + (horizontal ? 0 : scrollOffset); + absPos.y - containerMeas.y - scOffset.y + (horizontal ? 0 : scrollOffset); targetSlot = int.getSlotFromPosition(contentX, contentY); - if (targetSlot !== int.currentSlotRef.current) { + if (targetSlot >= 0 && targetSlot !== int.currentSlotRef.current) { const prevSize = int.totalContentSizeRef.current; gridResult = int.recomputeShiftsForReorder(dragKey, targetSlot); int.currentSlotRef.current = targetSlot; @@ -836,13 +853,16 @@ export const DraxList = (props: DraxListProps) => { visualY = basePos.y + (shift?.y ?? 0); } + const scOffset = int.scrollContainerOffsetRef.current; return { x: containerMeas.x + + scOffset.x + visualX - (horizontal ? int.scrollOffsetSV.value : 0), y: containerMeas.y + + scOffset.y + visualY - (horizontal ? 0 : int.scrollOffsetSV.value), }; diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index aee77e5..0364c47 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -89,6 +89,8 @@ export interface SortableListInternal { itemCrossAxisRef: React.RefObject>; totalContentSizeRef: React.RefObject; containerMeasRef: React.RefObject<{ x: number; y: number; width: number; height: number } | undefined>; + /** ScrollView's position within the monitoring DraxView (accounts for padding). */ + scrollContainerOffsetRef: React.RefObject; containerWidthRef: React.RefObject; dataRef: React.RefObject; keyExtractorRef: React.RefObject<(item: unknown, index: number) => string>; @@ -204,6 +206,7 @@ export const useSortableList = ( const itemCrossAxisRef = useRef>(new Map()); const totalContentSizeRef = useRef(0); const containerMeasRef = useRef<{ x: number; y: number; width: number; height: number } | undefined>(undefined); + const scrollContainerOffsetRef = useRef({ x: 0, y: 0 }); const containerWidthRef = useRef(0); const dataRef = useRef(externalData); const keyToIndexRef = useRef>(new Map()); @@ -336,6 +339,16 @@ export const useSortableList = ( } + // ── Gap layout for mixed-size grid slot detection (virtual slot approach) ── + // At drag start, pack grid WITHOUT the dragged item. This "gap layout" is frozen + // for the entire drag. Finger → gap cell → item key → insertion index. + // Because the gap layout never changes, same finger position = same result = no oscillation. + const frozenGapCellKeyMapRef = useRef([]); + const frozenGapKeyToIndexRef = useRef>(new Map()); + const frozenGapGeometryRef = useRef<{ + cellSize: number; gap: number; numColumns: number; totalRows: number; + } | null>(null); + // ── Drag state refs ── const isDraggingRef = useRef(false); const dragStartIndexRef = useRef(0); @@ -388,7 +401,10 @@ export const useSortableList = ( } const totalH = packing.totalRows * (cellSize + gap) - gap; - return { positions, dimensions, totalHeight: totalH }; + return { + positions, dimensions, totalHeight: totalH, + cellOwners: packing.cellOwners, gridTotalRows: packing.totalRows, + }; } if (numColumns > 1 && cw > 0) { @@ -491,21 +507,67 @@ export const useSortableList = ( // ── Slot detection (frozen boundaries) ── const frozenKeysRef = useRef([]); + const frozenDragKeyRef = useRef(''); const freezeSlotBoundaries = useCallback(() => { const keys = orderedKeysRef.current; - // Skip if keys haven't changed since last freeze (avoids redundant computeGridPositions) - if (keys === frozenKeysRef.current && frozenBoundariesRef.current.length > 0) return; - frozenKeysRef.current = keys; + const currentDragKey = draggedKeySV.value; + const keysUnchanged = keys === frozenKeysRef.current && frozenBoundariesRef.current.length > 0; + const dragKeyUnchanged = currentDragKey === frozenDragKeyRef.current; + // Skip boundaries recomputation if keys unchanged; always recompute gap layout if drag key changed + if (keysUnchanged && dragKeyUnchanged) return; + + // Recompute boundaries only when keys changed (not just drag key) + if (!keysUnchanged) { + frozenKeysRef.current = keys; + const result = computeGridPositions(keys); + const boundaries = keys.map(key => { + const pos = result.positions.get(key) ?? { x: 0, y: 0 }; + const dim = result.dimensions.get(key) ?? { width: 0, height: estimatedItemSize }; + return { key, x: pos.x, y: pos.y, width: dim.width, height: dim.height }; + }); + frozenBoundariesRef.current = boundaries; + frozenBoundariesSV.value = boundaries; // Sync to UI thread for worklet slot detection + } - const result = computeGridPositions(keys); - const boundaries = keys.map(key => { - const pos = result.positions.get(key) ?? { x: 0, y: 0 }; - const dim = result.dimensions.get(key) ?? { width: 0, height: estimatedItemSize }; - return { key, x: pos.x, y: pos.y, width: dim.width, height: dim.height }; - }); - frozenBoundariesRef.current = boundaries; - frozenBoundariesSV.value = boundaries; // Sync to UI thread for worklet slot detection - }, [estimatedItemSize, horizontal]); + // Virtual slot: pack grid WITHOUT the dragged item to create a stable "gap layout." + // Recomputed when keys OR drag key changes (different item picked up). + frozenDragKeyRef.current = currentDragKey; + const spanFn = getItemSpanRef.current; + if (spanFn && numColumns > 1 && currentDragKey) { + const gapKeys = keys.filter(k => k !== currentDragKey); + const data = dataRef.current; + const keyMap = keyToIndexRef.current; + const gapPacking = packGrid(gapKeys.length, numColumns, (i) => { + const key = gapKeys[i]!; + const idx = keyMap.get(key); + if (idx !== undefined && data[idx] !== undefined) { + return spanFn(data[idx]!, idx); + } + return { colSpan: 1, rowSpan: 1 }; + }); + + const cw = containerWidthRef.current; + const cellSize = cw > 0 + ? (cw - gridGap * (numColumns - 1)) / numColumns + : estimatedItemSize; + frozenGapGeometryRef.current = { + cellSize, gap: gridGap, numColumns, totalRows: gapPacking.totalRows, + }; + + // Build cell → key map from gap packing + const gapCellKeyMap = new Array(gapPacking.cellOwners.length); + for (let i = 0; i < gapPacking.cellOwners.length; i++) { + const ownerIdx = gapPacking.cellOwners[i]!; + gapCellKeyMap[i] = ownerIdx >= 0 && ownerIdx < gapKeys.length ? gapKeys[ownerIdx]! : ''; + } + frozenGapCellKeyMapRef.current = gapCellKeyMap; + + // Build key → insertion index map for O(1) lookup + const keyToIdx = new Map(); + gapKeys.forEach((k, i) => keyToIdx.set(k, i)); + frozenGapKeyToIndexRef.current = keyToIdx; + } + }, [estimatedItemSize, horizontal, gridGap, numColumns]); const getSlotFromPosition = useCallback((contentX: number, contentY: number): number => { const boundaries = frozenBoundariesRef.current; @@ -514,7 +576,46 @@ export const useSortableList = ( } if (numColumns > 1) { - // 2D grid: find nearest slot by distance to center + // Mixed-size grid: virtual slot detection via frozen gap layout. + // The gap layout (pack WITHOUT dragged item) is computed once at drag start. + // Finger → gap cell → item key → insertion index. Frozen = no oscillation. + const gapCellKeyMap = frozenGapCellKeyMapRef.current; + const gapKeyToIndex = frozenGapKeyToIndexRef.current; + const geo = frozenGapGeometryRef.current; + if (gapCellKeyMap.length > 0 && geo && gapKeyToIndex.size > 0) { + const step = geo.cellSize + geo.gap; + const fingerCol = Math.max(0, Math.min(Math.floor(contentX / step), geo.numColumns - 1)); + const fingerRow = Math.max(0, Math.min(Math.floor(contentY / step), geo.totalRows - 1)); + + // Look up the key at the finger's cell in the gap layout + let targetKey = gapCellKeyMap[fingerRow * geo.numColumns + fingerCol] || ''; + + // Empty cell in gap layout — spiral outward for nearest occupied cell + if (!targetKey) { + for (let radius = 1; radius <= Math.max(geo.totalRows, geo.numColumns); radius++) { + let found = false; + for (let dr = -radius; dr <= radius && !found; dr++) { + for (let dc = -radius; dc <= radius && !found; dc++) { + if (Math.abs(dr) !== radius && Math.abs(dc) !== radius) continue; + const nr = fingerRow + dr; + const nc = fingerCol + dc; + if (nr < 0 || nr >= geo.totalRows || nc < 0 || nc >= geo.numColumns) continue; + const k = gapCellKeyMap[nr * geo.numColumns + nc] || ''; + if (k) { targetKey = k; found = true; } + } + } + if (targetKey) break; + } + } + + if (!targetKey) return -1; + + // Convert gap key → insertion index (position in the post-removal array) + const insertionIdx = gapKeyToIndex.get(targetKey); + return insertionIdx !== undefined ? insertionIdx : -1; + } + + // Uniform grid (no getItemSpan): find nearest slot by distance to center let bestIdx = 0; let bestDist = Infinity; for (let i = 0; i < boundaries.length; i++) { @@ -682,6 +783,9 @@ export const useSortableList = ( if (result.totalHeight > totalContentSizeRef.current) { totalContentSizeRef.current = result.totalHeight; } + // NOTE: Do NOT rebuild frozenCellOwnersRef/frozenBoundariesRef here. + // Frozen geometry must stay frozen for the entire drag to prevent oscillation. + // The cell→key map from freezeSlotBoundaries provides stable slot detection. return result; }, [reorderStrategy, shiftsSV]); @@ -796,6 +900,7 @@ export const useSortableList = ( itemCrossAxisRef, totalContentSizeRef, containerMeasRef, + scrollContainerOffsetRef, containerWidthRef, dataRef, keyExtractorRef, diff --git a/src/math.ts b/src/math.ts index 28c8eef..756e73c 100644 --- a/src/math.ts +++ b/src/math.ts @@ -259,6 +259,9 @@ export interface GridPackResult { positions: { row: number; col: number }[]; /** Total number of rows in the packed grid */ totalRows: number; + /** Flat cell→owner map: cellOwners[row * numColumns + col] = display index of the + * item occupying that cell, or -1 if empty. Length = totalRows * numColumns. */ + cellOwners: number[]; } /** @@ -279,14 +282,15 @@ export function packGrid( numColumns: number, getSpan: (index: number) => GridItemSpan, ): GridPackResult { - // Dynamic 2D occupancy grid — rows are added as needed - const occupied: boolean[][] = []; + // Dynamic 2D occupancy grid — rows added as needed. + // Each cell stores the display index of the item occupying it, or -1 if empty. + const occupied: number[][] = []; const positions: { row: number; col: number }[] = []; let maxRow = 0; function ensureRow(row: number) { while (occupied.length <= row) { - occupied.push(new Array(numColumns).fill(false)); + occupied.push(new Array(numColumns).fill(-1)); } } @@ -295,17 +299,17 @@ export function packGrid( for (let r = row; r < row + rs; r++) { ensureRow(r); for (let c = col; c < col + cs; c++) { - if (occupied[r]![c]) return false; + if (occupied[r]![c]! >= 0) return false; } } return true; } - function markOccupied(row: number, col: number, cs: number, rs: number) { + function markOccupied(row: number, col: number, cs: number, rs: number, ownerIndex: number) { for (let r = row; r < row + rs; r++) { ensureRow(r); for (let c = col; c < col + cs; c++) { - occupied[r]![c] = true; + occupied[r]![c] = ownerIndex; } } maxRow = Math.max(maxRow, row + rs - 1); @@ -320,7 +324,7 @@ export function packGrid( ensureRow(r); for (let c = 0; c <= numColumns - cs; c++) { if (isAvailable(r, c, cs, rs)) { - markOccupied(r, c, cs, rs); + markOccupied(r, c, cs, rs, i); positions.push({ row: r, col: c }); placed = true; break; @@ -329,5 +333,15 @@ export function packGrid( } } - return { positions, totalRows: count > 0 ? maxRow + 1 : 0 }; + // Flatten 2D occupancy grid to 1D cellOwners array + const totalRows = count > 0 ? maxRow + 1 : 0; + const cellOwners = new Array(totalRows * numColumns); + for (let r = 0; r < totalRows; r++) { + const row = occupied[r]!; + for (let c = 0; c < numColumns; c++) { + cellOwners[r * numColumns + c] = row[c]!; + } + } + + return { positions, totalRows, cellOwners }; } From 2653d2bbc4ea7d44a8d41b323330046a1d661bd1 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:41:49 +0200 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20sortable=20flex=20=E2=80=94=20var?= =?UTF-8?q?iable-width=20drag-to-reorder=20with=20flex-wrap=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add flexWrap mode to DraxList for reordering variable-width items (tags, chips, pills). Uses packFlex greedy packing with virtual slot detection (frozen gap boundaries) for stable reorder without oscillation. - packFlex utility for left-to-right row-wrapping layout - Nearest-by-distance slot detection on frozen gap boundaries - Worklet path gated off for flex-wrap (JS-thread slot detection only) - scrollContainerOffsetRef for accurate snap targets with padding - New example: sortable-flex.tsx with 24 variable-width tag chips Closes #158, closes #111. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 11 +++- example/app/(tabs)/sortable-flex.tsx | 2 + example/screens/index.tsx | 8 +++ example/screens/sortable-flex.tsx | 99 ++++++++++++++++++++++++++++ src/DraxList.tsx | 52 ++++++++++----- src/hooks/useSortableList.ts | 93 ++++++++++++++++++++++++-- src/index.ts | 4 +- src/math.ts | 59 +++++++++++++++++ src/types.ts | 6 ++ 9 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 example/app/(tabs)/sortable-flex.tsx create mode 100644 example/screens/sortable-flex.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 175abe4..1e37316 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,15 @@ The composable API (`useSortableList` + `SortableContainer` + `SortableItem`) is - `packGrid` exported for users to compute grid positions in their render function - Example: `example/screens/mixed-grid.tsx` — 4-column grid with 1×1, 2×1, 1×2, and 2×2 items +### Sortable Flex (Flex-Wrap Layout) + +- `flexWrap` prop on `DraxList` — items flow left-to-right and wrap to new rows +- `getItemSize` callback returns `{ width, height }` per item (pixel dimensions) +- `packFlex` utility — greedy left-to-right packing with row wrapping +- Uses same virtual slot detection as mixed-size grid: gap layout frozen at drag start +- Slot detection via nearest-by-distance on gap boundaries (no grid cells) +- Example: `example/screens/sortable-flex.tsx` — variable-width tags with drag-to-reorder + ## Cross-Container Sortable (Board) - `useSortableBoard` hook — board-level coordinator for cross-container transfers @@ -139,7 +148,7 @@ We compete with two libraries. Drax must match or exceed their DX while keeping - Sorting only — no free-form DnD, no cross-container drag, no collision algorithms, no built-in accessibility (manual only), no snap alignment - Grid/Flex components do NOT spread ViewProps — accessibility props must go on inner children content - Drax advantage: cross-container drag, monitoring views, free-form DnD, collision algorithms, built-in accessibility + reduced motion, animation presets, snap alignment (9-point + custom), 15 drag state style props, list-agnostic API, 19-callback event system, UI-thread DnD collision -- Drax missing: sortable flex layout, haptic feedback, item removal animation, fixed-order items, collapsible items, debug mode +- Drax missing: haptic feedback, item removal animation, fixed-order items, collapsible items, debug mode **react-native-reanimated-dnd** (https://github.com/entropyconquers/react-native-reanimated-dnd) — Docs: https://reanimated-dnd-docs.vercel.app/ - v2 released March 2026: Reanimated ≥4.2 + react-native-worklets ≥0.7, sortable grids (insert + swap), free-form DnD diff --git a/example/app/(tabs)/sortable-flex.tsx b/example/app/(tabs)/sortable-flex.tsx new file mode 100644 index 0000000..c2fe01c --- /dev/null +++ b/example/app/(tabs)/sortable-flex.tsx @@ -0,0 +1,2 @@ +import { clientScreen } from '../../components/ClientOnly'; +export default clientScreen(() => import('../../screens/sortable-flex'), 'Sortable Flex'); diff --git a/example/screens/index.tsx b/example/screens/index.tsx index 6462e12..968e2a8 100644 --- a/example/screens/index.tsx +++ b/example/screens/index.tsx @@ -42,6 +42,14 @@ const EXAMPLES: Example[] = [ sourceFile: 'mixed-grid.tsx', docsSlug: 'mixed-grid', }, + { + route: '/sortable-flex', + title: 'Sortable Flex', + subtitle: 'Flex-wrap tags with drag-to-reorder', + icon: 'tag-multiple', + sourceFile: 'sortable-flex.tsx', + docsSlug: 'sortable-flex', + }, { route: '/drag-handles', title: 'Drag Handles', diff --git a/example/screens/sortable-flex.tsx b/example/screens/sortable-flex.tsx new file mode 100644 index 0000000..1f4be14 --- /dev/null +++ b/example/screens/sortable-flex.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { StyleSheet, View, Text } from 'react-native'; +import { DraxProvider, DraxList } from 'react-native-drax'; +import { useTheme, itemColor } from '../components/ThemeContext'; + +const COLORS = [ + '#ff6b6b', '#ffa06b', '#ffd96b', '#a8e06b', '#6be0a8', + '#6bd4e0', '#6b9fe0', '#8b6be0', '#d46be0', '#e06ba8', + '#ff8888', '#ffbb88', '#ffee88', '#bbee88', '#88eebb', + '#88e0ee', '#88b4ee', '#a488ee', '#e088ee', '#ee88bb', +]; + +interface Tag { + id: string; + label: string; + color: string; +} + +const TAG_HEIGHT = 36; +const TAG_PADDING_H = 14; +const CHAR_WIDTH = 8.5; // Approximate monospace-ish width per character +const GAP = 8; + +function estimateTagWidth(label: string): number { + return label.length * CHAR_WIDTH + TAG_PADDING_H * 2; +} + +const initialData: Tag[] = [ + 'React Native', 'TypeScript', 'Reanimated', 'Gesture Handler', + 'Drax', 'Drag & Drop', 'Tags', 'Flex Wrap', 'Sortable', + 'iOS', 'Android', 'Web', 'Expo', 'Metro', 'Fabric', + 'JSI', 'Hermes', 'Worklets', 'UI Thread', 'SharedValue', + 'Spring', 'Timing', 'Layout', 'Animation', +].map((label, i) => ({ + id: `tag-${i}`, + label, + color: COLORS[i % COLORS.length]!, +})); + +export default function SortableFlex() { + const [data, setData] = useState(initialData); + const { theme, isDark } = useTheme(); + const padding = 16; + + return ( + + + + + Flex-wrap tags — drag to reorder + + + + data={data} + keyExtractor={(tag) => tag.id} + flexWrap + getItemSize={(tag) => ({ + width: estimateTagWidth(tag.label), + height: TAG_HEIGHT, + })} + gridGap={GAP} + estimatedItemSize={TAG_HEIGHT} + drawDistance={300} + animationConfig="spring" + longPressDelay={200} + onReorder={({ data: newData }) => setData(newData)} + renderItem={({ item }) => ( + + + {item.label} + + + )} + style={[styles.list, { paddingHorizontal: padding }]} + /> + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { padding: 12, alignItems: 'center' }, + headerText: { fontSize: 14, fontStyle: 'italic', textAlign: 'center' }, + list: { flex: 1 }, + tag: { + height: TAG_HEIGHT, + borderRadius: TAG_HEIGHT / 2, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: TAG_PADDING_H, + }, + tagText: { fontSize: 14, fontWeight: '600' }, +}); diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 107024c..9de82e6 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -115,8 +115,12 @@ export interface DraxListProps { dragHandle?: boolean; /** Returns grid span per item. Enables mixed-size grid with bin-packing. */ getItemSpan?: (item: T, index: number) => import('./types').GridItemSpan; - /** Gap between grid cells in pixels. @default 0 */ + /** Gap between items in pixels. @default 0 */ gridGap?: number; + /** Enable flex-wrap layout. Items flow left-to-right and wrap to new rows. */ + flexWrap?: boolean; + /** Returns pixel dimensions per item. Required when flexWrap is true. */ + getItemSize?: (item: T, index: number) => { width: number; height: number }; /** Forwarded to the internal ScrollView. */ onScroll?: (event: NativeSyntheticEvent) => void; /** Scroll event throttle in ms. @default 16 */ @@ -172,6 +176,8 @@ export const DraxList = (props: DraxListProps) => { dragHandle, getItemSpan, gridGap, + flexWrap, + getItemSize, onScroll: onScrollProp, scrollEventThrottle = 16, style, @@ -209,6 +215,8 @@ export const DraxList = (props: DraxListProps) => { drawDistance, getItemSpan, gridGap, + flexWrap, + getItemSize, }); const int = sortable._internal; @@ -255,8 +263,10 @@ export const DraxList = (props: DraxListProps) => { }, [boardContext, int]); // ── Worklet config for UI-thread slot detection ── + // Flex-wrap and grids use JS-side slot detection (packFlex/packGrid). + // The worklet only supports linear 1D lists (cursor-based positioning). const sortableWorkletConfig = useMemo( - () => ({ + () => flexWrap || numColumns > 1 ? null : ({ frozenBoundariesSV: int.frozenBoundariesSV, orderedKeysSV: int.orderedKeysSV, basePositionsSV: int.basePositionsSV, @@ -275,7 +285,7 @@ export const DraxList = (props: DraxListProps) => { getSlotFromPositionWorklet: int.getSlotFromPositionWorklet, recomputeShiftsWorklet: int.recomputeShiftsWorklet, }), - [int, numColumns, horizontal, estimatedItemSize, reorderStrategy] + [int, numColumns, horizontal, estimatedItemSize, reorderStrategy, flexWrap] ); // ── Cell pool (refs only) ── @@ -546,7 +556,8 @@ export const DraxList = (props: DraxListProps) => { // The default hover auto-sizes from DraxView measurements, but flex:1 items // may have stale measurements if the cell was recently recycled. The computed // dimensions from packGrid are always authoritative. - if (getItemSpan && numColumns > 1) { + // Set hover dimensions from computed item size for grids and flex-wrap. + if ((getItemSpan && numColumns > 1) || flexWrap) { const dims = int.itemDimensionsRef.current.get(itemKey); if (dims) { hoverDimsSV.value = { x: dims.width, y: dims.height }; @@ -697,10 +708,12 @@ export const DraxList = (props: DraxListProps) => { if (boardContext?.transferRef?.current) return; const dragKey = int.draggedKeySV.value; - const workletHandlesShifts = numColumns === 1 && !!sortableWorkletConfig; + const workletHandlesShifts = numColumns === 1 && !!sortableWorkletConfig && !flexWrap; let targetSlot: number; let gridResult: ReturnType | null = null; + + if (workletHandlesShifts) { // Read slot from worklet SV. On the first 1-2 frames, this may be stale (initial 0). // Only update indicator if the worklet's slot matches a valid drag position change. @@ -837,7 +850,7 @@ export const DraxList = (props: DraxListProps) => { // For grids (JS path): compute from JS shiftsSV (worklet didn't handle slot detection) let visualX: number; let visualY: number; - if (numColumns === 1) { + if (numColumns === 1 && !flexWrap) { const keys = int.orderedKeysSV.value; const heights = int.itemHeightsSV.value; let cursor = 0; @@ -869,7 +882,7 @@ export const DraxList = (props: DraxListProps) => { } return DraxSnapbackTargetPreset.Default; - }, [int, stopAutoScroll, boardContext, horizontal]); + }, [int, stopAutoScroll, boardContext, horizontal, numColumns, flexWrap, estimatedItemSize]); const onMonitorDragEnd = useCallback( (_eventData: DraxMonitorEndEventData): DraxProtocolDragEndResponse => { @@ -1011,16 +1024,21 @@ export const DraxList = (props: DraxListProps) => { // Vertical: cell fills column width (users center via alignSelf on their card) // Horizontal: cell auto-sizes to content (primary axis measurement) // Grid: use computed dimensions - const itemCellWidth = horizontal - ? undefined // auto-size for primary axis measurement - : (dims?.width ?? cellWidthForGrid); // fill column - const itemCellHeight = horizontal - ? cellWidthForGrid // fill row height - : getItemSpan - ? dims?.height - : undefined; - // flex:1 for mixed-size grids (cells have explicit height from packGrid) - const fillStyle = getItemSpan && numColumns > 1 ? { flex: 1 } : undefined; + const itemCellWidth = flexWrap + ? dims?.width // flex-wrap: exact item width from packFlex + : horizontal + ? undefined // auto-size for primary axis measurement + : (dims?.width ?? cellWidthForGrid); // fill column + const itemCellHeight = flexWrap + ? dims?.height // flex-wrap: exact item height from packFlex + : horizontal + ? cellWidthForGrid // fill row height + : getItemSpan + ? dims?.height + : undefined; + // flex:1 for mixed-size grids (cells have explicit height from packGrid). + // NOT for flex-wrap (items have their own natural size). + const fillStyle = !flexWrap && getItemSpan && numColumns > 1 ? { flex: 1 } : undefined; return ( { getItemSpan?: (item: T, index: number) => GridItemSpan; /** Gap between grid cells in pixels. @default 0 */ gridGap?: number; + /** Enable flex-wrap layout mode. Items flow left-to-right and wrap to new rows. */ + flexWrap?: boolean; + /** Returns pixel dimensions for each item. Required when flexWrap is true. */ + getItemSize?: (item: T, index: number) => { width: number; height: number }; } export interface SortableListHandle { @@ -194,6 +198,8 @@ export const useSortableList = ( drawDistance = 250, getItemSpan, gridGap = 0, + flexWrap = false, + getItemSize, } = options; const id = useDraxId(options.id); @@ -216,8 +222,12 @@ export const useSortableList = ( const getItemSpanRef = useRef<((item: unknown, index: number) => GridItemSpan) | undefined>( getItemSpan as ((item: unknown, index: number) => GridItemSpan) | undefined ); + const getItemSizeRef = useRef<((item: unknown, index: number) => { width: number; height: number }) | undefined>( + getItemSize as ((item: unknown, index: number) => { width: number; height: number }) | undefined + ); keyExtractorRef.current = keyExtractor as (item: unknown, index: number) => string; getItemSpanRef.current = getItemSpan as ((item: unknown, index: number) => GridItemSpan) | undefined; + getItemSizeRef.current = getItemSize as ((item: unknown, index: number) => { width: number; height: number }) | undefined; // ── SharedValues ── const shiftsSV = useSharedValue>({}); @@ -257,7 +267,7 @@ export const useSortableList = ( if (isDraggingRef.current) { // Compute correct shift for single-column (worklet path). // Grid path recomputes all shifts via JS on next dragOver. - if (numColumns === 1) { + if (numColumns === 1 && !flexWrap) { const orderedKeys = orderedKeysSV.value; const basePos = basePositionsRef.current.get(key); if (basePos && orderedKeys.includes(key)) { @@ -348,6 +358,8 @@ export const useSortableList = ( const frozenGapGeometryRef = useRef<{ cellSize: number; gap: number; numColumns: number; totalRows: number; } | null>(null); + /** Flex-wrap gap boundaries: positions of items packed WITHOUT the dragged item. */ + const frozenFlexGapBoundariesRef = useRef([]); // ── Drag state refs ── const isDraggingRef = useRef(false); @@ -369,6 +381,28 @@ export const useSortableList = ( const positions = new Map(); const dimensions = new Map(); + // Flex-wrap: variable-width items flowing left-to-right with wrapping + const sizeFn = getItemSizeRef.current; + if (flexWrap && sizeFn && cw > 0) { + const data = dataRef.current; + const keyMap = keyToIndexRef.current; + const result = packFlex(cw, keys.length, (i) => { + const key = keys[i]!; + const idx = keyMap.get(key); + if (idx !== undefined && data[idx] !== undefined) { + return sizeFn(data[idx]!, idx); + } + return { width: estimatedItemSize, height: estimatedItemSize }; + }, gap); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; + positions.set(key, result.positions[i]!); + dimensions.set(key, result.dimensions[i]!); + } + return { positions, dimensions, totalHeight: result.totalHeight }; + } + const spanFn = getItemSpanRef.current; if (spanFn && numColumns > 1) { // Mixed-size grid: use packGrid for bin-packing @@ -566,8 +600,38 @@ export const useSortableList = ( const keyToIdx = new Map(); gapKeys.forEach((k, i) => keyToIdx.set(k, i)); frozenGapKeyToIndexRef.current = keyToIdx; + } else if (flexWrap && getItemSizeRef.current && currentDragKey) { + // Flex-wrap gap layout: pack without dragged item, store boundaries + const flexSizeFn = getItemSizeRef.current; + if (flexSizeFn) { + const gapKeys = keys.filter(k => k !== currentDragKey); + const data = dataRef.current; + const keyMap = keyToIndexRef.current; + const cw = containerWidthRef.current; + const gapResult = packFlex(cw, gapKeys.length, (i) => { + const key = gapKeys[i]!; + const idx = keyMap.get(key); + if (idx !== undefined && data[idx] !== undefined) { + return flexSizeFn(data[idx]!, idx); + } + return { width: estimatedItemSize, height: estimatedItemSize }; + }, gridGap); + + const gapBoundaries = gapKeys.map((key, i) => ({ + key, + x: gapResult.positions[i]!.x, + y: gapResult.positions[i]!.y, + width: gapResult.dimensions[i]!.width, + height: gapResult.dimensions[i]!.height, + })); + frozenFlexGapBoundariesRef.current = gapBoundaries; + + const keyToIdx = new Map(); + gapKeys.forEach((k, i) => keyToIdx.set(k, i)); + frozenGapKeyToIndexRef.current = keyToIdx; + } } - }, [estimatedItemSize, horizontal, gridGap, numColumns]); + }, [estimatedItemSize, horizontal, gridGap, numColumns, flexWrap]); const getSlotFromPosition = useCallback((contentX: number, contentY: number): number => { const boundaries = frozenBoundariesRef.current; @@ -575,6 +639,27 @@ export const useSortableList = ( return 0; } + // Flex-wrap: nearest-by-distance on frozen gap boundaries + if (flexWrap) { + const gapBounds = frozenFlexGapBoundariesRef.current; + const gapKeyToIndex = frozenGapKeyToIndexRef.current; + if (gapBounds.length > 0 && gapKeyToIndex.size > 0) { + let bestKey = ''; + let bestDist = Infinity; + for (const b of gapBounds) { + const cx = b.x + b.width / 2; + const cy = b.y + b.height / 2; + const dist = Math.abs(contentX - cx) + Math.abs(contentY - cy); + if (dist < bestDist) { bestDist = dist; bestKey = b.key; } + } + if (bestKey) { + const idx = gapKeyToIndex.get(bestKey); + return idx !== undefined ? idx : -1; + } + } + return -1; + } + if (numColumns > 1) { // Mixed-size grid: virtual slot detection via frozen gap layout. // The gap layout (pack WITHOUT dragged item) is computed once at drag start. @@ -648,7 +733,7 @@ export const useSortableList = ( } } return boundaries.length - 1; - }, [numColumns, horizontal]); + }, [numColumns, horizontal, flexWrap]); // ── Worklet: slot detection (runs on UI thread) ── diff --git a/src/index.ts b/src/index.ts index 1fb63c8..bf5db61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,8 +20,8 @@ export { useSortableList } from './hooks/useSortableList'; export { useSortableBoard } from './hooks/useSortableBoard'; // ── Public Utilities ───────────────────────────────────────────────── -export { snapToAlignment, packGrid } from './math'; -export type { SnapAlignment, GridPackResult } from './math'; +export { snapToAlignment, packGrid, packFlex } from './math'; +export type { SnapAlignment, GridPackResult, FlexPackResult } from './math'; // ── Public Types ───────────────────────────────────────────────────── export type { diff --git a/src/math.ts b/src/math.ts index 756e73c..efdce66 100644 --- a/src/math.ts +++ b/src/math.ts @@ -345,3 +345,62 @@ export function packGrid( return { positions, totalRows, cellOwners }; } + +// ─── Flex-wrap packing ──────────────────────────────────────────────── + +/** Result of packing items into a flex-wrap layout */ +export interface FlexPackResult { + /** Pixel position (x, y) for each item, in input order */ + positions: { x: number; y: number }[]; + /** Pixel dimensions for each item, in input order */ + dimensions: { width: number; height: number }[]; + /** Total height of the packed layout */ + totalHeight: number; +} + +/** + * Pack variable-width items into a flex-wrap layout. + * Items are placed left-to-right, wrapping to the next row when the + * next item would overflow the container width. + * + * @param containerWidth Available width for items + * @param count Number of items to pack + * @param getSize Returns pixel {width, height} for the item at the given index + * @param gap Space between items (both horizontal and vertical) + */ +export function packFlex( + containerWidth: number, + count: number, + getSize: (index: number) => { width: number; height: number }, + gap: number = 0, +): FlexPackResult { + const positions: { x: number; y: number }[] = []; + const dimensions: { width: number; height: number }[] = []; + let cursorX = 0; + let cursorY = 0; + let maxRowHeight = 0; + + for (let i = 0; i < count; i++) { + const size = getSize(i); + const w = Math.min(size.width, containerWidth); // Clamp to container + const h = size.height; + + // Wrap to next row if item doesn't fit (but always place first item in row) + if (cursorX > 0 && cursorX + w > containerWidth) { + cursorY += maxRowHeight + gap; + cursorX = 0; + maxRowHeight = 0; + } + + positions.push({ x: cursorX, y: cursorY }); + dimensions.push({ width: w, height: h }); + maxRowHeight = Math.max(maxRowHeight, h); + cursorX += w + gap; + } + + return { + positions, + dimensions, + totalHeight: count > 0 ? cursorY + maxRowHeight : 0, + }; +} diff --git a/src/types.ts b/src/types.ts index 01215dd..3a99991 100644 --- a/src/types.ts +++ b/src/types.ts @@ -742,6 +742,10 @@ export interface UseSortableListOptions { * where items can span multiple columns and/or rows. * Only used when numColumns > 1. */ getItemSpan?: (item: T, index: number) => GridItemSpan; + /** Enable flex-wrap layout. Items flow left-to-right and wrap to new rows. */ + flexWrap?: boolean; + /** Returns pixel dimensions per item. Required when flexWrap is true. */ + getItemSize?: (item: T, index: number) => { width: number; height: number }; /** Style applied to all non-dragged items while a drag is active. * Use for dimming/scaling inactive items (e.g., `{ opacity: 0.5 }`). */ inactiveItemStyle?: ViewStyle; @@ -782,6 +786,8 @@ export interface SortableListInternal { animationConfig: SortableAnimationConfig; /** Returns the grid span for an item (non-uniform grid layout) */ getItemSpan?: (item: T, index: number) => GridItemSpan; + flexWrap?: boolean; + getItemSize?: (item: T, index: number) => { width: number; height: number }; inactiveItemStyle?: ViewStyle; itemEntering?: EntryOrExitLayoutType; itemExiting?: EntryOrExitLayoutType; From 6c2d63084ac3c0e5b77a73da7016b9f9476d04ed Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:14:22 +0200 Subject: [PATCH 05/12] fix: recicler remeasurement --- example/screens/reorderable-list.tsx | 2 +- src/DraxList.tsx | 46 +++++++++++++++++++++------- src/hooks/useSortableList.ts | 45 +++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/example/screens/reorderable-list.tsx b/example/screens/reorderable-list.tsx index 12c286d..f8a1249 100644 --- a/example/screens/reorderable-list.tsx +++ b/example/screens/reorderable-list.tsx @@ -63,7 +63,7 @@ export default function ReorderableList() { data={data} keyExtractor={(item) => item.id} estimatedItemSize={60} - drawDistance={300} + getItemSize={(item) => ({ width: 0, height: item.height + 4 })} animationConfig="spring" longPressDelay={200} onReorder={({ data: newData }) => setData(newData)} diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 9de82e6..40767ff 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -168,7 +168,7 @@ export const DraxList = (props: DraxListProps) => { id: _idProp, numColumns = 1, horizontal = false, - drawDistance = 250, + drawDistance: drawDistanceProp, animationConfig = 'default', longPressDelay = 250, lockToMainAxis, @@ -193,7 +193,13 @@ export const DraxList = (props: DraxListProps) => { } = props; const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - + + // Smart default: pre-render ~3 viewports of items off-screen in each direction. + // Items measure off-screen BEFORE scrolling into view, reducing position + // adjustments from estimatedItemSize mismatches during fast scroll. + const viewportSize = horizontal ? screenWidth : screenHeight; + const drawDistance = drawDistanceProp ?? viewportSize * 3; + const scrollRef = useRef(null); // ── Single re-render trigger (the ONLY thing that causes re-render) ── @@ -291,6 +297,7 @@ export const DraxList = (props: DraxListProps) => { // ── Cell pool (refs only) ── const cellBindingsRef = useRef([]); const freeCellsRef = useRef([]); + const cellLastHeightRef = useRef>(new Map()); // cellKey → last measured height const bindingMapRef = useRef>(new Map()); // itemKey → cellKey const visibleKeysRef = useRef(new Set()); // Reused across scroll ticks (no allocation) const nextCellIdRef = useRef(0); @@ -339,7 +346,7 @@ export const DraxList = (props: DraxListProps) => { const current = int.itemHeightsRef.current.get(itemKey); const changed = current === undefined || Math.abs(current - height) > 0.5; if (changed) { - int.itemHeightsRef.current.set(itemKey, height); + int.recordItemHeight(itemKey, height); itemsMeasuredRef.current = true; // At least one real measurement — positions will be correct if (int.isDraggingRef.current) { // Sync to worklet so recomputeShiftsWorklet uses actual measurements @@ -362,23 +369,21 @@ export const DraxList = (props: DraxListProps) => { (scrollOffset: number) => { const keys = int.orderedKeysRef.current; const heights = int.itemHeightsRef.current; - const viewportSize = horizontal + const containerSize = horizontal ? (int.containerMeasRef.current?.width ?? screenWidth) : (int.containerMeasRef.current?.height ?? screenHeight); const buffer = drawDistance; const visibleStart = scrollOffset - buffer; - const visibleEnd = scrollOffset + viewportSize + buffer; + const visibleEnd = scrollOffset + containerSize + buffer; // Find visible items using visual positions (base + shift). const basePositions = int.basePositionsRef.current; const shifts = int.shiftsSV.value; visibleKeysRef.current.clear(); const visibleKeys = visibleKeysRef.current; - let missingBaseCount = 0; for (const key of keys) { const basePos = basePositions.get(key); if (!basePos) { - missingBaseCount++; visibleKeys.add(key); continue; } @@ -401,8 +406,6 @@ export const DraxList = (props: DraxListProps) => { visibleKeys.add(key); } } - if (missingBaseCount > 0) { - } // Diff: unbind items that left, bind items that entered const currentMap = bindingMapRef.current; @@ -419,11 +422,20 @@ export const DraxList = (props: DraxListProps) => { } // Bind + let proactiveMeasured = false; for (const itemKey of visibleKeys) { if (!currentMap.has(itemKey)) { let cellKey: string; if (freeCellsRef.current.length > 0) { cellKey = freeCellsRef.current.pop()!; + // Proactive measurement: use cell's last known height for the new item. + // If the height matches (same-size item recycled), position is correct + // immediately with no onLayout wait. If different, onLayout corrects in 1 frame. + const cellHeight = cellLastHeightRef.current.get(cellKey); + if (cellHeight !== undefined && !int.itemHeightsRef.current.has(itemKey)) { + int.recordItemHeight(itemKey, cellHeight); + proactiveMeasured = true; + } } else { cellKey = `cell-${nextCellIdRef.current++}`; } @@ -432,6 +444,13 @@ export const DraxList = (props: DraxListProps) => { } } + // Recompute positions if proactive measurements changed any heights. + // Without this, items stay at estimated positions when proactive height + // matches actual (onLayout won't fire → handleItemLayout won't recompute). + if (proactiveMeasured) { + int.recomputeBasePositions(); + } + if (changed) { const newBindings: CellBinding[] = []; for (const [itemKey, cellKey] of currentMap.entries()) { @@ -1020,6 +1039,9 @@ export const DraxList = (props: DraxListProps) => { const item = int.dataRef.current[dataIndex]; const basePos = int.basePositionsRef.current.get(itemKey); if (!item || !basePos) return null; + // Hide unmeasured items off-screen until onLayout fires and positions correct. + // Items with known sizes via getItemSize are always considered measured. + const isMeasured = int.itemHeightsRef.current.has(itemKey) || !!getItemSize; const dims = int.itemDimensionsRef.current.get(itemKey); // Vertical: cell fills column width (users center via alignSelf on their card) // Horizontal: cell auto-sizes to content (primary axis measurement) @@ -1043,8 +1065,8 @@ export const DraxList = (props: DraxListProps) => { return ( (props: DraxListProps) => { style={fillStyle} > { // Primary axis from outer wrapper (fills cell) @@ -1077,6 +1100,7 @@ export const DraxList = (props: DraxListProps) => { ? e.nativeEvent.layout.width : e.nativeEvent.layout.height; handleItemLayout(itemKey, primary); + cellLastHeightRef.current.set(cellKey, primary); }} > { pendingShiftClearRef: React.RefObject; // ── Layout engine ── + /** Record a measured height and update the running average for unmeasured items. */ + recordItemHeight: (key: string, height: number) => void; computeGridPositions: (keys: string[]) => { positions: Map; dimensions: Map; totalHeight: number }; recomputeBasePositions: () => void; recomputeBasePositionsAndClearShifts: () => void; @@ -209,6 +211,11 @@ export const useSortableList = ( const orderedKeysRef = useRef(externalData.map((item, i) => keyExtractor(item, i))); const basePositionsRef = useRef>(new Map()); const itemHeightsRef = useRef>(new Map()); + // Running average of measured heights — used for unmeasured items instead of + // estimatedItemSize. Automatically includes margins, padding, etc. + // Inspired by FlashList's MultiTypeAverageWindow. + const measuredAvgHeightRef = useRef(estimatedItemSize); + const measuredCountRef = useRef(0); const itemCrossAxisRef = useRef>(new Map()); const totalContentSizeRef = useRef(0); const containerMeasRef = useRef<{ x: number; y: number; width: number; height: number } | undefined>(undefined); @@ -444,13 +451,14 @@ export const useSortableList = ( if (numColumns > 1 && cw > 0) { // Uniform grid const heights = itemHeightsRef.current; + const avgH = measuredAvgHeightRef.current; let cursorY = 0; let maxRowHeight = 0; for (let i = 0; i < keys.length; i++) { const key = keys[i]!; const col = i % numColumns; if (col === 0 && i > 0) { cursorY += maxRowHeight; maxRowHeight = 0; } - const h = heights.get(key) ?? estimatedItemSize; + const h = heights.get(key) ?? avgH; positions.set(key, { x: col * cellSize, y: cursorY }); dimensions.set(key, { width: cellSize, height: h }); maxRowHeight = Math.max(maxRowHeight, h); @@ -460,9 +468,21 @@ export const useSortableList = ( // Linear list — alignment handled by inner wrapper's alignSelf (from contentContainerStyle.alignItems) const heights = itemHeightsRef.current; + const avgH = measuredAvgHeightRef.current; + const itemSizeFn = getItemSizeRef.current; + const data = dataRef.current; + const keyMap = keyToIndexRef.current; let cursor = 0; for (const key of keys) { - const h = heights.get(key) ?? estimatedItemSize; + // Priority: measured height > getItemSize callback > running average + let h = heights.get(key); + if (h === undefined && itemSizeFn) { + const idx = keyMap.get(key); + if (idx !== undefined && data[idx] !== undefined) { + h = horizontal ? itemSizeFn(data[idx]!, idx).width : itemSizeFn(data[idx]!, idx).height; + } + } + if (h === undefined) h = avgH; if (horizontal) { positions.set(key, { x: cursor, y: 0 }); dimensions.set(key, { width: h, height: cw || 0 }); @@ -475,6 +495,26 @@ export const useSortableList = ( return { positions, dimensions, totalHeight: cursor }; } + /** Record a measured height and update the running average. + * The running average is used for unmeasured items in computeGridPositions, + * automatically compensating for margins/padding that estimatedItemSize misses. */ + function recordItemHeight(key: string, height: number) { + const prev = itemHeightsRef.current.get(key); + itemHeightsRef.current.set(key, height); + if (prev === undefined) { + // First measurement for this item — update running average + measuredCountRef.current++; + const n = measuredCountRef.current; + measuredAvgHeightRef.current += (height - measuredAvgHeightRef.current) / n; + } else if (Math.abs(prev - height) > 0.5) { + // Height changed — adjust running average (subtract old, add new) + const n = measuredCountRef.current; + if (n > 0) { + measuredAvgHeightRef.current += (height - prev) / n; + } + } + } + // ── Layout engine ── /** Recompute base positions. Does NOT clear shifts (caller decides). */ function recomputeBasePositions() { @@ -1021,6 +1061,7 @@ export const useSortableList = ( currentSlotRef, frozenBoundariesRef, pendingShiftClearRef, + recordItemHeight, computeGridPositions, recomputeBasePositions, recomputeBasePositionsAndClearShifts, From 5dd238981325aade83692a5e25ec854be4f207f1 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:17:29 +0200 Subject: [PATCH 06/12] feat: performance --- CLAUDE.md | 1 + example/screens/reorderable-list.tsx | 72 ++++-- src/DraxList.tsx | 322 +++++++++++++++++++-------- src/hooks/useDragGesture.ts | 8 + src/hooks/useSortableList.ts | 19 +- 5 files changed, 311 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e37316..90e73b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ Drag-and-drop framework for React Native (iOS, Android, Web). v1.0.0 — major r - **Reanimated 4 + Gesture Handler 3** (beta) - Single `HoverLayer`, per-view gesture handlers - Latest React features. +- **New Architecture (Fabric)**: `useLayoutEffect` + `measure()` is synchronous (JSI SyncCallback). Measurement and state updates happen in a single commit before paint — no intermediate states visible to users. Use `useLayoutEffect` + `ref.measure()` instead of `onLayout` for item measurement. Reference: https://reactnative.dev/architecture/landing-page#synchronous-layout-and-effects ## Sortable Architecture diff --git a/example/screens/reorderable-list.tsx b/example/screens/reorderable-list.tsx index f8a1249..4178d69 100644 --- a/example/screens/reorderable-list.tsx +++ b/example/screens/reorderable-list.tsx @@ -1,11 +1,19 @@ import { useState } from 'react'; -import { StyleSheet, View, Text, Pressable } from 'react-native'; -import { DraxProvider, DraxList } from 'react-native-drax'; -import { useTheme, itemColor } from '../components/ThemeContext'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { DraxList, DraxProvider } from 'react-native-drax'; +import { itemColor, useTheme } from '../components/ThemeContext'; const COLORS = [ - '#ff6b6b', '#ffa06b', '#ffd96b', '#a8e06b', '#6be0a8', - '#6bd4e0', '#6b9fe0', '#8b6be0', '#d46be0', '#e06ba8', + '#ff6b6b', + '#ffa06b', + '#ffd96b', + '#a8e06b', + '#6be0a8', + '#6bd4e0', + '#6b9fe0', + '#8b6be0', + '#d46be0', + '#e06ba8', ]; const getHeight = (i: number) => { @@ -42,8 +50,13 @@ export default function ReorderableList() { onPress={() => { const id = `item-${Date.now()}-${nextId++}`; const h = getHeight(Math.floor(Math.random() * 6)); - setData(prev => [ - { id, label: `New ${prev.length + 1}`, color: COLORS[prev.length % COLORS.length]!, height: h }, + setData((prev) => [ + { + id, + label: `New ${prev.length + 1}`, + color: COLORS[prev.length % COLORS.length]!, + height: h, + }, ...prev, ]); }} @@ -52,7 +65,9 @@ export default function ReorderableList() { + Add Top data.length > 0 && setData(prev => prev.slice(1))} + onPress={() => + data.length > 0 && setData((prev) => prev.slice(1)) + } style={styles.btn} > - Remove Top @@ -68,17 +83,29 @@ export default function ReorderableList() { longPressDelay={200} onReorder={({ data: newData }) => setData(newData)} renderItem={({ item, index }) => ( - - - {item.label} - - - #{index} · {item.height}px - - + + + {item.label} + + + #{index} · {item.height}px + + )} style={styles.list} /> @@ -92,7 +119,12 @@ const styles = StyleSheet.create({ header: { padding: 12, alignItems: 'center' }, headerText: { fontSize: 14, fontStyle: 'italic' }, buttons: { flexDirection: 'row', gap: 12, marginTop: 8 }, - btn: { backgroundColor: '#4a90d9', paddingHorizontal: 16, paddingVertical: 6, borderRadius: 6 }, + btn: { + backgroundColor: '#4a90d9', + paddingHorizontal: 16, + paddingVertical: 6, + borderRadius: 6, + }, btnText: { color: '#fff', fontWeight: '600' }, list: { flex: 1 }, item: { diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 40767ff..204f4b2 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -9,6 +9,7 @@ */ import type { ReactNode } from 'react'; import { + memo, useCallback, useEffect, useLayoutEffect, @@ -155,6 +156,67 @@ interface CellBinding { itemKey: string; } +// ─── Measured Content (single View replacing the old 2-View wrapper) ── + +interface MeasuredContentProps { + itemKey: string; + cellKey: string; + horizontal: boolean; + skipMeasurement: boolean; + fillStyle: { flex: number } | undefined; + alignSelf: string; + onMeasure: (itemKey: string, height: number) => void; + onCellHeight: React.RefObject>; + children: ReactNode; +} + +const MeasuredContent = memo(({ + itemKey, + cellKey, + horizontal, + skipMeasurement, + fillStyle, + alignSelf, + onMeasure, + onCellHeight, + children, +}: MeasuredContentProps) => { + const ref = useRef(null); + + // New Architecture: useLayoutEffect + measure() runs synchronously before paint (JSI). + // https://reactnative.dev/architecture/landing-page#synchronous-layout-and-effects + // key={itemKey} forces remount on recycle → useLayoutEffect fires → guaranteed measurement. + useLayoutEffect(() => { + console.log(`[EFFECT] useLayoutEffect fired key=${itemKey} skip=${skipMeasurement} hasRef=${!!ref.current}`); + if (skipMeasurement) return; + if (!ref.current) { + console.log(`[MEASURE] ref null for key=${itemKey}`); + return; + } + const hasMeasure = typeof ref.current.measure === 'function'; + if (!hasMeasure) { + console.log(`[MEASURE] NO measure() on ref for key=${itemKey}, type=${typeof ref.current}`); + return; + } + ref.current.measure((_x: number, _y: number, width: number, height: number) => { + const primary = horizontal ? width : height; + console.log(`[MEASURE] key=${itemKey} w=${width} h=${height} primary=${primary}`); + if (primary > 0) { + onMeasure(itemKey, primary); + onCellHeight.current.set(cellKey, primary); + } + }); + }, [itemKey]); + + return ( + + {children} + + ); +}); + +MeasuredContent.displayName = 'MeasuredContent'; + // ─── Component ──────────────────────────────────────────────────────── export const DraxList = (props: DraxListProps) => { @@ -305,6 +367,11 @@ export const DraxList = (props: DraxListProps) => { const lastIndicatorSlotRef = useRef(-1); // Track indicator's last-set slot (avoids worklet/JS SV race) const itemsMeasuredRef = useRef(false); // True after first item measurement cycle — prevents FOUC + // ── Scroll perf refs ── + const lastProcessedOffsetRef = useRef(0); // For scroll delta threshold (only updates on threshold crossings) + const lastScrollOffsetRef = useRef(0); // Actual last scroll offset (for velocity — updates every event) + const lastScrollTimeRef = useRef(0); // For velocity tracking + const scrollVelocityRef = useRef(0); // px/ms — positive = scrolling forward // ── Container layout ── const handleContainerLayout = useCallback( (event: LayoutChangeEvent) => { @@ -314,6 +381,8 @@ export const DraxList = (props: DraxListProps) => { // ScrollView's offset within the monitoring DraxView (accounts for padding) int.scrollContainerOffsetRef.current = { x, y }; int.recomputeBasePositionsAndClearShifts(); + // Reset scroll delta tracking (positions just changed) + lastProcessedOffsetRef.current = int.scrollOffsetSV.value; // Rebind cells with new positions (grid positions change after container measured) updateVisibleCells(int.scrollOffsetSV.value); forceRender(); @@ -322,43 +391,62 @@ export const DraxList = (props: DraxListProps) => { ); // ── Scroll handling ── + const SCROLL_DELTA_THRESHOLD = 4; // px — skip updateVisibleCells if scroll moved less than this const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const offset = horizontal ? event.nativeEvent.contentOffset.x : event.nativeEvent.contentOffset.y; + // Always sync scrollOffset to UI thread (worklet needs accurate offset for slot detection) runOnUI((_sv: typeof int.scrollOffsetSV, _v: number) => { 'worklet'; _sv.value = _v; })(int.scrollOffsetSV, offset); - // Rebind cells for new visible range - updateVisibleCells(offset); + // Track scroll velocity for asymmetric buffer distribution. + // Uses actual last scroll offset (not lastProcessedOffset which only updates on threshold). + const now = Date.now(); + const dt = now - lastScrollTimeRef.current; + if (dt > 0 && dt < 500) { + scrollVelocityRef.current = (offset - lastScrollOffsetRef.current) / dt; + } + lastScrollOffsetRef.current = offset; + lastScrollTimeRef.current = now; + + // Skip visible cell recalculation if scroll delta below threshold. + // drawDistance buffer (3x viewport) makes this safe. + if (Math.abs(offset - lastProcessedOffsetRef.current) >= SCROLL_DELTA_THRESHOLD) { + lastProcessedOffsetRef.current = offset; + updateVisibleCells(offset); + } + onScrollProp?.(event); }, [horizontal, int.scrollOffsetSV, onScrollProp] ); - // ── Item measurement ── + // ── Item measurement (synchronous) ── const handleItemLayout = useCallback( (itemKey: string, height: number) => { const current = int.itemHeightsRef.current.get(itemKey); const changed = current === undefined || Math.abs(current - height) > 0.5; - if (changed) { - int.recordItemHeight(itemKey, height); - itemsMeasuredRef.current = true; // At least one real measurement — positions will be correct - if (int.isDraggingRef.current) { - // Sync to worklet so recomputeShiftsWorklet uses actual measurements - // (not stale estimatedItemSize from drag-start snapshot) - int.itemHeightsSV.value = { - ...int.itemHeightsSV.value, - [itemKey]: height, - }; - } else if (Object.keys(int.shiftsSV.value).length === 0) { - int.recomputeBasePositions(); - forceRender(); - } + if (!changed) return; + int.recordItemHeight(itemKey, height); + itemsMeasuredRef.current = true; + const isDragging = int.isDraggingRef.current || int.isDraggingSV.value; + const shiftsEmpty = Object.keys(int.shiftsSV.value).length === 0; + if (isDragging) { + console.log(`[MEASURE] DURING DRAG key=${itemKey} h=${height.toFixed(1)}`); + int.itemHeightsSV.value = { + ...int.itemHeightsSV.value, + [itemKey]: height, + }; + } else if (shiftsEmpty) { + int.recomputeBasePositions(); + forceRender(); + } else { + console.log(`[MEASURE] SKIPPED (shifts active) key=${itemKey} h=${height.toFixed(1)}`); } }, [int] @@ -372,38 +460,81 @@ export const DraxList = (props: DraxListProps) => { const containerSize = horizontal ? (int.containerMeasRef.current?.width ?? screenWidth) : (int.containerMeasRef.current?.height ?? screenHeight); - const buffer = drawDistance; - const visibleStart = scrollOffset - buffer; - const visibleEnd = scrollOffset + containerSize + buffer; - // Find visible items using visual positions (base + shift). - const basePositions = int.basePositionsRef.current; - const shifts = int.shiftsSV.value; + // Velocity-aware buffering: distribute buffer asymmetrically based on scroll direction. + // 70% buffer ahead of scroll direction, 30% behind (FlashList pattern). + // During drag: always use symmetric buffer. Switching from asymmetric→symmetric + // when drag starts would unbind cells that were visible under the asymmetric buffer, + // causing layout jumps and visual chaos. + const isDragging = int.isDraggingRef.current; + const velocity = isDragging ? 0 : scrollVelocityRef.current; + const totalBuffer = drawDistance * 2; + let bufferBefore: number; + let bufferAfter: number; + if (Math.abs(velocity) > 0.1) { + bufferAfter = totalBuffer * (velocity > 0 ? 0.7 : 0.3); + bufferBefore = totalBuffer - bufferAfter; + } else { + bufferBefore = bufferAfter = totalBuffer * 0.5; + } + const visibleStart = scrollOffset - bufferBefore; + const visibleEnd = scrollOffset + containerSize + bufferAfter; + visibleKeysRef.current.clear(); const visibleKeys = visibleKeysRef.current; - for (const key of keys) { - const basePos = basePositions.get(key); - if (!basePos) { - visibleKeys.add(key); - continue; + + // Binary search path for linear lists (O(log N + V) instead of O(N)) + const sorted = int.sortedPositionsRef.current; + if (sorted.length > 0 && numColumns === 1 && !flexWrap) { + // Binary search: find first item where end >= visibleStart + let lo = 0; + let hi = sorted.length - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (sorted[mid]!.end < visibleStart) lo = mid + 1; + else hi = mid; } - const shift = shifts[key]; - if (horizontal) { - const visualX = basePos.x + (shift?.x ?? 0); - const w = - int.itemDimensionsRef.current.get(key)?.width ?? - heights.get(key) ?? - estimatedItemSize; - if (visualX + w >= visibleStart && visualX <= visibleEnd) - visibleKeys.add(key); - } else { - const h = - int.itemDimensionsRef.current.get(key)?.height ?? - heights.get(key) ?? - estimatedItemSize; - const visualY = basePos.y + (shift?.y ?? 0); - if (visualY + h >= visibleStart && visualY <= visibleEnd) + // Walk forward collecting visible keys + for (let i = lo; i < sorted.length; i++) { + if (sorted[i]!.start > visibleEnd) break; + visibleKeys.add(sorted[i]!.key); + } + // During drag: pin all currently-bound keys to prevent unbinding cells + // that have shifted away from their base positions via animated transforms. + if (isDragging) { + for (const [itemKey] of bindingMapRef.current) { + visibleKeys.add(itemKey); + } + } + + } else { + // Fallback: O(N) loop for grids/flex-wrap (2D visibility) + const basePositions = int.basePositionsRef.current; + const shifts = int.shiftsSV.value; + for (const key of keys) { + const basePos = basePositions.get(key); + if (!basePos) { visibleKeys.add(key); + continue; + } + const shift = shifts[key]; + if (horizontal) { + const visualX = basePos.x + (shift?.x ?? 0); + const w = + int.itemDimensionsRef.current.get(key)?.width ?? + heights.get(key) ?? + estimatedItemSize; + if (visualX + w >= visibleStart && visualX <= visibleEnd) + visibleKeys.add(key); + } else { + const h = + int.itemDimensionsRef.current.get(key)?.height ?? + heights.get(key) ?? + estimatedItemSize; + const visualY = basePos.y + (shift?.y ?? 0); + if (visualY + h >= visibleStart && visualY <= visibleEnd) + visibleKeys.add(key); + } } } @@ -451,7 +582,12 @@ export const DraxList = (props: DraxListProps) => { int.recomputeBasePositions(); } + if (proactiveMeasured) { + console.log(`[VISIBLE] proactive recompute, totalContent=${int.totalContentSizeRef.current.toFixed(0)}`); + } + if (changed) { + console.log(`[VISIBLE] rebind: ${currentMap.size} cells, scroll=${scrollOffset.toFixed(0)}, isDrag=${isDragging}, visibleKeys=${visibleKeys.size}`); const newBindings: CellBinding[] = []; for (const [itemKey, cellKey] of currentMap.entries()) { newBindings.push({ cellKey, itemKey }); @@ -467,6 +603,8 @@ export const DraxList = (props: DraxListProps) => { screenHeight, drawDistance, estimatedItemSize, + numColumns, + flexWrap, ] ); @@ -484,6 +622,8 @@ export const DraxList = (props: DraxListProps) => { dropIndicatorVisibleSV.value = false; dropIndicatorInfoRef.current = undefined; } + // Reset scroll delta tracking (positions/data just changed) + lastProcessedOffsetRef.current = int.scrollOffsetSV.value; updateVisibleCells(int.scrollOffsetSV.value); // Source list: dragged item was transferred out — clear drag state AFTER cell is unbound. @@ -564,6 +704,7 @@ export const DraxList = (props: DraxListProps) => { if (item === undefined) return; const itemKey = keyExtractor(item, originalIndex); + console.log(`[DRAG START] key=${itemKey} idx=${originalIndex} scroll=${int.scrollOffsetSV.value.toFixed(0)} totalContent=${int.totalContentSizeRef.current.toFixed(0)} cells=${bindingMapRef.current.size} shifts=${Object.keys(int.shiftsSV.value).length}`); int.skipShiftAnimationSV.value = false; // Re-enable shift animations for this drag int.isDraggingRef.current = true; int.draggedKeySV.value = itemKey; @@ -855,27 +996,45 @@ export const DraxList = (props: DraxListProps) => { // During cross-container transfer, board handles snap target if (boardContext?.transferRef?.current) { + console.log(`[DRAG END] skipped: cross-container transfer`); return; // void — let board's snap target stand } - if (!int.isDraggingRef.current) return; + if (!int.isDraggingRef.current) { + console.log(`[DRAG END] skipped: isDragging=false`); + return; + } const dragKey = int.draggedKeySV.value; const basePos = int.basePositionsRef.current.get(dragKey); const containerMeas = int.containerMeasRef.current; + console.log(`[DRAG END] key=${dragKey} hasBasePos=${!!basePos} hasContainerMeas=${!!containerMeas} scroll=${int.scrollOffsetSV.value.toFixed(0)}`); if (basePos && containerMeas) { - // For single-column (worklet path): compute from worklet's orderedKeysSV (most up-to-date) - // For grids (JS path): compute from JS shiftsSV (worklet didn't handle slot detection) let visualX: number; let visualY: number; if (numColumns === 1 && !flexWrap) { + // Single-column: compute target position by walking the reordered keys. + // Use the SAME height sources as computeGridPositions to ensure consistency: + // measured height > getItemSize > running average > estimatedItemSize. const keys = int.orderedKeysSV.value; const heights = int.itemHeightsSV.value; + const avgH = int.measuredAvgHeightRef?.current ?? estimatedItemSize; + const sizeFn = getItemSize; + const dataArr = int.dataRef.current; + const keyMap = int.keyToIndexRef.current; let cursor = 0; for (const key of keys) { if (key === dragKey) break; - cursor += heights[key] ?? estimatedItemSize; + let h = heights[key]; + if (h === undefined && sizeFn) { + const idx = keyMap.get(key); + if (idx !== undefined && dataArr[idx] !== undefined) { + h = horizontal ? sizeFn(dataArr[idx] as T, idx).width : sizeFn(dataArr[idx] as T, idx).height; + } + } + if (h === undefined) h = avgH; + cursor += h; } visualX = horizontal ? cursor : basePos.x; visualY = horizontal ? basePos.y : cursor; @@ -886,18 +1045,11 @@ export const DraxList = (props: DraxListProps) => { } const scOffset = int.scrollContainerOffsetRef.current; - return { - x: - containerMeas.x + - scOffset.x + - visualX - - (horizontal ? int.scrollOffsetSV.value : 0), - y: - containerMeas.y + - scOffset.y + - visualY - - (horizontal ? 0 : int.scrollOffsetSV.value), - }; + const scrollOff = int.scrollOffsetSV.value; + const snapX = containerMeas.x + scOffset.x + visualX - (horizontal ? scrollOff : 0); + const snapY = containerMeas.y + scOffset.y + visualY - (horizontal ? 0 : scrollOff); + console.log(`[DRAG END] SNAP target: visualY=${visualY.toFixed(0)} scroll=${scrollOff.toFixed(0)} containerY=${containerMeas.y.toFixed(0)} avgH=${(int.measuredAvgHeightRef?.current ?? 0).toFixed(1)} snapY=${snapY.toFixed(0)} baseY=${basePos.y.toFixed(0)}`); + return { x: snapX, y: snapY }; } return DraxSnapbackTargetPreset.Default; @@ -950,10 +1102,14 @@ export const DraxList = (props: DraxListProps) => { } // Normal intra-column reorder - if (!int.isDraggingRef.current) return; + if (!int.isDraggingRef.current) { + console.log(`[SNAP END] isDragging=false, skipping commit`); + return; + } const fromIdx = int.dragStartIndexRef.current; const toIdx = int.currentSlotRef.current; const fromItem = int.dataRef.current[fromIdx]; + console.log(`[SNAP END] commit from=${fromIdx} to=${toIdx} scroll=${int.scrollOffsetSV.value.toFixed(0)} totalContent=${int.totalContentSizeRef.current.toFixed(0)}`); // Only sync from worklet when it handled slot detection (single-column lists). // For grids (numColumns > 1), JS handled slot detection — orderedKeysRef is already correct. if (numColumns === 1 && sortableWorkletConfig) { @@ -1027,8 +1183,7 @@ export const DraxList = (props: DraxListProps) => { horizontal ? { width: totalSize, height: '100%' } : { height: totalSize, width: '100%' }, - // Hide until items have measured — prevents FOUC from estimated positions - !itemsMeasuredRef.current && { opacity: 0 }, + // useLayoutEffect + measure() corrects positions before paint — no FOUC ]} > {bindings.map((binding) => { @@ -1039,9 +1194,9 @@ export const DraxList = (props: DraxListProps) => { const item = int.dataRef.current[dataIndex]; const basePos = int.basePositionsRef.current.get(itemKey); if (!item || !basePos) return null; - // Hide unmeasured items off-screen until onLayout fires and positions correct. - // Items with known sizes via getItemSize are always considered measured. - const isMeasured = int.itemHeightsRef.current.has(itemKey) || !!getItemSize; + // With useLayoutEffect + measure(), items render at estimated positions and + // correct before paint (single commit). No need to hide at -10000. + const isMeasured = true; const dims = int.itemDimensionsRef.current.get(itemKey); // Vertical: cell fills column width (users center via alignSelf on their card) // Horizontal: cell auto-sizes to content (primary axis measurement) @@ -1091,31 +1246,18 @@ export const DraxList = (props: DraxListProps) => { sortableWorklet={sortableWorkletConfig} style={fillStyle} > - { - // Primary axis from outer wrapper (fills cell) - const primary = horizontal - ? e.nativeEvent.layout.width - : e.nativeEvent.layout.height; - handleItemLayout(itemKey, primary); - cellLastHeightRef.current.set(cellKey, primary); - }} + - { - // Cross-axis from inner wrapper (doesn't stretch — card's natural size) - const cross = horizontal - ? e.nativeEvent.layout.height - : e.nativeEvent.layout.width; - int.itemCrossAxisRef.current.set(itemKey, cross); - }} - > - {renderItem({ item, index: dataIndex })} - - + {renderItem({ item, index: dataIndex })} + ); diff --git a/src/hooks/useDragGesture.ts b/src/hooks/useDragGesture.ts index 035a5e4..43d1c9d 100644 --- a/src/hooks/useDragGesture.ts +++ b/src/hooks/useDragGesture.ts @@ -97,6 +97,14 @@ export const useDragGesture = ( if (!isDragAllowedSV.value) return; isDragAllowedSV.value = false; // Lock — released in onSnapComplete + // Close the drag-start race window: set isDragging on the UI thread + // BEFORE runOnJS(handleDragStart). This prevents handleItemLayout from + // calling recomputeBasePositions + forceRender during the JS callback chain + // (between gesture activation and onMonitorDragStart). + if (sortableWorklet) { + sortableWorklet.isDraggingSV.value = true; + } + // Convert screen-absolute touch to root-view-relative const rootOffset = rootOffsetSV.value; const rootRelX = event.absoluteX - rootOffset.x; diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 0deb05b..376e720 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -89,6 +89,8 @@ export interface SortableListInternal { /** Base positions (React props left/top — Yoga knows position for touch) */ basePositionsRef: React.RefObject>; itemHeightsRef: React.RefObject>; + /** Running average of measured heights — used for unmeasured items in position computation. */ + measuredAvgHeightRef: React.RefObject; /** Cross-axis measurements (width for vertical items, height for horizontal items) */ itemCrossAxisRef: React.RefObject>; totalContentSizeRef: React.RefObject; @@ -103,6 +105,8 @@ export interface SortableListInternal { /** Per-item dimensions (for mixed-size grids) */ itemDimensionsRef: React.RefObject>; getItemSpanRef: React.RefObject<((item: unknown, index: number) => GridItemSpan) | undefined>; + /** Sorted array of item positions for binary search in updateVisibleCells (linear lists only). */ + sortedPositionsRef: React.RefObject<{ key: string; start: number; end: number }[]>; // ── SharedValues (UI thread animation) ── shiftsSV: ReturnType>>; @@ -226,6 +230,7 @@ export const useSortableList = ( const renderItemRef = useRef<((info: any) => ReactNode) | undefined>(undefined); const keyExtractorRef = useRef<(item: unknown, index: number) => string>(keyExtractor as (item: unknown, index: number) => string); const itemDimensionsRef = useRef>(new Map()); + const sortedPositionsRef = useRef<{ key: string; start: number; end: number }[]>([]); const getItemSpanRef = useRef<((item: unknown, index: number) => GridItemSpan) | undefined>( getItemSpan as ((item: unknown, index: number) => GridItemSpan) | undefined ); @@ -472,17 +477,25 @@ export const useSortableList = ( const itemSizeFn = getItemSizeRef.current; const data = dataRef.current; const keyMap = keyToIndexRef.current; + // Build sorted positions array alongside base positions (same loop, no extra pass) + const sorted: { key: string; start: number; end: number }[] = new Array(keys.length); let cursor = 0; - for (const key of keys) { + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; // Priority: measured height > getItemSize callback > running average let h = heights.get(key); if (h === undefined && itemSizeFn) { const idx = keyMap.get(key); if (idx !== undefined && data[idx] !== undefined) { h = horizontal ? itemSizeFn(data[idx]!, idx).width : itemSizeFn(data[idx]!, idx).height; + // Record getItemSize heights so they're available for the worklet + // (syncRefsToWorklet copies itemHeightsRef → itemHeightsSV at drag start). + // Without this, the worklet falls back to estimatedItemSize for all items. + if (h !== undefined) recordItemHeight(key, h); } } if (h === undefined) h = avgH; + sorted[i] = { key, start: cursor, end: cursor + h }; if (horizontal) { positions.set(key, { x: cursor, y: 0 }); dimensions.set(key, { width: h, height: cw || 0 }); @@ -492,6 +505,7 @@ export const useSortableList = ( } cursor += h; } + sortedPositionsRef.current = sorted; return { positions, dimensions, totalHeight: cursor }; } @@ -506,6 +520,7 @@ export const useSortableList = ( measuredCountRef.current++; const n = measuredCountRef.current; measuredAvgHeightRef.current += (height - measuredAvgHeightRef.current) / n; + if (n <= 5 || n % 50 === 0) console.log(`[RECORD] n=${n} key=${key} h=${height} avgH=${measuredAvgHeightRef.current.toFixed(1)}`); } else if (Math.abs(prev - height) > 0.5) { // Height changed — adjust running average (subtract old, add new) const n = measuredCountRef.current; @@ -1022,6 +1037,7 @@ export const useSortableList = ( orderedKeysRef, basePositionsRef, itemHeightsRef, + measuredAvgHeightRef, itemCrossAxisRef, totalContentSizeRef, containerMeasRef, @@ -1033,6 +1049,7 @@ export const useSortableList = ( renderItemRef, itemDimensionsRef, getItemSpanRef, + sortedPositionsRef, shiftsSV, draggedKeySV, scrollOffsetSV, From d3a4800322a746ffa3e793d1fb30351658fac977 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:09:25 +0200 Subject: [PATCH 07/12] feat: migrate to scheduleOnRN/scheduleOnUI + performance optimizations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate all runOnJS → scheduleOnRN (non-curried API, 8 calls) - Migrate all runOnUI → scheduleOnUI (non-curried API, 9 calls) - Eliminate DraxProvider re-renders: hoverVersion state → hoverTriggerSV SharedValue HoverLayer watches via useAnimatedReaction and forces its own local re-render - Eliminate redundant SV.value reads in buildDraggedViewData (3 per call): pass startPosition, grabOffset as params from worklet instead of reading SVs - Cache spatialIndexSV/scrollOffsetsSV reads in buildReceiverViewData callers - Pass grabOffset from gesture worklet to handleReceiverChange - Drop indicator: left/top → translateX/translateY (GPU fast path, no layout passes) - Auto-scroll: cache scrollPosition.value (11 → 1 cross-thread sync per tick) - Pre-flatten hover styles at view registration (avoids 5 StyleSheet.flatten at drag start) - Move FlattenedHoverStyles type from HoverLayer to types.ts (needed by ViewRegistryEntry) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DraxList.tsx | 28 +++--- src/DraxProvider.tsx | 19 ++-- src/DraxScrollView.tsx | 22 ++--- src/HoverLayer.tsx | 52 ++++++----- src/SortableBoardContainer.tsx | 2 +- src/compat/useDraxPanGesture.ts | 6 +- src/hooks/useCallbackDispatch.tsx | 144 ++++++++++++++++-------------- src/hooks/useDragGesture.ts | 19 ++-- src/hooks/useDraxScrollHandler.ts | 10 +-- src/hooks/useSpatialIndex.ts | 21 +++++ src/types.ts | 15 +++- 11 files changed, 196 insertions(+), 142 deletions(-) diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 204f4b2..2dc6f04 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -30,7 +30,7 @@ import { View, useWindowDimensions, } from 'react-native'; -import { runOnJS, runOnUI } from 'react-native-worklets'; +import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; import Reanimated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; import { DraxView } from './DraxView'; @@ -399,10 +399,10 @@ export const DraxList = (props: DraxListProps) => { : event.nativeEvent.contentOffset.y; // Always sync scrollOffset to UI thread (worklet needs accurate offset for slot detection) - runOnUI((_sv: typeof int.scrollOffsetSV, _v: number) => { + scheduleOnUI((_sv: typeof int.scrollOffsetSV, _v: number) => { 'worklet'; _sv.value = _v; - })(int.scrollOffsetSV, offset); + }, int.scrollOffsetSV, offset); // Track scroll velocity for asymmetric buffer distribution. // Uses actual last scroll offset (not lastProcessedOffset which only updates on threshold). @@ -653,7 +653,7 @@ export const DraxList = (props: DraxListProps) => { if (hoverClearDeferredRef.current) { hoverClearDeferredRef.current = false; - runOnUI( + scheduleOnUI( ( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, @@ -670,9 +670,8 @@ export const DraxList = (props: DraxListProps) => { _hoverPositionSV.value = { x: 0, y: 0 }; _hoverDimsSV.value = { x: 0, y: 0 }; _isDragAllowedSV.value = true; // Unlock — all cleanup done, allow new drags - runOnJS(_setHoverContent)(null); - } - )( + scheduleOnRN(_setHoverContent, null); + }, hoverReadySV, dragPhaseSV, draggedIdSV, @@ -737,7 +736,7 @@ export const DraxList = (props: DraxListProps) => { // Pre-populate drop indicator info + position for when first dragOver shows it. // DON'T set visible or forceRender here — that causes a race between - // draggedKeySV (immediate) and hoverReadySV (async via runOnUI), making the + // draggedKeySV (immediate) and hoverReadySV (async via scheduleOnUI), making the // cell flash visible for 1-2 frames before hover is ready. // The first onMonitorDragOver slot change will set visible + trigger re-render. if (renderDropIndicator) { @@ -1327,8 +1326,9 @@ const DropIndicatorOverlay = ({ if (!visible) { return { position: 'absolute' as const, - left: rawLeft, - top: rawTop, + left: 0, + top: 0, + transform: [{ translateX: rawLeft }, { translateY: rawTop }], opacity: 0, pointerEvents: 'none' as const, }; @@ -1345,8 +1345,12 @@ const DropIndicatorOverlay = ({ return { position: 'absolute' as const, - left: springConfig ? withSpring(rawLeft, springConfig) : rawLeft, - top: springConfig ? withSpring(rawTop, springConfig) : rawTop, + left: 0, + top: 0, + transform: [ + { translateX: springConfig ? withSpring(rawLeft, springConfig) : rawLeft }, + { translateY: springConfig ? withSpring(rawTop, springConfig) : rawTop }, + ], opacity: 1, pointerEvents: 'none' as const, }; diff --git a/src/DraxProvider.tsx b/src/DraxProvider.tsx index 12feeca..4ca5bc0 100644 --- a/src/DraxProvider.tsx +++ b/src/DraxProvider.tsx @@ -1,12 +1,11 @@ import type { ReactNode, RefObject } from 'react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef } from 'react'; import type { HostInstance } from 'react-native'; import { StyleSheet, View } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; import { DebugOverlay } from './DebugOverlay'; import { DraxContext } from './DraxContext'; -import type { FlattenedHoverStyles } from './HoverLayer'; import { HoverLayer } from './HoverLayer'; import { useCallbackDispatch } from './hooks/useCallbackDispatch'; import { useSpatialIndex } from './hooks/useSpatialIndex'; @@ -14,6 +13,7 @@ import type { DragPhase, DraxContextValue, DraxProviderProps, + FlattenedHoverStyles, Position, } from './types'; @@ -69,19 +69,20 @@ export const DraxProvider = ({ getViewEntry, } = useSpatialIndex(); - // ── Hover content (ref-based to avoid provider re-renders) ───────── - // Store content in a ref so changing it doesn't re-render the entire tree. - // Only HoverLayer re-renders via the version counter. + // ── Hover content (ref-based, zero provider re-renders) ───────────── + // Content stored in a ref. HoverLayer watches hoverTriggerSV via + // useAnimatedReaction and forces its own re-render — Provider never + // re-renders for hover changes. const hoverContentRef: RefObject = useRef(null); const hoverStylesRef: RefObject = useRef(null); - const [hoverVersion, setHoverVersion] = useState(0); + const hoverTriggerSV = useSharedValue(0); const setHoverContent = useCallback((content: ReactNode | null) => { hoverContentRef.current = content; if (content === null) { hoverStylesRef.current = null; } - setHoverVersion((v) => v + 1); - }, []); + hoverTriggerSV.value += 1; + }, [hoverTriggerSV]); // ── Callback dispatch ────────────────────────────────────────────── const { handleDragStart, handleReceiverChange, handleDragEnd } = @@ -209,7 +210,7 @@ export const DraxProvider = ({ )} 0) { - xNew = Math.max(scrollPosition.value.x - jump.x, 0); + if (currentScroll.x > 0) { + xNew = Math.max(currentScroll.x - jump.x, 0); } } if (autoScrollState.y === AutoScrollDirection.Forward) { const yMax = contentSize.y - containerMeasurements.height; - if (scrollPosition.value.y < yMax) { - yNew = Math.min(scrollPosition.value.y + jump.y, yMax); + if (currentScroll.y < yMax) { + yNew = Math.min(currentScroll.y + jump.y, yMax); } } else if (autoScrollState.y === AutoScrollDirection.Back) { - if (scrollPosition.value.y > 0) { - yNew = Math.max(scrollPosition.value.y - jump.y, 0); + if (currentScroll.y > 0) { + yNew = Math.max(currentScroll.y - jump.y, 0); } } if (xNew !== undefined || yNew !== undefined) { // @ts-expect-error Reanimated's type augmentation hides scrollTo, but it exists at runtime scroll.scrollTo({ - x: xNew ?? scrollPosition.value.x, - y: yNew ?? scrollPosition.value.y, + x: xNew ?? currentScroll.x, + y: yNew ?? currentScroll.y, }); if ( 'flashScrollIndicators' in scroll && diff --git a/src/HoverLayer.tsx b/src/HoverLayer.tsx index ae1df18..5349532 100644 --- a/src/HoverLayer.tsx +++ b/src/HoverLayer.tsx @@ -1,26 +1,19 @@ import type { ReactNode, RefObject } from 'react'; -import { memo, useLayoutEffect } from 'react'; +import { memo, useLayoutEffect, useReducer } from 'react'; import type { ViewStyle } from 'react-native'; import { StyleSheet } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; -import { runOnUI } from 'react-native-worklets'; +import Reanimated, { useAnimatedReaction, useAnimatedStyle } from 'react-native-reanimated'; +import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import type { DragPhase, Position } from './types'; - -/** Flattened hover styles for the currently dragged view */ -export interface FlattenedHoverStyles { - hoverStyle: ViewStyle | null; - hoverDraggingStyle: ViewStyle | null; - hoverDraggingWithReceiverStyle: ViewStyle | null; - hoverDraggingWithoutReceiverStyle: ViewStyle | null; - hoverDragReleasedStyle: ViewStyle | null; -} +import type { DragPhase, FlattenedHoverStyles, Position } from './types'; interface HoverLayerProps { hoverContentRef: RefObject; - /** Changing this value triggers a re-render to pick up new ref content */ - hoverVersion: number; + /** SharedValue trigger — incremented when hover content changes. + * HoverLayer watches this via useAnimatedReaction and forces a local re-render. + * This avoids re-rendering DraxProvider. */ + hoverTriggerSV: SharedValue; hoverPositionSV: SharedValue; dragPhaseSV: SharedValue; receiverIdSV: SharedValue; @@ -38,29 +31,42 @@ interface HoverLayerProps { * This is the ONLY component that reads hoverPositionSV (changes every frame). * All other DraxViews read draggedIdSV/receiverIdSV/dragPhaseSV which change ~5x per drag. * - * Content is passed via ref to avoid re-rendering the entire DraxProvider tree. - * Only this component re-renders when hover content changes (via hoverVersion). + * Content is passed via ref. DraxProvider never re-renders for hover changes. + * Only this component re-renders when hover content changes (via hoverTriggerSV). */ export const HoverLayer = memo( - ({ hoverContentRef, hoverVersion, hoverPositionSV, dragPhaseSV, receiverIdSV, hoverReadySV, hoverDimsSV, hoverStylesRef }: HoverLayerProps) => { + ({ hoverContentRef, hoverTriggerSV, hoverPositionSV, dragPhaseSV, receiverIdSV, hoverReadySV, hoverDimsSV, hoverStylesRef }: HoverLayerProps) => { + // Local re-render trigger — only HoverLayer re-renders, not DraxProvider. + // hoverTriggerSV is incremented on the JS thread by setHoverContent. + // useAnimatedReaction picks it up and forces a local re-render. + const [renderVersion, forceRender] = useReducer((x: number) => x + 1, 0); + useAnimatedReaction( + () => hoverTriggerSV.value, + (curr, prev) => { + if (prev !== null && curr !== prev) { + scheduleOnRN(forceRender); + } + } + ); + // After hover content is committed to the DOM, activate drag phase + signal readiness. // dragPhaseSV is NOT set in the gesture handler — it's set HERE, ensuring: // 1. HoverLayer becomes visible (opacity 1) only AFTER content is rendered // 2. SortableItem hides only AFTER hover is visible (reads hoverReadySV) - // Both writes happen in the same runOnUI call → same UI frame → no blink. + // Both writes happen in the same scheduleOnUI call → same UI frame → no blink. useLayoutEffect(() => { const hasContent = hoverContentRef.current != null; if (hasContent) { - runOnUI((_dragPhaseSV: SharedValue, _hoverReadySV: SharedValue) => { + scheduleOnUI((_dragPhaseSV: SharedValue, _hoverReadySV: SharedValue) => { 'worklet'; _dragPhaseSV.value = 'dragging'; _hoverReadySV.value = true; - })(dragPhaseSV, hoverReadySV); + }, dragPhaseSV, hoverReadySV); } - }, [hoverVersion]); + }, [renderVersion]); // Read hover styles from ref in the component body — they're captured by the - // worklet closure when the component re-renders (on hoverVersion change). + // worklet closure when the component re-renders (on renderVersion change). // This ensures the latest styles are available without SharedValues. const hs = hoverStylesRef.current; const flatHoverStyle = hs?.hoverStyle ?? null; diff --git a/src/SortableBoardContainer.tsx b/src/SortableBoardContainer.tsx index c7ec8ef..d6544b6 100644 --- a/src/SortableBoardContainer.tsx +++ b/src/SortableBoardContainer.tsx @@ -6,7 +6,7 @@ */ import type { ReactNode } from 'react'; import { useCallback, useRef } from 'react'; -// runOnUI/runOnJS no longer needed here — hover cleanup moved to DraxList +// scheduleOnUI/scheduleOnRN no longer needed here — hover cleanup moved to DraxList import type { StyleProp, ViewStyle } from 'react-native'; import { DraxView } from './DraxView'; diff --git a/src/compat/useDraxPanGesture.ts b/src/compat/useDraxPanGesture.ts index 565782b..227794b 100644 --- a/src/compat/useDraxPanGesture.ts +++ b/src/compat/useDraxPanGesture.ts @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useAnimatedReaction } from 'react-native-reanimated'; -import { runOnJS } from 'react-native-worklets'; +import { scheduleOnRN } from 'react-native-worklets'; import { isGestureHandlerV3 } from './detectVersion'; import type { DraxPanEvent, DraxPanGesture, DraxPanGestureConfig } from './types'; @@ -53,7 +53,7 @@ function useDraxPanGestureV2(config: DraxPanGestureConfig): DraxPanGesture { () => config.enabledSV.value, (current, prev) => { if (prev !== null && current !== prev) { - runOnJS(setEnabled)(current); + scheduleOnRN(setEnabled, current); } } ); @@ -62,7 +62,7 @@ function useDraxPanGestureV2(config: DraxPanGestureConfig): DraxPanGesture { () => config.longPressDelaySV.value, (current, prev) => { if (prev !== null && current !== prev) { - runOnJS(setLongPressDelay)(current); + scheduleOnRN(setLongPressDelay, current); } } ); diff --git a/src/hooks/useCallbackDispatch.tsx b/src/hooks/useCallbackDispatch.tsx index b70c31c..aaa53f9 100644 --- a/src/hooks/useCallbackDispatch.tsx +++ b/src/hooks/useCallbackDispatch.tsx @@ -1,12 +1,11 @@ import type { ReactNode, RefObject } from 'react'; import { useRef } from 'react'; -import type { ViewStyle } from 'react-native'; -import { StyleSheet, View } from 'react-native'; +import { 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 { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import type { FlattenedHoverStyles } from '../HoverLayer'; +import type { FlattenedHoverStyles } from '../types'; import { computeAbsolutePositionWorklet, getRelativePosition } from '../math'; import { defaultSnapbackDelay, @@ -80,7 +79,7 @@ interface CallbackDispatchDeps { } /** - * Provides JS-thread callback dispatch functions that are invoked via runOnJS + * Provides JS-thread callback dispatch functions that are invoked via scheduleOnRN * from gesture worklets. These handle ~5 calls per drag (start, receiver changes, end), * NOT per frame. */ @@ -108,19 +107,20 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { const currentMonitorIdsRef = useRef([]); - /** Build dragged view event data from current state */ + /** Build dragged view event data. Position data is passed as params + * (from the worklet) to avoid cross-thread SV.value reads on JS thread. */ const buildDraggedViewData = ( draggedId: string, - absolutePosition: Position + absolutePosition: Position, + startPosition: Position, + grabOffset: 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, + x: absolutePosition.x - startPosition.x, + y: absolutePosition.y - startPosition.y, }; const measurements = entry.measurements; @@ -146,23 +146,24 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { x: grabOffset.x / width, y: grabOffset.y / height, }, - hoverPosition: hoverPositionSV.value, + hoverPosition: absolutePosition, }; }; - /** Build receiver view event data */ + /** Build receiver view event data. Spatial entries + scroll offsets passed + * as params (cached once per handler call) to avoid redundant SV.value reads. */ const buildReceiverViewData = ( receiverId: string, - absolutePosition: Position + absolutePosition: Position, + spatialEntries: SpatialEntry[], + scrollOffsets: 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 absPos = computeAbsolutePositionWorklet(idx, spatialEntries, scrollOffsets); const absMeasurements: DraxViewMeasurements = { ...absPos, width: entry.measurements.width, @@ -185,23 +186,22 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { }; }; - /** Called via runOnJS when drag starts */ + /** Called via scheduleOnRN when drag starts. + * absolutePosition IS the startPosition at drag start (set in onActivate). */ const handleDragStart = ( draggedId: string, absolutePosition: Position, - _grabOffset: Position + grabOffset: Position ) => { const draggedEntry = getViewEntry(draggedId); if (!draggedEntry) return; - const dragged = buildDraggedViewData(draggedId, absolutePosition); + // At drag start, absolutePosition === startPosition (both set from rootRelPos in onActivate) + const dragged = buildDraggedViewData(draggedId, absolutePosition, absolutePosition, grabOffset); if (!dragged) return; - const startPos = startPositionSV.value; - const dragTranslation = { - x: absolutePosition.x - startPos.x, - y: absolutePosition.y - startPos.y, - }; + // At drag start, dragTranslation is always {0,0} + const dragTranslation = { x: 0, y: 0 }; // Fire onDragStart callback draggedEntry.props.onDragStart?.({ @@ -215,15 +215,10 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { // actual content dimensions AFTER rendering. Board reads these for cross-orientation gaps. hoverDimsSV.value = { x: 0, y: 0 }; - // 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), - }; + // Use pre-flattened styles from registration — avoids 5 StyleSheet.flatten calls + // in the drag-start hot path. Set BEFORE setHoverContent so HoverLayer captures + // them when it re-renders. + deps.hoverStylesRef.current = draggedEntry.flattenedHoverStyles ?? null; // Setup hover content if (isDraggable(draggedEntry.props) && !draggedEntry.props.noHover) { @@ -277,7 +272,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { currentMonitorIdsRef.current = []; }; - /** Called via runOnJS on every gesture update for callback dispatch. + /** Called via scheduleOnRN 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 = ( @@ -286,6 +281,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { absolutePosition: Position, draggedId: string, startPosition: Position, + grabOffset: Position, monitorIds?: string[] ) => { @@ -307,9 +303,13 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (!hasOnDragOver && !hasOnReceiveDragOver && !hasOnDrag) return; } - const dragged = buildDraggedViewData(draggedId, absolutePosition); + const dragged = buildDraggedViewData(draggedId, absolutePosition, startPosition, grabOffset); if (!dragged) return; + // Cache spatial data once per handler call — avoids redundant cross-thread SV reads + const cachedSpatialEntries = spatialIndexSV.value; + const cachedScrollOffsets = scrollOffsetsSV.value; + const draggedEntry = getViewEntry(draggedId); const draggedPayload = draggedEntry?.props.dragPayload ?? draggedEntry?.props.payload; @@ -363,7 +363,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { // 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(( + scheduleOnUI(( _receiverIdSV: typeof deps.receiverIdSV, _rejectedReceiverIdSV: typeof deps.rejectedReceiverIdSV, _rejectedId: string, @@ -371,7 +371,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { 'worklet'; _receiverIdSV.value = ''; _rejectedReceiverIdSV.value = _rejectedId; - })(deps.receiverIdSV, deps.rejectedReceiverIdSV, newReceiverId); + }, deps.receiverIdSV, deps.rejectedReceiverIdSV, newReceiverId); } } @@ -380,7 +380,9 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { const oldReceiverEntry = getViewEntry(oldReceiverId); const oldReceiverData = buildReceiverViewData( oldReceiverId, - absolutePosition + absolutePosition, + cachedSpatialEntries, + cachedScrollOffsets ); if (oldReceiverEntry && oldReceiverData) { // Dragged view: onDragExit @@ -403,7 +405,9 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { const newReceiverEntry = getViewEntry(acceptedReceiverId); const newReceiverData = buildReceiverViewData( acceptedReceiverId, - absolutePosition + absolutePosition, + cachedSpatialEntries, + cachedScrollOffsets ); if (newReceiverEntry && newReceiverData) { // Dragged view: onDragEnter @@ -424,7 +428,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (acceptedReceiverId && oldReceiverId === acceptedReceiverId) { // Dragging over the same receiver — fire onDragOver + onReceiveDragOver const receiverEntry = getViewEntry(acceptedReceiverId); - const receiverData = buildReceiverViewData(acceptedReceiverId, absolutePosition); + const receiverData = buildReceiverViewData(acceptedReceiverId, absolutePosition, cachedSpatialEntries, cachedScrollOffsets); if (receiverEntry && receiverData) { draggedEntry?.props.onDragOver?.({ ...baseEventData, @@ -445,7 +449,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { // Build receiver data for monitor event payload (use accepted receiver, not raw hit-test) const receiverData = acceptedReceiverId - ? buildReceiverViewData(acceptedReceiverId, absolutePosition) + ? buildReceiverViewData(acceptedReceiverId, absolutePosition, cachedSpatialEntries, cachedScrollOffsets) : undefined; // Fire events on current monitors (start/enter before over) @@ -504,10 +508,10 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { currentMonitorIdsRef.current = newMonitorIds; // Fire provider-level onDrag (use acceptedReceiverId, not raw newReceiverId) - onProviderDrag?.({ draggedId: draggedIdSV.value, receiverId: acceptedReceiverId || undefined, position: absolutePosition }); + onProviderDrag?.({ draggedId, receiverId: acceptedReceiverId || undefined, position: absolutePosition }); }; - /** Called via runOnJS when drag ends or is cancelled */ + /** Called via scheduleOnRN when drag ends or is cancelled */ const handleDragEnd = ( draggedId: string, receiverId: string, @@ -521,7 +525,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (!draggedEntry) { // Reset drag state atomically on UI thread to avoid one-frame flash - runOnUI(( + scheduleOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, @@ -532,15 +536,21 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; - })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); + }, hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); return; } + // Cache all SV reads once at the top — avoids redundant cross-thread syncs const absolutePosition = { ...hoverPositionSV.value }; - const dragged = buildDraggedViewData(draggedId, absolutePosition); + const startPos = startPositionSV.value; + const grabOffset = grabOffsetSV.value; + const cachedSpatialEntries = spatialIndexSV.value; + const cachedScrollOffsets = scrollOffsetsSV.value; + + const dragged = buildDraggedViewData(draggedId, absolutePosition, startPos, grabOffset); if (!dragged) { - runOnUI(( + scheduleOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, @@ -551,12 +561,11 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; - })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); + }, hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); return; } - const startPos = startPositionSV.value; const dragTranslation = { x: absolutePosition.x - startPos.x, y: absolutePosition.y - startPos.y, @@ -574,15 +583,17 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { const receiverEntry = getViewEntry(receiverId); const receiverData = buildReceiverViewData( receiverId, - absolutePosition + absolutePosition, + cachedSpatialEntries, + cachedScrollOffsets ); if (receiverData && receiverEntry) { // Compute receiver's absolute position and center the dragged item within it const receiverAbsPos = computeAbsolutePositionWorklet( receiverEntry.spatialIndex, - spatialIndexSV.value, - scrollOffsetsSV.value + cachedSpatialEntries, + cachedScrollOffsets ); const draggedDims = draggedEntry.measurements; const receiverDims = receiverEntry.measurements; @@ -642,7 +653,9 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (receiverId && !cancelled) { const receiverData = buildReceiverViewData( receiverId, - absolutePosition + absolutePosition, + cachedSpatialEntries, + cachedScrollOffsets ); if (receiverData) { const monitorDropResponse = @@ -671,8 +684,8 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (snapTarget === DraxSnapbackTargetPreset.Default) { const absPos = computeAbsolutePositionWorklet( draggedEntry.spatialIndex, - spatialIndexSV.value, - scrollOffsetsSV.value + cachedSpatialEntries, + cachedScrollOffsets ); snapTarget = absPos; } @@ -692,7 +705,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { ); // Fire provider-level onDragEnd (use last known hover position) - onProviderDragEnd?.({ draggedId, receiverId: receiverId || undefined, position: hoverPositionSV.value, cancelled }); + onProviderDragEnd?.({ draggedId, receiverId: receiverId || undefined, position: absolutePosition, cancelled }); }; return { @@ -752,7 +765,7 @@ function performSnapback( * 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 + * For REORDER: finalizeDrag sets permanent shifts + clears hover via scheduleOnUI * in a single atomic block. No FlatList data change, so no blink. * * For CANCEL: finalizeDrag → cancelDrag → reverts to committed shifts. @@ -776,7 +789,7 @@ function performSnapback( // Step 3: Clear hover if NOT deferred by a sortable reorder. if (!hoverClearDeferredRef.current) { - runOnUI(( + scheduleOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, @@ -789,7 +802,7 @@ function performSnapback( _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; _isDragAllowedSV.value = true; // Unlock — allow new drags - })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV, isDragAllowedSV); + }, hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV, isDragAllowedSV); setHoverContent(null); } else { // Do NOT call setHoverContent(null) here — the hover must remain visible @@ -803,7 +816,7 @@ function performSnapback( console.error('[snap] onSnapComplete crashed — emergency cleanup', e); isDragAllowedSV.value = true; hoverClearDeferredRef.current = false; - runOnUI(( + scheduleOnUI(( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, _draggedIdSV: typeof draggedIdSV, @@ -814,7 +827,7 @@ function performSnapback( _dragPhaseSV.value = 'idle'; _draggedIdSV.value = ''; _hoverPositionSV.value = { x: 0, y: 0 }; - })(hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); + }, hoverReadySV, dragPhaseSV, draggedIdSV, hoverPositionSV); setHoverContent(null); } }; @@ -858,7 +871,7 @@ function performSnapback( withTiming(toValue, { duration: snapDuration }, (finished) => { 'worklet'; if (finished) { - runOnJS(onSnapComplete)(); + scheduleOnRN(onSnapComplete); } }) ); @@ -866,8 +879,3 @@ function performSnapback( } // ─── Helpers ─────────────────────────────────────────────────────────────── - -function flattenOrNull(s: unknown): ViewStyle | null { - if (!s) return null; - return StyleSheet.flatten(s as ViewStyle) ?? null; -} diff --git a/src/hooks/useDragGesture.ts b/src/hooks/useDragGesture.ts index 43d1c9d..b8e78df 100644 --- a/src/hooks/useDragGesture.ts +++ b/src/hooks/useDragGesture.ts @@ -1,6 +1,6 @@ import { Platform } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import { runOnJS } from 'react-native-worklets'; +import { scheduleOnRN } from 'react-native-worklets'; import { useDraxPanGesture } from '../compat'; import { computeAbsolutePositionWorklet, hitTestWorklet } from '../math'; @@ -9,7 +9,7 @@ import { useDraxContext } from './useDraxContext'; /** * Creates a Pan gesture for a draggable DraxView. - * Hit-testing runs entirely on the UI thread — zero runOnJS per frame + * Hit-testing runs entirely on the UI thread — zero scheduleOnRN per frame * unless the receiver changes. * * On RNGH v3, `enabledSV` and `longPressDelaySV` are SharedValues that @@ -98,7 +98,7 @@ export const useDragGesture = ( isDragAllowedSV.value = false; // Lock — released in onSnapComplete // Close the drag-start race window: set isDragging on the UI thread - // BEFORE runOnJS(handleDragStart). This prevents handleItemLayout from + // BEFORE scheduleOnRN(handleDragStart). This prevents handleItemLayout from // calling recomputeBasePositions + forceRender during the JS callback chain // (between gesture activation and onMonitorDragStart). if (sortableWorklet) { @@ -159,7 +159,7 @@ export const useDragGesture = ( rejectedReceiverIdSV.value = ''; // Bounce to JS for callback dispatch + hover content setup - runOnJS(handleDragStart)( + scheduleOnRN(handleDragStart, id, { x: rootRelX, y: rootRelY }, grabOffset @@ -270,13 +270,14 @@ export const useDragGesture = ( } // Pass static SVs as args to avoid cross-thread reads on JS thread. - // draggedIdSV and startPositionSV are set once in onActivate and never change during drag. - runOnJS(handleReceiverChange)( + // draggedIdSV, startPositionSV, grabOffsetSV are set once in onActivate and never change during drag. + scheduleOnRN(handleReceiverChange, oldReceiver, candidateReceiverId, hitTestPos, draggedIdSV.value, startPositionSV.value, + grabOffsetSV.value, result.monitorIds ); }, @@ -309,14 +310,14 @@ export const useDragGesture = ( receiverIdSV.value = ''; // Bounce to JS for end callbacks + snap animation - runOnJS(handleDragEnd)(currentDraggedId, currentReceiverId, false, finalHitResult.monitorIds); + scheduleOnRN(handleDragEnd, currentDraggedId, currentReceiverId, false, finalHitResult.monitorIds); }, onFinalize: (_event, didSucceed) => { 'worklet'; // If gesture was cancelled (not ended normally). // Check draggedIdSV (set in onActivate) instead of dragPhaseSV - // because phase is now set later in handleDragStart via runOnUI. + // because phase is now set later in handleDragStart via scheduleOnUI. if (!didSucceed && draggedIdSV.value !== '') { const currentDraggedId = draggedIdSV.value; const currentReceiverId = receiverIdSV.value; @@ -336,7 +337,7 @@ export const useDragGesture = ( dragPhaseSV.value = 'releasing'; receiverIdSV.value = ''; - runOnJS(handleDragEnd)(currentDraggedId, currentReceiverId, true, finalHitResult.monitorIds); + scheduleOnRN(handleDragEnd, currentDraggedId, currentReceiverId, true, finalHitResult.monitorIds); } }, }); diff --git a/src/hooks/useDraxScrollHandler.ts b/src/hooks/useDraxScrollHandler.ts index 3d04ba9..6a39e61 100644 --- a/src/hooks/useDraxScrollHandler.ts +++ b/src/hooks/useDraxScrollHandler.ts @@ -2,10 +2,8 @@ import type { Ref, RefObject } from 'react'; import { useCallback, useEffect, useRef } from 'react'; import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import { FlatList, ScrollView } from 'react-native'; -import { - runOnUI, - useSharedValue, -} from 'react-native-reanimated'; +import { useSharedValue } from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; import { defaultAutoScrollIntervalLength } from '../params'; import type { DraxViewMeasurements, Position } from '../types'; @@ -53,13 +51,13 @@ export const useDraxScrollHandler = ({ const onScroll = (event: NativeSyntheticEvent) => { onScrollProp?.(event); - runOnUI((_scrollPos: typeof scrollPosition, _event: NativeScrollEvent) => { + scheduleOnUI((_scrollPos: typeof scrollPosition, _event: NativeScrollEvent) => { 'worklet'; _scrollPos.value = { x: _event.contentOffset.x, y: _event.contentOffset.y, }; - })(scrollPosition, event.nativeEvent); + }, scrollPosition, event.nativeEvent); }; const setScrollRefs = (instance: T | null) => { diff --git a/src/hooks/useSpatialIndex.ts b/src/hooks/useSpatialIndex.ts index 9c80288..f3b16c1 100644 --- a/src/hooks/useSpatialIndex.ts +++ b/src/hooks/useSpatialIndex.ts @@ -1,4 +1,6 @@ import { useRef } from 'react'; +import type { ViewStyle } from 'react-native'; +import { StyleSheet } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; import { useSharedValue } from 'react-native-reanimated'; @@ -6,12 +8,28 @@ import type { CollisionAlgorithm, DraxViewMeasurements, DraxViewProps, + FlattenedHoverStyles, Position, RegisterViewPayload, SpatialEntry, ViewRegistryEntry, } from '../types'; +function flattenOrNull(s: unknown): ViewStyle | null { + if (!s) return null; + return StyleSheet.flatten(s as ViewStyle) ?? null; +} + +function buildFlattenedHoverStyles(props: DraxViewProps): FlattenedHoverStyles { + return { + hoverStyle: flattenOrNull(props.hoverStyle), + hoverDraggingStyle: flattenOrNull(props.hoverDraggingStyle), + hoverDraggingWithReceiverStyle: flattenOrNull(props.hoverDraggingWithReceiverStyle), + hoverDraggingWithoutReceiverStyle: flattenOrNull(props.hoverDraggingWithoutReceiverStyle), + hoverDragReleasedStyle: flattenOrNull(props.hoverDragReleasedStyle), + }; +} + /** * Module-level helper to update spatial entry capabilities. * Defined outside the hook so the worklet closure cannot capture @@ -133,6 +151,7 @@ export const useSpatialIndex = () => { existing.parentId = parentId; existing.scrollPosition = scrollPosition; existing.props = props; + existing.flattenedHoverStyles = buildFlattenedHoverStyles(props); const idx = existing.spatialIndex; updateSpatialEntryCapabilities( @@ -184,6 +203,7 @@ export const useSpatialIndex = () => { scrollPosition, measurements: undefined, props, + flattenedHoverStyles: buildFlattenedHoverStyles(props), }); // Fix up children that registered before this parent. @@ -263,6 +283,7 @@ export const useSpatialIndex = () => { if (!entry) return; entry.props = props; + entry.flattenedHoverStyles = buildFlattenedHoverStyles(props); // Update capabilities in spatial index const draggable = isDraggable(props); diff --git a/src/types.ts b/src/types.ts index 3a99991..e8c12ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -431,6 +431,15 @@ export interface DraxViewProps // ─── View Registry (JS Thread) ───────────────────────────────────────────── +/** Flattened hover styles for the currently dragged view */ +export interface FlattenedHoverStyles { + hoverStyle: ViewStyle | null; + hoverDraggingStyle: ViewStyle | null; + hoverDraggingWithReceiverStyle: ViewStyle | null; + hoverDraggingWithoutReceiverStyle: ViewStyle | null; + hoverDragReleasedStyle: ViewStyle | null; +} + /** Entry in the JS-thread view registry Map */ export interface ViewRegistryEntry { id: string; @@ -443,6 +452,9 @@ export interface ViewRegistryEntry { measurements?: DraxViewMeasurements; /** All props from DraxView (callbacks, styles, payload, etc.) */ props: DraxViewProps; + /** Pre-flattened hover styles — computed at registration/prop-update time + * to avoid 5 StyleSheet.flatten calls in the drag-start hot path. */ + flattenedHoverStyles?: FlattenedHoverStyles; } // ─── Context Value ───────────────────────────────────────────────────────── @@ -494,7 +506,7 @@ export interface DraxContextValue { updateViewProps: (id: string, props: DraxViewProps) => void; getViewEntry: (id: string) => ViewRegistryEntry | undefined; - // ── Callback dispatch (JS thread, called via runOnJS from gesture) ─ + // ── Callback dispatch (JS thread, called via scheduleOnRN from gesture) ─ handleDragStart: ( draggedId: string, absolutePosition: Position, @@ -506,6 +518,7 @@ export interface DraxContextValue { absolutePosition: Position, draggedId: string, startPosition: Position, + grabOffset: Position, monitorIds?: string[] ) => void; handleDragEnd: ( From 4a03c9db9bc1b208e1b8217995f0b7c9765caa78 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:45:16 +0200 Subject: [PATCH 08/12] fix: eliminate 1-frame blink on reorder commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Items flashed back to original positions for one frame after reorder because shifts were cleared (SV write → immediate UI thread) before cells received new baseX/baseY props (deferred to forceRender). Fix: recompute base positions eagerly during the render phase (in the data sync block) instead of in useLayoutEffect. Cells now get new base positions in the SAME React commit as the shift clear — no gap. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DraxList.tsx | 14 +++++--------- src/hooks/useSortableList.ts | 30 ++++++++++++++++++------------ 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 2dc6f04..1fc7a44 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -614,11 +614,11 @@ export const DraxList = (props: DraxListProps) => { // Skip animation so cells snap to final positions (no spring-back artifact). if (int.pendingShiftClearRef.current) { int.pendingShiftClearRef.current = false; - int.skipShiftAnimationSV.value = true; - int.recomputeBasePositionsAndClearShifts(); + // Base positions were recomputed eagerly during render (in useSortableList data sync). + // Cells in THIS commit already have new baseX/baseY. Now clear shifts — both + // updates land in the same Fabric commit, so no 1-frame blink at old positions. + int.clearShifts(); // Hide indicator + clear stale info — transfer complete. Clean state for next drag. - // Without this, the board's info (from cross-container drag) persists and flashes - // when the next drag in this column sets visible=true before re-render. dropIndicatorVisibleSV.value = false; dropIndicatorInfoRef.current = undefined; } @@ -1101,14 +1101,10 @@ export const DraxList = (props: DraxListProps) => { } // Normal intra-column reorder - if (!int.isDraggingRef.current) { - console.log(`[SNAP END] isDragging=false, skipping commit`); - return; - } + if (!int.isDraggingRef.current) return; const fromIdx = int.dragStartIndexRef.current; const toIdx = int.currentSlotRef.current; const fromItem = int.dataRef.current[fromIdx]; - console.log(`[SNAP END] commit from=${fromIdx} to=${toIdx} scroll=${int.scrollOffsetSV.value.toFixed(0)} totalContent=${int.totalContentSizeRef.current.toFixed(0)}`); // Only sync from worklet when it handled slot detection (single-column lists). // For grids (numColumns > 1), JS handled slot detection — orderedKeysRef is already correct. if (numColumns === 1 && sortableWorkletConfig) { diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 376e720..d0e2ed1 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -170,6 +170,7 @@ export interface SortableListInternal { recordItemHeight: (key: string, height: number) => void; computeGridPositions: (keys: string[]) => { positions: Map; dimensions: Map; totalHeight: number }; recomputeBasePositions: () => void; + clearShifts: () => void; recomputeBasePositionsAndClearShifts: () => void; freezeSlotBoundaries: () => void; getSlotFromPosition: (contentX: number, contentY: number) => number; @@ -540,20 +541,23 @@ export const useSortableList = ( totalContentSizeRef.current = result.totalHeight; } - /** Recompute base positions AND clear shifts (used after layout changes, not during drag). */ - function recomputeBasePositionsAndClearShifts() { - // Set skipShift HERE (not just in caller) to ensure it's in the same Reanimated - // SV write batch as the cell clears. Writing from the caller and reading .value - // back can return the old value (JSI getter reads UI-thread state, not pending JS write). + /** Clear all shifts (snap to 0). Caller must ensure base positions are already current. */ + function clearShifts() { skipShiftAnimationSV.value = true; - recomputeBasePositions(); shiftsSV.value = {}; - // Clear per-cell SVs for (const sv of cellShiftRegistryRef.current.values()) { sv.value = { x: 0, y: 0 }; } } + /** Recompute base positions AND clear shifts. + * Used by paths where base positions weren't recomputed during render + * (container layout change, cross-container transfer). */ + function recomputeBasePositionsAndClearShifts() { + recomputeBasePositions(); + clearShifts(); + } + // ── Sync external data EAGERLY during render (not in useLayoutEffect) ── // This ensures basePositionsRef is updated BEFORE cells render with new top values. // Combined with shiftsValidSV gating, both top and shifts update in same Fabric commit. @@ -578,10 +582,12 @@ export const useSortableList = ( if (!isDraggingRef.current) { orderedKeysRef.current = keys; - // ALWAYS reset base positions + clear shifts after data change. - // "Permanent shifts" kept Yoga touch at OLD base positions → wrong item grabbed. - // useLayoutEffect handles: skipShiftAnimation → recomputeBasePositionsAndClearShifts → forceRender. - // No visual change: newBase + 0 = oldBase + oldShift. But Yoga touch now correct. + // Recompute base positions EAGERLY during render so cells in THIS commit + // get new baseX/baseY props. Without this, shifts clear to 0 in useLayoutEffect + // but cells still have OLD base positions → 1-frame blink at original positions. + recomputeBasePositions(); + + // Mark for shift clear in useLayoutEffect (SV writes not allowed during render). pendingShiftClearRef.current = true; } } @@ -1005,7 +1011,6 @@ export const useSortableList = ( const fromItem = currentData[fromIndex]; const toItem = currentData[toIndex]; - // Clear drag state isDraggingRef.current = false; draggedKeySV.value = ''; @@ -1081,6 +1086,7 @@ export const useSortableList = ( recordItemHeight, computeGridPositions, recomputeBasePositions, + clearShifts, recomputeBasePositionsAndClearShifts, freezeSlotBoundaries, getSlotFromPosition, From 47f8f497d5d96f2166851783f7dce9e63d82221a Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 12:38:00 +0200 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20internal=20data=20ownership=20?= =?UTF-8?q?=E2=80=94=20onReorder=20as=20notification,=20echo=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitReorder now commits data internally (dataRef + keyToIndexRef), making the library self-sufficient. When the parent echoes back the same array reference from onReorder, the data sync detects it via awaitingEchoRef and skips the expensive forceRender + updateVisibleCells cycle — eliminating one full React render on every reorder commit. Also removes all console.log statements from production code paths (DraxList, useSortableList) to reduce JS thread overhead during drag. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- src/DraxList.tsx | 37 ++++++++--------------------- src/hooks/useSortableList.ts | 46 +++++++++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 90e73b1..6e367b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ Drag-and-drop framework for React Native (iOS, Android, Web). v1.0.0 — major r - Map-based measurements (keyed by item key) instead of array-indexed - Supports insert + swap reorder strategies - Drop indicator support: `SortableContainer` tracks target position via SharedValues, renders indicator at insertion point -- **Data ownership**: Library commits reorders internally via `commitReorder`. `onReorder` is a notification — parent stores data but library already committed it. When parent echoes data back, useLayoutEffect detects the match and skips (no double-render). +- **Data ownership**: Library commits reorders internally via `commitReorder` — updates `dataRef`, `keyToIndexRef`, and `orderedKeysRef`. `onReorder` is a notification only — parent stores data for persistence, but the library already committed the visual state. When parent echoes data back (same array reference from `event.data`), the data sync detects the match via `awaitingEchoRef` and skips `forceRender` + `updateVisibleCells` (no double-render). Bases are still recomputed + shifts cleared for touch correctness, but the expensive second React render is eliminated. ### Animation Customization diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 1fc7a44..1cad4cc 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -187,20 +187,11 @@ const MeasuredContent = memo(({ // https://reactnative.dev/architecture/landing-page#synchronous-layout-and-effects // key={itemKey} forces remount on recycle → useLayoutEffect fires → guaranteed measurement. useLayoutEffect(() => { - console.log(`[EFFECT] useLayoutEffect fired key=${itemKey} skip=${skipMeasurement} hasRef=${!!ref.current}`); if (skipMeasurement) return; - if (!ref.current) { - console.log(`[MEASURE] ref null for key=${itemKey}`); - return; - } - const hasMeasure = typeof ref.current.measure === 'function'; - if (!hasMeasure) { - console.log(`[MEASURE] NO measure() on ref for key=${itemKey}, type=${typeof ref.current}`); - return; - } + if (!ref.current) return; + if (typeof ref.current.measure !== 'function') return; ref.current.measure((_x: number, _y: number, width: number, height: number) => { const primary = horizontal ? width : height; - console.log(`[MEASURE] key=${itemKey} w=${width} h=${height} primary=${primary}`); if (primary > 0) { onMeasure(itemKey, primary); onCellHeight.current.set(cellKey, primary); @@ -437,7 +428,6 @@ export const DraxList = (props: DraxListProps) => { const isDragging = int.isDraggingRef.current || int.isDraggingSV.value; const shiftsEmpty = Object.keys(int.shiftsSV.value).length === 0; if (isDragging) { - console.log(`[MEASURE] DURING DRAG key=${itemKey} h=${height.toFixed(1)}`); int.itemHeightsSV.value = { ...int.itemHeightsSV.value, [itemKey]: height, @@ -445,8 +435,6 @@ export const DraxList = (props: DraxListProps) => { } else if (shiftsEmpty) { int.recomputeBasePositions(); forceRender(); - } else { - console.log(`[MEASURE] SKIPPED (shifts active) key=${itemKey} h=${height.toFixed(1)}`); } }, [int] @@ -582,12 +570,7 @@ export const DraxList = (props: DraxListProps) => { int.recomputeBasePositions(); } - if (proactiveMeasured) { - console.log(`[VISIBLE] proactive recompute, totalContent=${int.totalContentSizeRef.current.toFixed(0)}`); - } - if (changed) { - console.log(`[VISIBLE] rebind: ${currentMap.size} cells, scroll=${scrollOffset.toFixed(0)}, isDrag=${isDragging}, visibleKeys=${visibleKeys.size}`); const newBindings: CellBinding[] = []; for (const [itemKey, cellKey] of currentMap.entries()) { newBindings.push({ cellKey, itemKey }); @@ -622,6 +605,13 @@ export const DraxList = (props: DraxListProps) => { dropIndicatorVisibleSV.value = false; dropIndicatorInfoRef.current = undefined; } + + // Echo: parent echoed back our committed reorder. Bases + shifts already handled above. + // Skip updateVisibleCells (bindings unchanged) + forceRender (main perf win). + const isEcho = int.echoSkipRef.current; + int.echoSkipRef.current = false; + if (isEcho) return; + // Reset scroll delta tracking (positions/data just changed) lastProcessedOffsetRef.current = int.scrollOffsetSV.value; updateVisibleCells(int.scrollOffsetSV.value); @@ -703,7 +693,6 @@ export const DraxList = (props: DraxListProps) => { if (item === undefined) return; const itemKey = keyExtractor(item, originalIndex); - console.log(`[DRAG START] key=${itemKey} idx=${originalIndex} scroll=${int.scrollOffsetSV.value.toFixed(0)} totalContent=${int.totalContentSizeRef.current.toFixed(0)} cells=${bindingMapRef.current.size} shifts=${Object.keys(int.shiftsSV.value).length}`); int.skipShiftAnimationSV.value = false; // Re-enable shift animations for this drag int.isDraggingRef.current = true; int.draggedKeySV.value = itemKey; @@ -995,19 +984,14 @@ export const DraxList = (props: DraxListProps) => { // During cross-container transfer, board handles snap target if (boardContext?.transferRef?.current) { - console.log(`[DRAG END] skipped: cross-container transfer`); return; // void — let board's snap target stand } - if (!int.isDraggingRef.current) { - console.log(`[DRAG END] skipped: isDragging=false`); - return; - } + if (!int.isDraggingRef.current) return; const dragKey = int.draggedKeySV.value; const basePos = int.basePositionsRef.current.get(dragKey); const containerMeas = int.containerMeasRef.current; - console.log(`[DRAG END] key=${dragKey} hasBasePos=${!!basePos} hasContainerMeas=${!!containerMeas} scroll=${int.scrollOffsetSV.value.toFixed(0)}`); if (basePos && containerMeas) { let visualX: number; @@ -1047,7 +1031,6 @@ export const DraxList = (props: DraxListProps) => { const scrollOff = int.scrollOffsetSV.value; const snapX = containerMeas.x + scOffset.x + visualX - (horizontal ? scrollOff : 0); const snapY = containerMeas.y + scOffset.y + visualY - (horizontal ? 0 : scrollOff); - console.log(`[DRAG END] SNAP target: visualY=${visualY.toFixed(0)} scroll=${scrollOff.toFixed(0)} containerY=${containerMeas.y.toFixed(0)} avgH=${(int.measuredAvgHeightRef?.current ?? 0).toFixed(1)} snapY=${snapY.toFixed(0)} baseY=${basePos.y.toFixed(0)}`); return { x: snapX, y: snapY }; } diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index d0e2ed1..4d12f9b 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -164,6 +164,8 @@ export interface SortableListInternal { frozenBoundariesRef: React.RefObject; /** Set during render when cross-container adds new keys. DraxList clears shifts in useLayoutEffect. */ pendingShiftClearRef: React.RefObject; + /** Set during render when parent echoes back committed reorder. DraxList skips forceRender. */ + echoSkipRef: React.RefObject; // ── Layout engine ── /** Record a measured height and update the running average for unmeasured items. */ @@ -381,6 +383,10 @@ export const useSortableList = ( const frozenBoundariesRef = useRef([]); /** Set during render when cross-container adds new keys. Cleared in useLayoutEffect. */ const pendingShiftClearRef = useRef(false); + /** Set during render when parent echoes back committed reorder. DraxList skips forceRender. */ + const echoSkipRef = useRef(false); + /** Holds the committed data array from commitReorder for reference-equality echo detection. */ + const awaitingEchoRef = useRef(null); // ── Layout helpers ── @@ -521,7 +527,6 @@ export const useSortableList = ( measuredCountRef.current++; const n = measuredCountRef.current; measuredAvgHeightRef.current += (height - measuredAvgHeightRef.current) / n; - if (n <= 5 || n % 50 === 0) console.log(`[RECORD] n=${n} key=${key} h=${height} avgH=${measuredAvgHeightRef.current.toFixed(1)}`); } else if (Math.abs(prev - height) > 0.5) { // Height changed — adjust running average (subtract old, add new) const n = measuredCountRef.current; @@ -564,7 +569,6 @@ export const useSortableList = ( const prevExternalDataRef = useRef(externalData); if (externalData !== prevExternalDataRef.current) { prevExternalDataRef.current = externalData; - dataRef.current = externalData; // Single loop: build both key→index map and ordered keys array const map = new Map(); @@ -577,9 +581,26 @@ export const useSortableList = ( map.set(k, i); } } + + // ── Echo detection ── + // When commitReorder fires, it saves the reordered array in awaitingEchoRef. + // If the parent echoes back that exact array (reference equality), skip the + // expensive forceRender — the library already committed internally. + const isEcho = awaitingEchoRef.current !== null && externalData === awaitingEchoRef.current; + awaitingEchoRef.current = null; + + // Always sync data source + key map + dataRef.current = externalData; keyToIndexRef.current = map; - if (!isDraggingRef.current) { + if (isEcho) { + // Library already committed order. orderedKeysRef is correct. + // Still recompute bases + clear shifts for Yoga touch hit-testing. + // echoSkipRef tells DraxList to skip forceRender (main perf win). + recomputeBasePositions(); + pendingShiftClearRef.current = true; + echoSkipRef.current = true; + } else if (!isDraggingRef.current) { orderedKeysRef.current = keys; // Recompute base positions EAGERLY during render so cells in THIS commit @@ -592,11 +613,6 @@ export const useSortableList = ( } } - // No flush needed. Base positions (top) stay frozen. Shifts are permanent. - // Visual is always correct: top + shift = correct position. - // Touch is correct because keyToIndexRef is synced eagerly. - // Next drag starts from committed shifts (via orderedKeysRef + frozen boundaries). - // ── (recomputeBasePositions defined above as function, before sync block) ── // ── Slot detection (frozen boundaries) ── @@ -1011,11 +1027,22 @@ export const useSortableList = ( const fromItem = currentData[fromIndex]; const toItem = currentData[toIndex]; + // ── Internal commit: library owns the data ── + // Update dataRef + keyToIndexRef so the library is self-sufficient. + // When the parent echoes this array back, data sync detects the reference match and skips. + dataRef.current = reorderedData; + const newKeyToIndex = new Map(); + for (let i = 0; i < keys.length; i++) { + newKeyToIndex.set(keys[i]!, i); + } + keyToIndexRef.current = newKeyToIndex; + awaitingEchoRef.current = reorderedData; + // Clear drag state isDraggingRef.current = false; draggedKeySV.value = ''; - // Fire notification — parent stores data, visual already correct + // Notification — parent stores data for persistence, library already committed if (fromItem !== undefined && toItem !== undefined) { onReorder({ data: reorderedData, @@ -1083,6 +1110,7 @@ export const useSortableList = ( currentSlotRef, frozenBoundariesRef, pendingShiftClearRef, + echoSkipRef, recordItemHeight, computeGridPositions, recomputeBasePositions, From 784faa49dfc12185f2a5ddefc6ca7520ee473b95 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:17:58 +0200 Subject: [PATCH 10/12] perf: eliminate JS frame drops at drag start/end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Atomic scheduleOnUI in syncRefsToWorklet: all SV writes land in one UI-thread batch. isDraggingSV set LAST to gate worklet — prevents stale-data slot detection that caused items to jump after reorder. - Pre-sync large SharedValues during render/measurement: basePositionsSV, itemHeightsSV, orderedKeysSV written in useLayoutEffect and callbacks (not drag start). syncRefsToWorklet drops from O(N) to O(K) visible cells. - Permanent shifts on echo: when parent echoes back reordered data, skip ALL work (no recomputeBasePositions, no clearShifts, no SV writes). Eliminates Fabric/Reanimated race that caused 1-frame position jump. - isDraggingSV race fix: removed early set in onActivate, added clear in onDeactivate/onFinalize. Prevents stale worklet runs between drags. - Snap target cache: O(1) lookup at drag end instead of O(N) key walk. Boundaries use visual positions (base+shift) for permanent shifts. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DraxList.tsx | 94 +++++++++++-------------- src/hooks/useDragGesture.ts | 28 +++++--- src/hooks/useSortableList.ts | 130 +++++++++++++++++++++++++++-------- 3 files changed, 164 insertions(+), 88 deletions(-) diff --git a/src/DraxList.tsx b/src/DraxList.tsx index 1cad4cc..f69d4fa 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -337,6 +337,7 @@ export const DraxList = (props: DraxListProps) => { draggedKeySV: int.draggedKeySV, dropIndicatorPositionSV: int.dropIndicatorPositionSV, scrollOffsetSV: int.scrollOffsetSV, + snapTargetSV: int.snapTargetSV, numColumns, horizontal, estimatedItemSize, @@ -372,6 +373,7 @@ export const DraxList = (props: DraxListProps) => { // ScrollView's offset within the monitoring DraxView (accounts for padding) int.scrollContainerOffsetRef.current = { x, y }; int.recomputeBasePositionsAndClearShifts(); + int.syncPositionsToWorklet(); // Reset scroll delta tracking (positions just changed) lastProcessedOffsetRef.current = int.scrollOffsetSV.value; // Rebind cells with new positions (grid positions change after container measured) @@ -434,6 +436,7 @@ export const DraxList = (props: DraxListProps) => { }; } else if (shiftsEmpty) { int.recomputeBasePositions(); + int.syncPositionsToWorklet(); forceRender(); } }, @@ -593,8 +596,16 @@ export const DraxList = (props: DraxListProps) => { // ── Initial binding + data sync ── useLayoutEffect(() => { - // Cross-container: new items arrived — recompute positions + clear shifts atomically. - // Skip animation so cells snap to final positions (no spring-back artifact). + // Echo: parent echoed back our committed reorder. Shifts are permanent, visual is correct. + // Skip ALL work — no base recompute, no shift clear, no SV sync, no forceRender. + const isEcho = int.echoSkipRef.current; + int.echoSkipRef.current = false; + if (isEcho) return; + + // PRE-SYNC position/height/orderedKeys SVs to worklet (safe here — after render, before paint). + int.syncPositionsToWorklet(); + int.orderedKeysSV.value = [...int.orderedKeysRef.current]; + if (int.pendingShiftClearRef.current) { int.pendingShiftClearRef.current = false; // Base positions were recomputed eagerly during render (in useSortableList data sync). @@ -606,12 +617,6 @@ export const DraxList = (props: DraxListProps) => { dropIndicatorInfoRef.current = undefined; } - // Echo: parent echoed back our committed reorder. Bases + shifts already handled above. - // Skip updateVisibleCells (bindings unchanged) + forceRender (main perf win). - const isEcho = int.echoSkipRef.current; - int.echoSkipRef.current = false; - if (isEcho) return; - // Reset scroll delta tracking (positions/data just changed) lastProcessedOffsetRef.current = int.scrollOffsetSV.value; updateVisibleCells(int.scrollOffsetSV.value); @@ -697,6 +702,8 @@ export const DraxList = (props: DraxListProps) => { int.isDraggingRef.current = true; int.draggedKeySV.value = itemKey; int.dragStartIndexRef.current = originalIndex; + int.snapTargetPositionRef.current = null; // Reset for this drag + int.snapTargetSV.value = { x: -1, y: -1 }; // Sentinel: worklet hasn't set target yet // Sync to worklet SVs for UI-thread slot detection int.syncRefsToWorklet(); @@ -715,19 +722,14 @@ export const DraxList = (props: DraxListProps) => { // Fire user callback onDragStartProp?.({ index: originalIndex, item: item as T }); - // Find display index - const keys = int.orderedKeysRef.current; - const displayIdx = keys.indexOf(itemKey); + // Find display index — O(1) Map lookup instead of O(N) indexOf + const displayIdx = int.keyToIndexRef.current.get(itemKey) ?? -1; int.currentSlotRef.current = displayIdx >= 0 ? displayIdx : originalIndex; // Freeze slot boundaries for stable detection int.freezeSlotBoundaries(); - // Pre-populate drop indicator info + position for when first dragOver shows it. - // DON'T set visible or forceRender here — that causes a race between - // draggedKeySV (immediate) and hoverReadySV (async via scheduleOnUI), making the - // cell flash visible for 1-2 frames before hover is ready. - // The first onMonitorDragOver slot change will set visible + trigger re-render. + // Pre-populate drop indicator info + position. Read from basePositionsRef (already computed, O(1)). if (renderDropIndicator) { const draggedMeas = eventData.dragged.measurements; const dims = int.itemDimensionsRef.current.get(itemKey); @@ -745,17 +747,16 @@ export const DraxList = (props: DraxListProps) => { targetListId: int.id, fromIndex: originalIndex, }; - const result = int.computeGridPositions(int.orderedKeysRef.current); - const indicatorPos = result.positions.get(itemKey); + // Use visual position (base + permanentShift) for permanent shifts after reorder + const baseIndicatorPos = int.basePositionsRef.current.get(itemKey); + const indicatorShift = int.shiftsSV.value[itemKey]; + const indicatorPos = baseIndicatorPos + ? { x: baseIndicatorPos.x + (indicatorShift?.x ?? 0), y: baseIndicatorPos.y + (indicatorShift?.y ?? 0) } + : undefined; if (indicatorPos) dropIndicatorPositionSV.value = indicatorPos; int.dropIndicatorGenSV.value++; - // DON'T set visible here — let onMonitorDragOver show it after the first real slot detection. - // Setting visible here + the worklet's stale currentSlotSV causes the indicator to flash at (0,0). lastIndicatorSlotRef.current = displayIdx >= 0 ? displayIdx : originalIndex; - // No forceRender() — that causes a race with hoverReadySV. - // The overlay shows empty (no info prop yet) but at correct position + opacity. - // First onMonitorDragOver slot change will trigger a natural re-render with info. } }, [ @@ -996,35 +997,24 @@ export const DraxList = (props: DraxListProps) => { if (basePos && containerMeas) { let visualX: number; let visualY: number; - if (numColumns === 1 && !flexWrap) { - // Single-column: compute target position by walking the reordered keys. - // Use the SAME height sources as computeGridPositions to ensure consistency: - // measured height > getItemSize > running average > estimatedItemSize. - const keys = int.orderedKeysSV.value; - const heights = int.itemHeightsSV.value; - const avgH = int.measuredAvgHeightRef?.current ?? estimatedItemSize; - const sizeFn = getItemSize; - const dataArr = int.dataRef.current; - const keyMap = int.keyToIndexRef.current; - let cursor = 0; - for (const key of keys) { - if (key === dragKey) break; - let h = heights[key]; - if (h === undefined && sizeFn) { - const idx = keyMap.get(key); - if (idx !== undefined && dataArr[idx] !== undefined) { - h = horizontal ? sizeFn(dataArr[idx] as T, idx).width : sizeFn(dataArr[idx] as T, idx).height; - } - } - if (h === undefined) h = avgH; - cursor += h; - } - visualX = horizontal ? cursor : basePos.x; - visualY = horizontal ? basePos.y : cursor; + // O(1) snap target: read from cache (JS path) or SV (worklet path). + // JS recomputeShiftsForReorder writes snapTargetPositionRef. + // Worklet recomputeShiftsWorklet writes snapTargetSV via useDragGesture. + const cachedJS = int.snapTargetPositionRef.current; + const cachedWorklet = sortableWorkletConfig ? int.snapTargetSV.value : null; + if (cachedJS) { + // JS path (grids): target cached in recomputeShiftsForReorder + visualX = cachedJS.x; + visualY = cachedJS.y; + } else if (cachedWorklet && cachedWorklet.x >= 0) { + // Worklet path (single-column): target cached in useDragGesture onUpdate + // Sentinel {-1,-1} means worklet hasn't computed a reorder yet + visualX = cachedWorklet.x; + visualY = cachedWorklet.y; } else { - const shift = int.shiftsSV.value[dragKey]; - visualX = basePos.x + (shift?.x ?? 0); - visualY = basePos.y + (shift?.y ?? 0); + // No reorder — visual position = base + permanent shift + visualX = basePos.x; + visualY = basePos.y; } const scOffset = int.scrollContainerOffsetRef.current; @@ -1035,7 +1025,7 @@ export const DraxList = (props: DraxListProps) => { } return DraxSnapbackTargetPreset.Default; - }, [int, stopAutoScroll, boardContext, horizontal, numColumns, flexWrap, estimatedItemSize]); + }, [int, stopAutoScroll, boardContext, horizontal, sortableWorkletConfig]); const onMonitorDragEnd = useCallback( (_eventData: DraxMonitorEndEventData): DraxProtocolDragEndResponse => { diff --git a/src/hooks/useDragGesture.ts b/src/hooks/useDragGesture.ts index b8e78df..97baae6 100644 --- a/src/hooks/useDragGesture.ts +++ b/src/hooks/useDragGesture.ts @@ -29,6 +29,7 @@ export interface SortableWorkletConfig { draggedKeySV: SharedValue; dropIndicatorPositionSV: SharedValue; scrollOffsetSV: SharedValue; + snapTargetSV: SharedValue; numColumns: number; horizontal: boolean; estimatedItemSize: number; @@ -97,13 +98,11 @@ export const useDragGesture = ( if (!isDragAllowedSV.value) return; isDragAllowedSV.value = false; // Lock — released in onSnapComplete - // Close the drag-start race window: set isDragging on the UI thread - // BEFORE scheduleOnRN(handleDragStart). This prevents handleItemLayout from - // calling recomputeBasePositions + forceRender during the JS callback chain - // (between gesture activation and onMonitorDragStart). - if (sortableWorklet) { - sortableWorklet.isDraggingSV.value = true; - } + // DO NOT set isDraggingSV here. It gates worklet slot detection (onUpdate). + // syncRefsToWorklet (called from onMonitorDragStart on JS thread) writes + // isDraggingSV=true LAST in an atomic scheduleOnUI batch with all other SVs. + // Setting it here would let the worklet run with stale base positions, + // computing wrong shifts that cause items to jump to incorrect positions. // Convert screen-absolute touch to root-view-relative const rootOffset = rootOffsetSV.value; @@ -263,7 +262,16 @@ export const useDragGesture = ( sw.itemHeightsSV.value, sw.cellShiftRecordSV.value, sw.estimatedItemSize, sw.horizontal, sw.reorderStrategy, ); - if (newKeys) sw.orderedKeysSV.value = newKeys; + if (newKeys) { + sw.orderedKeysSV.value = newKeys; + // Cache dragged item's target for O(1) snap at drag end + const bp = sw.basePositionsSV.value[dragKey]; + const cs = sw.cellShiftRecordSV.value[dragKey]; + if (bp && cs) { + const s = cs.value; + sw.snapTargetSV.value = { x: bp.x + s.x, y: bp.y + s.y }; + } + } } } } @@ -308,6 +316,9 @@ export const useDragGesture = ( // re-evaluates immediately (receiver style clears instantly). dragPhaseSV.value = 'releasing'; receiverIdSV.value = ''; + // Stop worklet slot detection immediately. Without this, isDraggingSV stays + // true between drags, letting the next drag's worklet run with stale SVs. + if (sortableWorklet) sortableWorklet.isDraggingSV.value = false; // Bounce to JS for end callbacks + snap animation scheduleOnRN(handleDragEnd, currentDraggedId, currentReceiverId, false, finalHitResult.monitorIds); @@ -336,6 +347,7 @@ export const useDragGesture = ( dragPhaseSV.value = 'releasing'; receiverIdSV.value = ''; + if (sortableWorklet) sortableWorklet.isDraggingSV.value = false; scheduleOnRN(handleDragEnd, currentDraggedId, currentReceiverId, true, finalHitResult.monitorIds); } diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 4d12f9b..7723ec1 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -16,6 +16,7 @@ import type { ReactNode } from 'react'; import { useCallback, useRef } from 'react'; import type { SharedValue } from 'react-native-reanimated'; import { useSharedValue } from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; import type { GridItemSpan, @@ -130,6 +131,7 @@ export interface SortableListInternal { cellShiftRecordSV: SharedValue>>; syncRefsToWorklet: () => void; syncWorkletToRefs: () => void; + syncPositionsToWorklet: () => void; getSlotFromPositionWorklet: ( contentX: number, contentY: number, boundaries: { key: string; x: number; y: number; width: number; height: number }[], @@ -166,6 +168,12 @@ export interface SortableListInternal { pendingShiftClearRef: React.RefObject; /** Set during render when parent echoes back committed reorder. DraxList skips forceRender. */ echoSkipRef: React.RefObject; + /** Cached result from freezeSlotBoundaries' computeGridPositions — avoids redundant O(N) recompute. */ + frozenGridResultRef: React.RefObject<{ positions: Map; dimensions: Map; totalHeight: number } | null>; + /** Cached snap target position — updated during each shift recompute. Avoids O(N) walk at drag end. */ + snapTargetPositionRef: React.RefObject; + /** Snap target (worklet-accessible SV). Written by worklet path during shift computation. */ + snapTargetSV: SharedValue; // ── Layout engine ── /** Record a measured height and update the running average for unmeasured items. */ @@ -244,6 +252,10 @@ export const useSortableList = ( getItemSpanRef.current = getItemSpan as ((item: unknown, index: number) => GridItemSpan) | undefined; getItemSizeRef.current = getItemSize as ((item: unknown, index: number) => { width: number; height: number }) | undefined; + // ── Shadow data (maintained alongside Maps for O(1) worklet sync at drag start) ── + const basePositionsRecordRef = useRef>({}); + const itemHeightsRecordRef = useRef>({}); + // ── SharedValues ── const shiftsSV = useSharedValue>({}); const draggedKeySV = useSharedValue(''); @@ -257,6 +269,8 @@ export const useSortableList = ( const itemHeightsSV = useSharedValue>({}); const currentSlotSV = useSharedValue(0); const isDraggingSV = useSharedValue(false); + /** Snap target position (worklet writes here during shift computation for O(1) snap at drag end). */ + const snapTargetSV = useSharedValue({ x: 0, y: 0 }); const containerMeasSV = useSharedValue<{ x: number; y: number; width: number; height: number } | null>(null); // ── Drop indicator ── @@ -315,24 +329,41 @@ export const useSortableList = ( } }, []); - /** Sync JS refs → SharedValues for worklet slot detection. Called at drag start. */ + /** Sync JS refs → SharedValues for worklet slot detection. Called at drag start. + * + * Large data (basePositions, itemHeights, orderedKeys) is PRE-SYNCED during + * render/measurement in recomputeBasePositions() and the data sync block. + * This function only writes scalars + cellShiftRecord — O(K) where K ≈ visible cells. + * + * All SV writes go through scheduleOnUI for ATOMIC application on the UI thread. + * isDraggingSV is set LAST — it gates the worklet, ensuring all other SVs are + * correct before slot detection runs. */ function syncRefsToWorklet() { - orderedKeysSV.value = [...orderedKeysRef.current]; - currentSlotSV.value = currentSlotRef.current; - isDraggingSV.value = isDraggingRef.current; - containerMeasSV.value = containerMeasRef.current ?? null; - // Base positions: Map → Record - const bp: Record = {}; - for (const [k, v] of basePositionsRef.current) bp[k] = v; - basePositionsSV.value = bp; - // Item heights: Map → Record - const ih: Record = {}; - for (const [k, v] of itemHeightsRef.current) ih[k] = v; - itemHeightsSV.value = ih; - // Cell shift registry: Map → Record (for worklet access) + const slot = currentSlotRef.current; + const cm = containerMeasRef.current ?? null; + // Cell shift registry: Map → Record (O(K) where K ≈ visible cells) const cs: Record> = {}; for (const [k, v] of cellShiftRegistryRef.current) cs[k] = v; - cellShiftRecordSV.value = cs; + + // Atomic write on UI thread. Gesture onUpdate is also on UI thread → + // serialized with this worklet. Either onUpdate runs before (isDraggingSV + // still false → worklet gate skips) or after (all SVs correct). + scheduleOnUI(( + _currentSlotSV: typeof currentSlotSV, + _containerMeasSV: typeof containerMeasSV, + _cellShiftRecordSV: typeof cellShiftRecordSV, + _isDraggingSV: typeof isDraggingSV, + _slot: number, + _cm: typeof cm, + _cs: Record>, + ) => { + 'worklet'; + _currentSlotSV.value = _slot; + _containerMeasSV.value = _cm; + _cellShiftRecordSV.value = _cs; + _isDraggingSV.value = true; // LAST — gates the worklet + }, currentSlotSV, containerMeasSV, cellShiftRecordSV, isDraggingSV, + slot, cm, cs); } /** Sync SharedValues → JS refs after drag ends. */ @@ -375,6 +406,8 @@ export const useSortableList = ( } | null>(null); /** Flex-wrap gap boundaries: positions of items packed WITHOUT the dragged item. */ const frozenFlexGapBoundariesRef = useRef([]); + /** Cached computeGridPositions result from freezeSlotBoundaries — reused by drop indicator. */ + const frozenGridResultRef = useRef | null>(null); // ── Drag state refs ── const isDraggingRef = useRef(false); @@ -387,6 +420,8 @@ export const useSortableList = ( const echoSkipRef = useRef(false); /** Holds the committed data array from commitReorder for reference-equality echo detection. */ const awaitingEchoRef = useRef(null); + /** Cached target position of the dragged item — updated during each shift recompute. Avoids O(N) walk at drag end. */ + const snapTargetPositionRef = useRef(null); // ── Layout helpers ── @@ -537,13 +572,32 @@ export const useSortableList = ( } // ── Layout engine ── - /** Recompute base positions. Does NOT clear shifts (caller decides). */ + /** Recompute base positions. Does NOT clear shifts (caller decides). + * Updates refs only — no SharedValue writes (safe to call during render). + * Call syncPositionsToWorklet() afterwards from a non-render context. */ function recomputeBasePositions() { const keys = orderedKeysRef.current; const result = computeGridPositions(keys); basePositionsRef.current = result.positions; itemDimensionsRef.current = result.dimensions; totalContentSizeRef.current = result.totalHeight; + // Cache for drop indicator position lookup. + // Guard: frozenGridResultRef not yet declared during first-render initialization. + if (frozenGridResultRef) frozenGridResultRef.current = result; + } + + /** Sync position/height data to worklet SharedValues. + * Creates fresh Record copies (Reanimated freezes SV values — never share with refs). + * Call OUTSIDE render: useLayoutEffect, callbacks, commitReorder. */ + function syncPositionsToWorklet() { + const bp: Record = {}; + for (const [k, v] of basePositionsRef.current) bp[k] = v; + basePositionsRecordRef.current = bp; + basePositionsSV.value = bp; + const ih: Record = {}; + for (const [k, v] of itemHeightsRef.current) ih[k] = v; + itemHeightsRecordRef.current = ih; + itemHeightsSV.value = ih; } /** Clear all shifts (snap to 0). Caller must ensure base positions are already current. */ @@ -594,11 +648,10 @@ export const useSortableList = ( keyToIndexRef.current = map; if (isEcho) { - // Library already committed order. orderedKeysRef is correct. - // Still recompute bases + clear shifts for Yoga touch hit-testing. - // echoSkipRef tells DraxList to skip forceRender (main perf win). - recomputeBasePositions(); - pendingShiftClearRef.current = true; + // Library already committed. Shifts are permanent. Visual is correct. + // DON'T recompute bases or clear shifts — Fabric/Reanimated race causes blink + // (newBase + oldShift visible for 1 frame before clearShifts takes effect). + // Permanent shifts: visual = oldBase + shift = correct. Zero work. echoSkipRef.current = true; } else if (!isDraggingRef.current) { orderedKeysRef.current = keys; @@ -606,6 +659,8 @@ export const useSortableList = ( // Recompute base positions EAGERLY during render so cells in THIS commit // get new baseX/baseY props. Without this, shifts clear to 0 in useLayoutEffect // but cells still have OLD base positions → 1-frame blink at original positions. + // SV writes (orderedKeysSV, basePositionsSV, itemHeightsSV) happen in + // useLayoutEffect via syncPositionsToWorklet (not during render). recomputeBasePositions(); // Mark for shift clear in useLayoutEffect (SV writes not allowed during render). @@ -630,14 +685,24 @@ export const useSortableList = ( // Recompute boundaries only when keys changed (not just drag key) if (!keysUnchanged) { frozenKeysRef.current = keys; - const result = computeGridPositions(keys); - const boundaries = keys.map(key => { - const pos = result.positions.get(key) ?? { x: 0, y: 0 }; - const dim = result.dimensions.get(key) ?? { width: 0, height: estimatedItemSize }; - return { key, x: pos.x, y: pos.y, width: dim.width, height: dim.height }; + // Build boundaries from CURRENT key order with VISUAL positions (base + permanentShift). + // Must iterate orderedKeysRef (not shadowBoundariesRef) because shadow is in OLD order. + const shifts = shiftsSV.value; + const basePositions = basePositionsRef.current; + const dimensions = itemDimensionsRef.current; + frozenBoundariesRef.current = keys.map(key => { + const pos = basePositions.get(key) ?? { x: 0, y: 0 }; + const shift = shifts[key]; + const dim = dimensions.get(key) ?? { width: 0, height: estimatedItemSize }; + return { + key, + x: pos.x + (shift?.x ?? 0), + y: pos.y + (shift?.y ?? 0), + width: dim.width, + height: dim.height, + }; }); - frozenBoundariesRef.current = boundaries; - frozenBoundariesSV.value = boundaries; // Sync to UI thread for worklet slot detection + frozenBoundariesSV.value = frozenBoundariesRef.current; } // Virtual slot: pack grid WITHOUT the dragged item to create a stable "gap layout." @@ -941,6 +1006,9 @@ export const useSortableList = ( } } shiftsSV.value = newShifts; // Keep for JS-thread reads (visibility, snap) + // Cache dragged item's target position for O(1) snap at drag end + const draggedTarget = result.positions.get(dragKey); + if (draggedTarget) snapTargetPositionRef.current = draggedTarget; // Grow content area during drag so shifted items aren't clipped if (result.totalHeight > totalContentSizeRef.current) { totalContentSizeRef.current = result.totalHeight; @@ -1037,6 +1105,8 @@ export const useSortableList = ( } keyToIndexRef.current = newKeyToIndex; awaitingEchoRef.current = reorderedData; + // PRE-SYNC orderedKeys so next drag start doesn't need O(N) copy + orderedKeysSV.value = [...keys]; // Clear drag state isDraggingRef.current = false; @@ -1096,8 +1166,10 @@ export const useSortableList = ( isDraggingSV, containerMeasSV, cellShiftRecordSV, + snapTargetSV, syncRefsToWorklet, syncWorkletToRefs, + syncPositionsToWorklet, getSlotFromPositionWorklet, recomputeShiftsWorklet, dropIndicatorPositionSV, @@ -1111,6 +1183,8 @@ export const useSortableList = ( frozenBoundariesRef, pendingShiftClearRef, echoSkipRef, + frozenGridResultRef, + snapTargetPositionRef, recordItemHeight, computeGridPositions, recomputeBasePositions, From 6077efbca718e0f375088c81d3cbdcfc92d3c75c Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:51:36 +0200 Subject: [PATCH 11/12] refactor: split monolithic types.ts into types/ folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split 958-line types.ts into 5 focused files: core, events, view, provider, sortable — plus barrel index.ts for backward-compatible imports - Remove ~270 lines of dead/duplicate type definitions that were never imported (authoritative versions live in hooks/useSortableList.ts, hooks/useSortableBoard.ts, SortableBoardContext.ts) - Expand hooks/index.ts barrel from 2 to 12 exports - Export AnimatedViewStylePropWithoutLayout from public API - Update CLAUDE.md with Code Organization section Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 23 + src/hooks/index.ts | 12 + src/index.ts | 1 + src/types.ts | 958 ------------------------------------------ src/types/core.ts | 117 ++++++ src/types/events.ts | 106 +++++ src/types/index.ts | 74 ++++ src/types/provider.ts | 190 +++++++++ src/types/sortable.ts | 44 ++ src/types/view.ts | 245 +++++++++++ 10 files changed, 812 insertions(+), 958 deletions(-) delete mode 100644 src/types.ts create mode 100644 src/types/core.ts create mode 100644 src/types/events.ts create mode 100644 src/types/index.ts create mode 100644 src/types/provider.ts create mode 100644 src/types/sortable.ts create mode 100644 src/types/view.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6e367b5..251bfa5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,29 @@ Drag-and-drop framework for React Native (iOS, Android, Web). v1.0.0 — major r - Latest React features. - **New Architecture (Fabric)**: `useLayoutEffect` + `measure()` is synchronous (JSI SyncCallback). Measurement and state updates happen in a single commit before paint — no intermediate states visible to users. Use `useLayoutEffect` + `ref.measure()` instead of `onLayout` for item measurement. Reference: https://reactnative.dev/architecture/landing-page#synchronous-layout-and-effects +### Code Organization + +``` +src/ +├── types/ ← All shared type definitions +│ ├── core.ts — Geometry, phases, enums, collision, spatial, registration +│ ├── events.ts — Event data interfaces, snap types +│ ├── view.ts — DraxView props, render props, styles +│ ├── provider.ts — Context, registry, provider, scroll types +│ ├── sortable.ts — Sortable config, animation presets, item payload +│ └── index.ts — Barrel re-export (import from '../types' resolves here) +├── hooks/ ← All React hooks (barrel: hooks/index.ts) +├── compat/ ← Gesture Handler version compatibility +├── DraxList.tsx — Custom recycling list with drag-and-drop +├── DraxView.tsx — Core draggable/receptive view +├── DraxProvider.tsx — Root provider (context + spatial index) +├── math.ts — Geometry utilities, grid/flex packing +└── params.ts — Animation presets, default constants +``` + +- **Type ownership**: Shared types live in `types/`. Hook-local types (e.g., `SortableListInternal` in `useSortableList.ts`, `SortableWorkletConfig` in `useDragGesture.ts`) stay co-located with their hook. Component props (e.g., `DraxListProps`) stay in their component file. +- **Public API**: `src/index.ts` re-exports public components, hooks, utilities, and types. Hook-specific types (`SortableReorderEvent`, `SortableListHandle`, etc.) are exported from their hook files, not from `types/`. + ## Sortable Architecture diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8ab049d..f3358b6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,2 +1,14 @@ +// ─── Public hooks ──────────────────────────────────────────────────────── export { useDraxContext } from './useDraxContext'; export { useDraxId } from './useDraxId'; +export { useDraxMethods } from './useDraxMethods'; +export { useSortableList } from './useSortableList'; +export { useSortableBoard } from './useSortableBoard'; + +// ─── Internal hooks (used within src/) ─────────────────────────────────── +export { useCallbackDispatch } from './useCallbackDispatch'; +export { useDragGesture } from './useDragGesture'; +export { useDraxScrollHandler } from './useDraxScrollHandler'; +export { useSpatialIndex, isDraggable } from './useSpatialIndex'; +export { useViewStyles } from './useViewStyles'; +export { useWebScrollFreeze } from './useWebScrollFreeze'; diff --git a/src/index.ts b/src/index.ts index bf5db61..2a6d2a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ export type { DraxRenderContentProps, DraxRenderHoverContentProps, DraxStyleProp, + AnimatedViewStylePropWithoutLayout, DraxViewStyleProps, DraxViewRenderContent, DraxViewRenderHoverContent, diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index e8c12ab..0000000 --- a/src/types.ts +++ /dev/null @@ -1,958 +0,0 @@ -import type { ReactNode, RefObject } from 'react'; -import type { - NativeScrollEvent, - NativeSyntheticEvent, - ScrollViewProps, - StyleProp, - ViewProps, - ViewStyle, -} from 'react-native'; -import type { HostInstance } from 'react-native'; -import type { - AnimatedStyle, - EntryOrExitLayoutType, - SharedValue, -} from 'react-native-reanimated'; - -// ─── Core Geometry Types ─────────────────────────────────────────────────── - -/** An xy-coordinate position value */ -export interface Position { - // Index signature required for Reanimated AnimatableValue compatibility - [k: string]: number; - x: number; - y: number; -} - -/** Predicate for checking if something is a Position */ -export const isPosition = (something: unknown): something is Position => - typeof something === 'object' && - something !== null && - 'x' in something && - 'y' in something && - typeof something.x === 'number' && - typeof something.y === 'number'; - -/** Dimensions of a view */ -export interface ViewDimensions { - width: number; - height: number; -} - -/** Grid span for a sortable item (columns and rows it occupies) */ -export interface GridItemSpan { - /** Number of columns this item spans. @default 1 */ - colSpan: number; - /** Number of rows this item spans. @default 1 */ - rowSpan: number; -} - -/** Measurements of a Drax view for bounds checking purposes */ -export interface DraxViewMeasurements extends Position, ViewDimensions { - /** 1 when DraxView auto-detected transform-based positioning - * (e.g., LegendList) and used visual measurement instead of Yoga layout. 0 otherwise. */ - _transformDetected: number; -} - -// ─── Drag Phase & Status Types ───────────────────────────────────────────── - -/** Phase of a drag operation — drives all animated styles */ -export type DragPhase = 'idle' | 'dragging' | 'releasing'; - -/** The states a dragged view can be in */ -export enum DraxViewDragStatus { - Inactive, - Dragging, - Released, -} - -/** The states a receiver view can be in */ -export enum DraxViewReceiveStatus { - Inactive, - Receiving, -} - -// ─── Collision Algorithm ──────────────────────────────────────────────────── - -/** Algorithm used to determine if a dragged view is over a receiver */ -export type CollisionAlgorithm = 'center' | 'intersect' | 'contain'; - -// ─── Spatial Index (SharedValue, UI Thread) ──────────────────────────────── - -/** Entry in the spatial index SharedValue, accessed from worklets for hit-testing */ -export interface SpatialEntry { - /** View unique identifier */ - id: string; - /** Position relative to parent */ - x: number; - y: number; - width: number; - height: number; - /** Index of parent in the spatial index array, -1 if root */ - parentIndex: number; - /** Can this view receive drags? */ - receptive: boolean; - /** Can this view monitor drags? */ - monitoring: boolean; - /** Can this view be dragged? */ - draggable: boolean; - /** If true, this view will not receive drags from its own children */ - rejectOwnChildren: boolean; - /** Collision algorithm for receiving: 'center' (default), 'intersect', or 'contain' */ - collisionAlgorithm: CollisionAlgorithm; -} - -/** Result of a UI-thread hit test */ -export interface HitTestResult { - receiverId: string; - monitorIds: string[]; -} - -// ─── Event Data Types (Public API) ───────────────────────────────────────── - -/** Data about a view involved in a Drax event */ -export interface DraxEventViewData { - id: string; - parentId?: string; - payload: unknown; - measurements?: DraxViewMeasurements; -} - -/** Data about a dragged view involved in a Drax event */ -export interface DraxEventDraggedViewData extends DraxEventViewData { - dragTranslationRatio: Position; - dragOffset: Position; - grabOffset: Position; - grabOffsetRatio: Position; - hoverPosition: Position; -} - -/** Data about a receiver view involved in a Drax event */ -export interface DraxEventReceiverViewData extends DraxEventViewData { - receiveOffset: Position; - receiveOffsetRatio: Position; -} - -/** Data about a Drax drag event */ -export interface DraxDragEventData { - dragAbsolutePosition: Position; - dragTranslation: Position; - dragged: DraxEventDraggedViewData; -} - -/** Supplemental type for adding a cancelled flag */ -export interface WithCancelledFlag { - cancelled: boolean; -} - -/** Predicate for checking if something has a cancelled flag */ -export const isWithCancelledFlag = ( - something: unknown -): something is WithCancelledFlag => - typeof something === 'object' && - something !== null && - 'cancelled' in something && - typeof something.cancelled === 'boolean'; - -/** Data about a Drax drag end event */ -export interface DraxDragEndEventData - extends DraxDragEventData, WithCancelledFlag {} - -/** Data about a Drax drag event that involves a receiver */ -export interface DraxDragWithReceiverEventData extends DraxDragEventData { - receiver: DraxEventReceiverViewData; -} - -/** Data about a Drax drag/receive end event */ -export interface DraxDragWithReceiverEndEventData - extends DraxDragWithReceiverEventData, WithCancelledFlag {} - -/** Data about a Drax monitor event */ -export interface DraxMonitorEventData extends DraxDragEventData { - receiver?: DraxEventReceiverViewData; - monitorOffset: Position; - monitorOffsetRatio: Position; -} - -/** Data about a Drax monitor drag end event */ -export interface DraxMonitorEndEventData - extends DraxMonitorEventData, WithCancelledFlag {} - -/** Data about a Drax monitor drag-drop event */ -export interface DraxMonitorDragDropEventData extends Required {} - -// ─── Snap Types ──────────────────────────────────────────────────────────── - -/** Preset values for specifying snap targets without a Position */ -export enum DraxSnapbackTargetPreset { - Default, - None, -} - -/** Target for snap hover view release animation: none, default, or specified Position */ -export type DraxSnapbackTarget = DraxSnapbackTargetPreset | Position; - -/** Response type for drag end callbacks, allowing override of default release snap behavior */ -export type DraxProtocolDragEndResponse = void | DraxSnapbackTarget; - -/** Data about a Drax snap, used for custom animations */ -export interface DraxSnapData { - hoverPosition: SharedValue; - toValue: Position; - delay: number; - duration: number; - scrollPosition?: SharedValue; - finishedCallback: (finished: boolean) => void; -} - -/** Data passed to onSnapEnd and onReceiveSnapEnd callbacks */ -export interface DraxSnapEndEventData { - dragged: { id: string; parentId?: string; payload: unknown }; - receiver?: { id: string; parentId?: string; payload: unknown }; -} - -// ─── Render Content Props ────────────────────────────────────────────────── - -/** Simplified view state for render content props */ -export interface DraxViewState { - dragStatus: DraxViewDragStatus; - receiveStatus: DraxViewReceiveStatus; - dragAbsolutePosition?: Position; - dragTranslation?: Position; - dragTranslationRatio?: Position; - dragOffset?: Position; - grabOffset?: Position; - grabOffsetRatio?: Position; - draggingOverReceiver?: DraxEventViewData; - receiveOffset?: Position; - receiveOffsetRatio?: Position; - receivingDrag?: DraxEventViewData; -} - -/** Tracking status indicating whether anything is being dragged/received */ -export interface DraxTrackingStatus { - dragging: boolean; - receiving: boolean; -} - -/** Props provided to a render function for a Drax view */ -export interface DraxRenderContentProps { - viewState?: DraxViewState; - trackingStatus?: DraxTrackingStatus; - hover: boolean; - children: ReactNode; - dimensions?: ViewDimensions; -} - -/** Props provided to a render function for a hovering copy of a Drax view */ -export interface DraxRenderHoverContentProps extends DraxRenderContentProps {} - -// ─── Style Types ─────────────────────────────────────────────────────────── - -/** Style prop for DraxView drag/receive states (flattened for worklets) */ -export type DraxStyleProp = StyleProp; - -/** Style prop for hover views (supports animated styles) */ -export type AnimatedViewStylePropWithoutLayout = - | StyleProp - | StyleProp>>; - -/** Style-related props for a Drax view */ -export interface DraxViewStyleProps { - style?: DraxStyleProp; - dragInactiveStyle?: DraxStyleProp; - draggingStyle?: DraxStyleProp; - draggingWithReceiverStyle?: DraxStyleProp; - draggingWithoutReceiverStyle?: DraxStyleProp; - dragReleasedStyle?: DraxStyleProp; - hoverStyle?: AnimatedViewStylePropWithoutLayout; - hoverDraggingStyle?: AnimatedViewStylePropWithoutLayout; - hoverDraggingWithReceiverStyle?: AnimatedViewStylePropWithoutLayout; - hoverDraggingWithoutReceiverStyle?: AnimatedViewStylePropWithoutLayout; - hoverDragReleasedStyle?: AnimatedViewStylePropWithoutLayout; - receiverInactiveStyle?: DraxStyleProp; - receivingStyle?: DraxStyleProp; - otherDraggingStyle?: DraxStyleProp; - otherDraggingWithReceiverStyle?: DraxStyleProp; - otherDraggingWithoutReceiverStyle?: DraxStyleProp; -} - -// ─── Custom render functions ─────────────────────────────────────────────── - -/** Custom render function for content of a DraxView */ -export interface DraxViewRenderContent { - (props: DraxRenderContentProps): ReactNode; -} - -/** Custom render function for content of hovering copy of a DraxView */ -export interface DraxViewRenderHoverContent { - (props: DraxRenderHoverContentProps): ReactNode; -} - -// ─── View Props ──────────────────────────────────────────────────────────── - -/** Props for a DraxView */ -export interface DraxViewProps - extends Omit, DraxViewStyleProps { - /** Custom render function for content of this view */ - renderContent?: DraxViewRenderContent; - /** Custom render function for content of hovering copy of this view, defaults to renderContent */ - renderHoverContent?: DraxViewRenderHoverContent; - /** If true, do not render hover view copies for this view when dragging */ - noHover?: boolean; - /** For external registration of this view, to access internal methods */ - registration?: (registration: DraxViewRegistration | undefined) => void; - /** For receiving view measurements externally */ - onMeasure?: DraxViewMeasurementHandler; - /** Unique Drax view id, auto-generated if omitted */ - id?: string; - /** Drax parent view, if nesting */ - parent?: DraxParentView; - /** If true, treat this view as a Drax parent view for nested children */ - isParent?: boolean; - /** Used internally - The view's scroll position, if it is a scrollable parent view */ - scrollPosition?: SharedValue; - /** Time in milliseconds view needs to be pressed before drag starts */ - longPressDelay?: number; - /** Cancel drag activation if finger moves more than this distance (px). - * Prevents accidental drags when the user is trying to scroll. - * Can be a number (symmetric) or [min, max] tuple per axis. */ - dragActivationFailOffset?: number; - - /** Hint that this view is inside a horizontal scroll container. - * On mobile web, sets `touch-action: pan-x` so the browser allows - * native horizontal scrolling before the long-press activates drag. - * Without this, items in horizontal lists default to `pan-y` which - * blocks horizontal scrolling on touch devices. */ - scrollHorizontal?: boolean; - - // ─── Callback props (formerly in DraxProtocol) ───────────────────── - - /** A function that can be used to conditionally enable or disable receiving */ - dynamicReceptiveCallback?: (data: { - targetId: string; - targetMeasurements: DraxViewMeasurements; - draggedId: string; - draggedPayload: unknown; - }) => boolean; - - /** Simpler convenience prop for conditional drop acceptance based on payload */ - acceptsDrag?: (draggedPayload: unknown) => boolean; - /** Maximum number of items this view can receive. Drops are auto-rejected - * when at capacity. Requires DraxProvider to track dropped items centrally. */ - capacity?: number; - - /** Called in the dragged view when a drag action begins */ - onDragStart?: (data: DraxDragEventData) => void; - /** Called in the dragged view repeatedly while dragged, not over any receiver */ - onDrag?: (data: DraxDragEventData) => void; - /** Called in the dragged view when initially dragged over a new receiver */ - onDragEnter?: (data: DraxDragWithReceiverEventData) => void; - /** Called in the dragged view repeatedly while dragged over a receiver */ - onDragOver?: (data: DraxDragWithReceiverEventData) => void; - /** Called in the dragged view when dragged off of a receiver */ - onDragExit?: (data: DraxDragWithReceiverEventData) => void; - /** Called in the dragged view when drag ends not over any receiver or is cancelled */ - onDragEnd?: (data: DraxDragEndEventData) => DraxProtocolDragEndResponse; - /** Called in the dragged view when drag ends over a receiver */ - onDragDrop?: ( - data: DraxDragWithReceiverEventData - ) => DraxProtocolDragEndResponse; - /** Called in the dragged view when drag release snap ends */ - onSnapEnd?: (data: DraxSnapEndEventData) => void; - /** Called in the receiver view when drag release snap ends */ - onReceiveSnapEnd?: (data: DraxSnapEndEventData) => void; - /** Called in the receiver view each time an item is initially dragged over it */ - onReceiveDragEnter?: (data: DraxDragWithReceiverEventData) => void; - /** Called in the receiver view repeatedly while an item is dragged over it */ - onReceiveDragOver?: (data: DraxDragWithReceiverEventData) => void; - /** Called in the receiver view when item is dragged off of it or drag is cancelled */ - onReceiveDragExit?: (data: DraxDragWithReceiverEndEventData) => void; - /** Called in the receiver view when drag ends over it */ - onReceiveDragDrop?: ( - data: DraxDragWithReceiverEventData - ) => DraxProtocolDragEndResponse; - /** Called in the monitor view when a drag action begins over it */ - onMonitorDragStart?: (data: DraxMonitorEventData) => void; - /** Called in the monitor view each time an item is initially dragged over it */ - onMonitorDragEnter?: (data: DraxMonitorEventData) => void; - /** Called in the monitor view repeatedly while an item is dragged over it */ - onMonitorDragOver?: (data: DraxMonitorEventData) => void; - /** Called in the monitor view when item is dragged off of it */ - onMonitorDragExit?: (data: DraxMonitorEventData) => void; - /** Called in the monitor view when drag ends over it while not over any receiver or drag is cancelled */ - onMonitorDragEnd?: ( - data: DraxMonitorEndEventData - ) => DraxProtocolDragEndResponse; - /** Called in the monitor view when drag ends over it while over a receiver */ - onMonitorDragDrop?: ( - data: DraxMonitorDragDropEventData - ) => DraxProtocolDragEndResponse; - - /** Whether or not to animate hover view snap after drag release, defaults to true */ - animateSnap?: boolean; - /** Delay in ms before hover view snap begins after drag is released */ - snapDelay?: number; - /** Duration in ms for hover view snap to complete */ - snapDuration?: number; - /** Function returning custom hover view snap animation */ - snapAnimator?: (data: DraxSnapData) => void; - - /** Payload that will be delivered to receiver views when this view is dragged; overrides `payload` */ - dragPayload?: unknown; - /** Payload that will be delivered to dragged views when this view receives them; overrides `payload` */ - receiverPayload?: unknown; - /** Convenience prop to provide one value for both `dragPayload` and `receiverPayload` */ - payload?: unknown; - - /** Whether the view can be dragged */ - draggable?: boolean; - /** Whether the view can receive drags */ - receptive?: boolean; - /** Whether the view can monitor drags */ - monitoring?: boolean; - /** If true, this view will not receive drags from its own children */ - rejectOwnChildren?: boolean; - /** @deprecated No longer needed — hover measurements are handled automatically */ - disableHoverViewMeasurementsOnLayout?: boolean; - /** If true, lock drag's x-position */ - lockDragXPosition?: boolean; - /** If true, lock drag's y position */ - lockDragYPosition?: boolean; - /** When true, drag is only activated via a descendant DraxHandle component */ - dragHandle?: boolean; - /** Internal: worklet config for UI-thread slot detection (set by DraxList) */ - sortableWorklet?: unknown; - /** Collision algorithm for receiving drags: 'center' (default), 'intersect', or 'contain' */ - collisionAlgorithm?: CollisionAlgorithm; - /** Ref to a View that constrains the drag area. The dragged view will be clamped within these bounds. */ - dragBoundsRef?: RefObject; -} - -// ─── View Registry (JS Thread) ───────────────────────────────────────────── - -/** Flattened hover styles for the currently dragged view */ -export interface FlattenedHoverStyles { - hoverStyle: ViewStyle | null; - hoverDraggingStyle: ViewStyle | null; - hoverDraggingWithReceiverStyle: ViewStyle | null; - hoverDraggingWithoutReceiverStyle: ViewStyle | null; - hoverDragReleasedStyle: ViewStyle | null; -} - -/** Entry in the JS-thread view registry Map */ -export interface ViewRegistryEntry { - id: string; - parentId?: string; - /** Index in the spatialIndexSV array */ - spatialIndex: number; - /** Scroll position SharedValue, for scrollable parent views */ - scrollPosition?: SharedValue; - /** Current measurements */ - measurements?: DraxViewMeasurements; - /** All props from DraxView (callbacks, styles, payload, etc.) */ - props: DraxViewProps; - /** Pre-flattened hover styles — computed at registration/prop-update time - * to avoid 5 StyleSheet.flatten calls in the drag-start hot path. */ - flattenedHoverStyles?: FlattenedHoverStyles; -} - -// ─── Context Value ───────────────────────────────────────────────────────── - -/** Context value used internally by Drax provider */ -export interface DraxContextValue { - // ── Split SharedValues (by update frequency) ─────────────────────── - /** Changes ~2x per drag. Read by all DraxView useAnimatedStyle. */ - draggedIdSV: SharedValue; - /** Changes ~3-5x per drag. Read by all DraxView useAnimatedStyle. */ - receiverIdSV: SharedValue; - /** Changes ~3x per drag. Read by all DraxView useAnimatedStyle. */ - dragPhaseSV: SharedValue; - /** Changes every frame during drag. Read ONLY by HoverLayer. */ - hoverPositionSV: SharedValue; - /** Changes every frame during drag. Used by gesture worklet for hit-testing. */ - dragAbsolutePositionSV: SharedValue; - /** ID of the most recently rejected receiver (cleared when drag leaves its bounds). - * Read by gesture worklet to skip re-detecting the same rejected receiver. */ - rejectedReceiverIdSV: SharedValue; - /** Changes on view mount/layout. Read by gesture worklet for hit-testing. */ - spatialIndexSV: SharedValue; - /** Changes during scroll. Indexed parallel to spatialIndex. */ - scrollOffsetsSV: SharedValue; - /** Set once on drag start. */ - grabOffsetSV: SharedValue; - /** Absolute position where drag started. */ - startPositionSV: SharedValue; - /** Screen offset of the DraxProvider root view (for coordinate conversion). */ - rootOffsetSV: SharedValue; - /** True after hover content is committed to DOM (set in HoverLayer useLayoutEffect). - * False after snap completes. Used by SortableItem for blink-free visibility. */ - hoverReadySV: SharedValue; - /** Set to true by SortableContainer.finalizeDrag when a reorder commit is in-flight. - * Checked by onSnapComplete to skip immediate hover clearing — the clearing is - * deferred to useSortableList's useLayoutEffect (after FlatList re-render). */ - hoverClearDeferredRef: { current: boolean }; - /** Animated hover content dimensions for cross-container transfer. - * x = width, y = height. {0,0} = no constraint (natural size). */ - hoverDimsSV: SharedValue; - /** Drag lock — false during snap animation. Blocks new gesture activation on UI thread. */ - isDragAllowedSV: SharedValue; - - // ── Registry methods (JS thread) ─────────────────────────────────── - registerView: (payload: RegisterViewPayload) => void; - unregisterView: (id: string) => void; - updateMeasurements: (id: string, measurements: DraxViewMeasurements) => void; - updateScrollOffset: (id: string, offset: Position) => void; - updateViewProps: (id: string, props: DraxViewProps) => void; - getViewEntry: (id: string) => ViewRegistryEntry | undefined; - - // ── Callback dispatch (JS thread, called via scheduleOnRN from gesture) ─ - handleDragStart: ( - draggedId: string, - absolutePosition: Position, - grabOffset: Position - ) => void; - handleReceiverChange: ( - oldReceiverId: string, - newReceiverId: string, - absolutePosition: Position, - draggedId: string, - startPosition: Position, - grabOffset: Position, - monitorIds?: string[] - ) => void; - handleDragEnd: ( - draggedId: string, - receiverId: string, - cancelled: boolean, - finalMonitorIds?: string[] - ) => void; - - // ── Hover content ────────────────────────────────────────────────── - setHoverContent: (content: ReactNode | null) => void; - - // ── Dropped items tracking ───────────────────────────────────────── - /** Map of receiverId → Set of draggedIds that have been dropped on it */ - droppedItemsRef: RefObject>>; - - // ── View refs ────────────────────────────────────────────────────── - rootViewRef: { current: HostInstance | null }; - parent?: DraxParentView; -} - -/** Payload for registering a Drax view */ -export interface RegisterViewPayload { - id: string; - parentId?: string; - scrollPosition?: SharedValue; - props: DraxViewProps; -} - -// ─── Provider / Subprovider Props ────────────────────────────────────────── - -/** Event data for provider-level drag callbacks */ -export interface DraxProviderDragEvent { - draggedId: string; - receiverId?: string; - position: Position; -} - -/** Optional props that can be passed to a DraxProvider */ -export interface DraxProviderProps { - style?: StyleProp; - debug?: boolean; - /** Called when any drag starts */ - onDragStart?: (event: DraxProviderDragEvent) => void; - /** Called on every gesture update during any drag */ - onDrag?: (event: DraxProviderDragEvent) => void; - /** Called when any drag ends (drop or cancel) */ - onDragEnd?: (event: DraxProviderDragEvent & { cancelled: boolean }) => void; - children?: ReactNode; -} - -/** Props that are passed to a DraxSubprovider */ -export interface DraxSubproviderProps { - parent: DraxParentView; -} - -// ─── External Registration ───────────────────────────────────────────────── - -/** Methods provided by a DraxView when registered externally */ -export interface DraxViewRegistration { - id: string; - measure: (measurementHandler?: DraxViewMeasurementHandler) => void; -} - -/** Information about the parent of a nested DraxView */ -export interface DraxParentView { - id: string; - /** Any ref-like object with a .current holding a native view instance. - * Accepts both React.RefObject and Reanimated.AnimatedRef. */ - viewRef: { current: any }; - /** When true, measureLayout returns content-relative positions on native - * (scroll offset should NOT be added). */ - isScrollContainer?: boolean; -} - -/** Function that receives a Drax view measurement */ -export interface DraxViewMeasurementHandler { - (measurements: DraxViewMeasurements | undefined): void; -} - -// ─── Auto-scroll Types ───────────────────────────────────────────────────── - -/** Auto-scroll direction used internally by DraxScrollView and DraxList */ -export enum AutoScrollDirection { - Back = -1, - None = 0, - Forward = 1, -} - -/** Auto-scroll state used internally by DraxScrollView */ -export interface AutoScrollState { - x: AutoScrollDirection; - y: AutoScrollDirection; -} - -/** Props for auto-scroll options */ -export interface DraxAutoScrollProps { - autoScrollIntervalLength?: number; - autoScrollJumpRatio?: number; - autoScrollBackThreshold?: number; - autoScrollForwardThreshold?: number; -} - -// ─── ScrollView Props ────────────────────────────────────────────────────── - -/** Props for a DraxScrollView */ -export interface DraxScrollViewProps - extends ScrollViewProps, DraxAutoScrollProps { - id?: string; -} - -// ─── Sortable Types (List-Agnostic) ───────────────────────────────────────── - -/** Reorder strategy for sortable lists */ -export type SortableReorderStrategy = 'insert' | 'swap'; - -/** Named animation preset for sortable item shift animations */ -export type SortableAnimationPreset = 'default' | 'spring' | 'gentle' | 'snappy' | 'none'; - -/** Custom animation configuration for sortable item shifts */ -export interface SortableAnimationCustomConfig { - /** Duration in ms for timing-based animations. Ignored when useSpring is true. @default 200 */ - shiftDuration?: number; - /** Use spring physics instead of timing. @default false */ - useSpring?: boolean; - /** Spring damping. @default 15 */ - springDamping?: number; - /** Spring stiffness. @default 150 */ - springStiffness?: number; - /** Spring mass. @default 1 */ - springMass?: number; -} - -/** Animation configuration: a preset name or custom config object */ -export type SortableAnimationConfig = SortableAnimationPreset | SortableAnimationCustomConfig; - -/** Measurement for a single sortable item, keyed by item key */ -export interface SortableItemMeasurement { - x: number; - y: number; - width: number; - height: number; - key: string; - /** Current display index (updated on reorder) */ - index: number; - /** Scroll offset at the time this measurement was taken */ - scrollAtMeasure: Position; -} - -/** Internal payload attached to each SortableItem's DraxView */ -export interface SortableItemPayload { - index: number; - originalIndex: number; -} - -/** Type guard for SortableItemPayload */ -export function isSortableItemPayload(value: unknown): value is SortableItemPayload { - return ( - typeof value === 'object' && - value !== null && - 'index' in value && - 'originalIndex' in value && - typeof value.index === 'number' && - typeof value.originalIndex === 'number' - ); -} - -/** Event data for sortable drag start */ -export interface SortableDragStartEvent { - index: number; - item: T; -} - -/** Event data for sortable drag position change */ -export interface SortableDragPositionChangeEvent { - index: number; - item: T; - toIndex: number | undefined; - previousIndex: number | undefined; -} - -/** Event data for sortable drag end */ -export interface SortableDragEndEvent { - index: number; - item: T; - toIndex: number | undefined; - cancelled: boolean; -} - -/** Event data for sortable item reorder */ -export interface SortableReorderEvent { - data: T[]; - fromIndex: number; - toIndex: number; - fromItem: T; - toItem: T; - isExternalDrag: boolean; -} - -/** Props for rendering a drop indicator in a sortable container */ -export interface DropIndicatorProps { - /** Whether the indicator should be visible */ - visible: boolean; - /** Whether the list is horizontal */ - horizontal: boolean; -} - -/** Options for useSortableList hook */ -export interface UseSortableListOptions { - /** Optional explicit DraxView id for the container */ - id?: string; - data: T[]; - keyExtractor: (item: T, index: number) => string; - onReorder: (event: SortableReorderEvent) => void; - /** List layout direction. @default false */ - horizontal?: boolean; - /** Number of columns for grid layout. @default 1 */ - numColumns?: number; - /** Reorder strategy. @default 'insert' */ - reorderStrategy?: SortableReorderStrategy; - /** Long press delay before drag starts in ms. @default 250 */ - longPressDelay?: number; - /** Lock item drags to the list's main axis. @default false */ - lockToMainAxis?: boolean; - /** Auto-scroll jump distance as fraction of container size. @default 0.2 */ - autoScrollJumpRatio?: number; - /** Drag position threshold for back auto-scroll. @default 0.1 */ - autoScrollBackThreshold?: number; - /** Drag position threshold for forward auto-scroll. @default 0.9 */ - autoScrollForwardThreshold?: number; - /** Animation config for item shift animations. @default 'default' */ - animationConfig?: SortableAnimationConfig; - /** Returns the grid span for an item. Enables non-uniform grid layout - * where items can span multiple columns and/or rows. - * Only used when numColumns > 1. */ - getItemSpan?: (item: T, index: number) => GridItemSpan; - /** Enable flex-wrap layout. Items flow left-to-right and wrap to new rows. */ - flexWrap?: boolean; - /** Returns pixel dimensions per item. Required when flexWrap is true. */ - getItemSize?: (item: T, index: number) => { width: number; height: number }; - /** Style applied to all non-dragged items while a drag is active. - * Use for dimming/scaling inactive items (e.g., `{ opacity: 0.5 }`). */ - inactiveItemStyle?: ViewStyle; - /** Reanimated layout animation for items entering the list (e.g., `FadeIn`). */ - itemEntering?: EntryOrExitLayoutType; - /** Reanimated layout animation for items exiting the list (e.g., `FadeOut`). */ - itemExiting?: EntryOrExitLayoutType; - /** Callback when drag starts */ - onDragStart?: (event: SortableDragStartEvent) => void; - /** Callback when drag position (index) changes */ - onDragPositionChange?: (event: SortableDragPositionChangeEvent) => void; - /** Callback when drag ends */ - onDragEnd?: (event: SortableDragEndEvent) => void; -} - -/** Handle returned by useSortableList — pass to SortableContainer and SortableItem */ -export interface SortableListHandle { - /** Reordered data to pass to your list component */ - data: T[]; - /** Wire to your list's onScroll prop */ - onScroll: (event: NativeSyntheticEvent) => void; - /** Wire to your list's onContentSizeChange prop */ - onContentSizeChange: (width: number, height: number) => void; - /** Stable index-based keyExtractor — prevents FlatList cell unmounting on reorder */ - stableKeyExtractor: (item: T, index: number) => string; - /** Internal state — consumed by SortableContainer and SortableItem */ - _internal: SortableListInternal; -} - -/** Internal state of the sortable list (not part of public API contract) */ -export interface SortableListInternal { - id: string; - horizontal: boolean; - numColumns: number; - reorderStrategy: SortableReorderStrategy; - longPressDelay: number; - lockToMainAxis: boolean; - animationConfig: SortableAnimationConfig; - /** Returns the grid span for an item (non-uniform grid layout) */ - getItemSpan?: (item: T, index: number) => GridItemSpan; - flexWrap?: boolean; - getItemSize?: (item: T, index: number) => { width: number; height: number }; - inactiveItemStyle?: ViewStyle; - itemEntering?: EntryOrExitLayoutType; - itemExiting?: EntryOrExitLayoutType; - /** Set of item keys that are fixed (cannot be dragged or displaced) */ - fixedKeys: RefObject>; - draggedItem: SharedValue; - itemMeasurements: RefObject>; - originalIndexes: number[]; - keyExtractor: (item: T, index: number) => string; - data: T[]; - rawData: T[]; - /** Move the dragged item to a new display index (live reorder during drag) */ - moveDraggedItem: (toDisplayIndex: number) => void; - /** Get the snapback target for the dragged item's current position */ - getSnapbackTarget: () => DraxSnapbackTarget; - setDraggedItem: (index: number) => void; - resetDraggedItem: () => void; - scrollPosition: SharedValue; - containerMeasurementsRef: RefObject; - contentSizeRef: RefObject; - autoScrollJumpRatio: number; - autoScrollBackThreshold: number; - autoScrollForwardThreshold: number; - /** Callbacks from options, stored for SortableContainer to invoke */ - onDragStart?: (event: SortableDragStartEvent) => void; - onDragPositionChange?: (event: SortableDragPositionChangeEvent) => void; - onDragEnd?: (event: SortableDragEndEvent) => void; - onReorder: (event: SortableReorderEvent) => void; - getMeasurementByOriginalIndex: (originalIndex: number) => SortableItemMeasurement | undefined; - /** Position of the drop indicator (animated) */ - dropTargetPositionSV: SharedValue; - /** Whether the drop indicator is visible (animated) */ - dropTargetVisibleSV: SharedValue; - /** Called by SortableItem's onSnapEnd to finalize the drag. - * Stored as a ref so the latest finalizeDrag is always called, - * even if SortableItem has a stale _internal reference. */ - onItemSnapEnd?: () => void; - /** Current display index of the dragged item (updated during live reorder) */ - draggedDisplayIndexRef: RefObject; - /** Original display index where the drag started */ - dragStartIndexRef: RefObject; - /** Per-item shift transforms keyed by item key (UI thread) */ - shiftsRef: SharedValue>; - /** When true, SortableItem clears shift transforms instantly (no animation) */ - instantClearSV: SharedValue; - /** When false, SortableItem ignores shifts entirely (treats as 0). - * Set to false SYNCHRONOUSLY in useLayoutEffect when rawData changes, - * so the animated style reads it in the same UI frame as the Fabric commit. - * This prevents the 1-frame blink where cells show new content but stale shifts. */ - shiftsValidSV: SharedValue; - /** Initialize pending order from current originalIndexes at drag start */ - initPendingOrder: () => void; - /** Store committed visual order after drag (permanent shifts, no data change) */ - commitVisualOrder: () => void; - /** Flush permanent shifts: sync stableData to rawData and clear shifts. - * Restores touch hit testing after permanent shifts. */ - flushVisualOrder: () => void; - /** Compute shifts for a given order. Returns undefined if measurements missing. */ - computeShiftsForOrder: ( - order: number[], - skipIndex?: number, - phantom?: SortablePhantomSlot, - ) => Record | undefined; - /** Committed visual order from last completed drag (indices into rawData) */ - committedOrderRef: RefObject; - /** Pending order ref (indices into rawData) */ - pendingOrderRef: RefObject; - /** Cancel drag without reorder — reverts to committed shifts */ - cancelDrag: () => void; - /** Compute target display index from a container-local content position */ - getSlotFromPosition: (contentPos: Position) => number; - /** Current phantom slot (cross-container drag) */ - phantomRef: RefObject; - /** Reserve space for an incoming item at the given display index */ - setPhantomSlot: (atDisplayIndex: number, width: number, height: number) => void; - /** Remove phantom slot, items shift back */ - clearPhantomSlot: () => void; - /** Remove the dragged item from pending order — items close the gap */ - ejectDraggedItem: () => void; - /** Re-add a previously ejected item into pending order at the given display index */ - reinjectDraggedItem: (displayIndex: number, originalIndex: number) => void; - /** Get snap target position for the current phantom slot */ - getPhantomSnapTarget: () => DraxSnapbackTarget; - /** Off-screen shifts for transferred items (persist across shift recalculations) */ - ghostShiftsRef: RefObject>; - /** Committed shifts from last completed drag (for cancel revert) */ - committedShiftsRef: RefObject>; - /** When true, the next useLayoutEffect RESET skips sync shiftsValidSV=false */ - skipShiftsInvalidationRef: RefObject; -} - -// ─── Board Types (Cross-Container Sortable) ────────────────────────────── - -/** Phantom slot for cross-container drag: virtual space in target column */ -export interface SortablePhantomSlot { - atDisplayIndex: number; - width: number; - height: number; -} - -/** Event data for cross-container item transfer */ -export interface SortableBoardTransferEvent { - item: TItem; - fromContainerId: string; - fromIndex: number; - toContainerId: string; - toIndex: number; -} - -/** Options for useSortableBoard hook */ -export interface UseSortableBoardOptions { - keyExtractor: (item: TItem) => string; - onTransfer: (event: SortableBoardTransferEvent) => void; -} - -/** Handle returned by useSortableBoard — pass to SortableBoardContainer */ -export interface SortableBoardHandle { - _internal: SortableBoardInternal; -} - -/** Transfer state during cross-container drag */ -export interface SortableBoardTransferState { - sourceId: string; - sourceOriginalIndex: number; - itemKey: string; - itemDimensions: ViewDimensions; - dragStartIndex: number; - targetId?: string; - targetSlot?: number; -} - -/** Internal state of the sortable board (not part of public API contract) */ -export interface SortableBoardInternal { - keyExtractor: (item: TItem) => string; - onTransfer: (event: SortableBoardTransferEvent) => void; - columns: Map>; - registerColumn: (id: string, internal: SortableListInternal) => void; - unregisterColumn: (id: string) => void; - transferState: RefObject; - /** Set by SortableBoardContainer — handles cross-container transfer finalization */ - finalizeTransfer?: () => void; -} - -/** Context value for board coordination. - * Uses Pick to avoid generic variance issues — consumers only need - * transferState and finalizeTransfer, not typed item fields. */ -export interface SortableBoardContextValue { - registerColumn: (id: string, internal: SortableListInternal) => void; - unregisterColumn: (id: string) => void; - boardInternal: SortableBoardInternal; -} - -// ─── Utility Types ───────────────────────────────────────────────────────── - - diff --git a/src/types/core.ts b/src/types/core.ts new file mode 100644 index 0000000..ed7f409 --- /dev/null +++ b/src/types/core.ts @@ -0,0 +1,117 @@ +// ─── Core Geometry Types ─────────────────────────────────────────────────── + +/** An xy-coordinate position value */ +export interface Position { + // Index signature required for Reanimated AnimatableValue compatibility + [k: string]: number; + x: number; + y: number; +} + +/** Predicate for checking if something is a Position */ +export const isPosition = (something: unknown): something is Position => + typeof something === 'object' && + something !== null && + 'x' in something && + 'y' in something && + typeof something.x === 'number' && + typeof something.y === 'number'; + +/** Dimensions of a view */ +export interface ViewDimensions { + width: number; + height: number; +} + +/** Grid span for a sortable item (columns and rows it occupies) */ +export interface GridItemSpan { + /** Number of columns this item spans. @default 1 */ + colSpan: number; + /** Number of rows this item spans. @default 1 */ + rowSpan: number; +} + +/** Measurements of a Drax view for bounds checking purposes */ +export interface DraxViewMeasurements extends Position, ViewDimensions { + /** 1 when DraxView auto-detected transform-based positioning + * (e.g., LegendList) and used visual measurement instead of Yoga layout. 0 otherwise. */ + _transformDetected: number; +} + +// ─── Drag Phase & Status Types ───────────────────────────────────────────── + +/** Phase of a drag operation — drives all animated styles */ +export type DragPhase = 'idle' | 'dragging' | 'releasing'; + +/** The states a dragged view can be in */ +export enum DraxViewDragStatus { + Inactive, + Dragging, + Released, +} + +/** The states a receiver view can be in */ +export enum DraxViewReceiveStatus { + Inactive, + Receiving, +} + +// ─── Collision Algorithm ──────────────────────────────────────────────────── + +/** Algorithm used to determine if a dragged view is over a receiver */ +export type CollisionAlgorithm = 'center' | 'intersect' | 'contain'; + +// ─── Spatial Index (SharedValue, UI Thread) ──────────────────────────────── + +/** Entry in the spatial index SharedValue, accessed from worklets for hit-testing */ +export interface SpatialEntry { + /** View unique identifier */ + id: string; + /** Position relative to parent */ + x: number; + y: number; + width: number; + height: number; + /** Index of parent in the spatial index array, -1 if root */ + parentIndex: number; + /** Can this view receive drags? */ + receptive: boolean; + /** Can this view monitor drags? */ + monitoring: boolean; + /** Can this view be dragged? */ + draggable: boolean; + /** If true, this view will not receive drags from its own children */ + rejectOwnChildren: boolean; + /** Collision algorithm for receiving: 'center' (default), 'intersect', or 'contain' */ + collisionAlgorithm: CollisionAlgorithm; +} + +/** Result of a UI-thread hit test */ +export interface HitTestResult { + receiverId: string; + monitorIds: string[]; +} + +// ─── External Registration ───────────────────────────────────────────────── + +/** Methods provided by a DraxView when registered externally */ +export interface DraxViewRegistration { + id: string; + measure: (measurementHandler?: DraxViewMeasurementHandler) => void; +} + +/** Information about the parent of a nested DraxView */ +export interface DraxParentView { + id: string; + /** Any ref-like object with a .current holding a native view instance. + * Accepts both React.RefObject and Reanimated.AnimatedRef. */ + viewRef: { current: any }; + /** When true, measureLayout returns content-relative positions on native + * (scroll offset should NOT be added). */ + isScrollContainer?: boolean; +} + +/** Function that receives a Drax view measurement */ +export interface DraxViewMeasurementHandler { + (measurements: DraxViewMeasurements | undefined): void; +} diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..51e4c72 --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,106 @@ +import type { SharedValue } from 'react-native-reanimated'; + +import type { DraxViewMeasurements, Position } from './core'; + +// ─── Event Data Types (Public API) ───────────────────────────────────────── + +/** Data about a view involved in a Drax event */ +export interface DraxEventViewData { + id: string; + parentId?: string; + payload: unknown; + measurements?: DraxViewMeasurements; +} + +/** Data about a dragged view involved in a Drax event */ +export interface DraxEventDraggedViewData extends DraxEventViewData { + dragTranslationRatio: Position; + dragOffset: Position; + grabOffset: Position; + grabOffsetRatio: Position; + hoverPosition: Position; +} + +/** Data about a receiver view involved in a Drax event */ +export interface DraxEventReceiverViewData extends DraxEventViewData { + receiveOffset: Position; + receiveOffsetRatio: Position; +} + +/** Data about a Drax drag event */ +export interface DraxDragEventData { + dragAbsolutePosition: Position; + dragTranslation: Position; + dragged: DraxEventDraggedViewData; +} + +/** Supplemental type for adding a cancelled flag */ +export interface WithCancelledFlag { + cancelled: boolean; +} + +/** Predicate for checking if something has a cancelled flag */ +export const isWithCancelledFlag = ( + something: unknown +): something is WithCancelledFlag => + typeof something === 'object' && + something !== null && + 'cancelled' in something && + typeof something.cancelled === 'boolean'; + +/** Data about a Drax drag end event */ +export interface DraxDragEndEventData + extends DraxDragEventData, WithCancelledFlag {} + +/** Data about a Drax drag event that involves a receiver */ +export interface DraxDragWithReceiverEventData extends DraxDragEventData { + receiver: DraxEventReceiverViewData; +} + +/** Data about a Drax drag/receive end event */ +export interface DraxDragWithReceiverEndEventData + extends DraxDragWithReceiverEventData, WithCancelledFlag {} + +/** Data about a Drax monitor event */ +export interface DraxMonitorEventData extends DraxDragEventData { + receiver?: DraxEventReceiverViewData; + monitorOffset: Position; + monitorOffsetRatio: Position; +} + +/** Data about a Drax monitor drag end event */ +export interface DraxMonitorEndEventData + extends DraxMonitorEventData, WithCancelledFlag {} + +/** Data about a Drax monitor drag-drop event */ +export interface DraxMonitorDragDropEventData extends Required {} + +// ─── Snap Types ──────────────────────────────────────────────────────────── + +/** Preset values for specifying snap targets without a Position */ +export enum DraxSnapbackTargetPreset { + Default, + None, +} + +/** Target for snap hover view release animation: none, default, or specified Position */ +export type DraxSnapbackTarget = DraxSnapbackTargetPreset | Position; + +/** Response type for drag end callbacks, allowing override of default release snap behavior */ +export type DraxProtocolDragEndResponse = void | DraxSnapbackTarget; + +/** Data about a Drax snap, used for custom animations */ +export interface DraxSnapData { + hoverPosition: SharedValue; + toValue: Position; + delay: number; + duration: number; + scrollPosition?: SharedValue; + finishedCallback: (finished: boolean) => void; +} + +/** Data passed to onSnapEnd and onReceiveSnapEnd callbacks */ +export interface DraxSnapEndEventData { + dragged: { id: string; parentId?: string; payload: unknown }; + receiver?: { id: string; parentId?: string; payload: unknown }; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..6b8a0bb --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,74 @@ +// ─── Core ──────────────────────────────────────────────────────────────── +export type { + Position, + ViewDimensions, + GridItemSpan, + DraxViewMeasurements, + DragPhase, + CollisionAlgorithm, + SpatialEntry, + HitTestResult, + DraxViewRegistration, + DraxParentView, + DraxViewMeasurementHandler, +} from './core'; +export { isPosition, DraxViewDragStatus, DraxViewReceiveStatus } from './core'; + +// ─── Events & Snap ─────────────────────────────────────────────────────── +export type { + DraxEventViewData, + DraxEventDraggedViewData, + DraxEventReceiverViewData, + DraxDragEventData, + WithCancelledFlag, + DraxDragEndEventData, + DraxDragWithReceiverEventData, + DraxDragWithReceiverEndEventData, + DraxMonitorEventData, + DraxMonitorEndEventData, + DraxMonitorDragDropEventData, + DraxSnapbackTarget, + DraxProtocolDragEndResponse, + DraxSnapData, + DraxSnapEndEventData, +} from './events'; +export { isWithCancelledFlag, DraxSnapbackTargetPreset } from './events'; + +// ─── View ──────────────────────────────────────────────────────────────── +export type { + DraxViewState, + DraxTrackingStatus, + DraxRenderContentProps, + DraxRenderHoverContentProps, + DraxStyleProp, + AnimatedViewStylePropWithoutLayout, + DraxViewStyleProps, + DraxViewRenderContent, + DraxViewRenderHoverContent, + DraxViewProps, +} from './view'; + +// ─── Provider & Context ────────────────────────────────────────────────── +export type { + FlattenedHoverStyles, + ViewRegistryEntry, + DraxContextValue, + RegisterViewPayload, + DraxProviderDragEvent, + DraxProviderProps, + DraxSubproviderProps, + AutoScrollState, + DraxAutoScrollProps, + DraxScrollViewProps, +} from './provider'; +export { AutoScrollDirection } from './provider'; + +// ─── Sortable ──────────────────────────────────────────────────────────── +export type { + SortableReorderStrategy, + SortableAnimationPreset, + SortableAnimationCustomConfig, + SortableAnimationConfig, + SortableItemPayload, +} from './sortable'; +export { isSortableItemPayload } from './sortable'; diff --git a/src/types/provider.ts b/src/types/provider.ts new file mode 100644 index 0000000..64ee49e --- /dev/null +++ b/src/types/provider.ts @@ -0,0 +1,190 @@ +import type { ReactNode, RefObject } from 'react'; +import type { ScrollViewProps, StyleProp, ViewStyle } from 'react-native'; +import type { HostInstance } from 'react-native'; +import type { SharedValue } from 'react-native-reanimated'; + +import type { + DragPhase, + DraxParentView, + DraxViewMeasurements, + Position, + SpatialEntry, +} from './core'; +import type { DraxViewProps } from './view'; + +// ─── View Registry (JS Thread) ───────────────────────────────────────────── + +/** Flattened hover styles for the currently dragged view */ +export interface FlattenedHoverStyles { + hoverStyle: ViewStyle | null; + hoverDraggingStyle: ViewStyle | null; + hoverDraggingWithReceiverStyle: ViewStyle | null; + hoverDraggingWithoutReceiverStyle: ViewStyle | null; + hoverDragReleasedStyle: ViewStyle | null; +} + +/** Entry in the JS-thread view registry Map */ +export interface ViewRegistryEntry { + id: string; + parentId?: string; + /** Index in the spatialIndexSV array */ + spatialIndex: number; + /** Scroll position SharedValue, for scrollable parent views */ + scrollPosition?: SharedValue; + /** Current measurements */ + measurements?: DraxViewMeasurements; + /** All props from DraxView (callbacks, styles, payload, etc.) */ + props: DraxViewProps; + /** Pre-flattened hover styles — computed at registration/prop-update time + * to avoid 5 StyleSheet.flatten calls in the drag-start hot path. */ + flattenedHoverStyles?: FlattenedHoverStyles; +} + +// ─── Context Value ───────────────────────────────────────────────────────── + +/** Context value used internally by Drax provider */ +export interface DraxContextValue { + // ── Split SharedValues (by update frequency) ─────────────────────── + /** Changes ~2x per drag. Read by all DraxView useAnimatedStyle. */ + draggedIdSV: SharedValue; + /** Changes ~3-5x per drag. Read by all DraxView useAnimatedStyle. */ + receiverIdSV: SharedValue; + /** Changes ~3x per drag. Read by all DraxView useAnimatedStyle. */ + dragPhaseSV: SharedValue; + /** Changes every frame during drag. Read ONLY by HoverLayer. */ + hoverPositionSV: SharedValue; + /** Changes every frame during drag. Used by gesture worklet for hit-testing. */ + dragAbsolutePositionSV: SharedValue; + /** ID of the most recently rejected receiver (cleared when drag leaves its bounds). + * Read by gesture worklet to skip re-detecting the same rejected receiver. */ + rejectedReceiverIdSV: SharedValue; + /** Changes on view mount/layout. Read by gesture worklet for hit-testing. */ + spatialIndexSV: SharedValue; + /** Changes during scroll. Indexed parallel to spatialIndex. */ + scrollOffsetsSV: SharedValue; + /** Set once on drag start. */ + grabOffsetSV: SharedValue; + /** Absolute position where drag started. */ + startPositionSV: SharedValue; + /** Screen offset of the DraxProvider root view (for coordinate conversion). */ + rootOffsetSV: SharedValue; + /** True after hover content is committed to DOM (set in HoverLayer useLayoutEffect). + * False after snap completes. Used by SortableItem for blink-free visibility. */ + hoverReadySV: SharedValue; + /** Set to true by SortableContainer.finalizeDrag when a reorder commit is in-flight. + * Checked by onSnapComplete to skip immediate hover clearing — the clearing is + * deferred to useSortableList's useLayoutEffect (after FlatList re-render). */ + hoverClearDeferredRef: { current: boolean }; + /** Animated hover content dimensions for cross-container transfer. + * x = width, y = height. {0,0} = no constraint (natural size). */ + hoverDimsSV: SharedValue; + /** Drag lock — false during snap animation. Blocks new gesture activation on UI thread. */ + isDragAllowedSV: SharedValue; + + // ── Registry methods (JS thread) ─────────────────────────────────── + registerView: (payload: RegisterViewPayload) => void; + unregisterView: (id: string) => void; + updateMeasurements: (id: string, measurements: DraxViewMeasurements) => void; + updateScrollOffset: (id: string, offset: Position) => void; + updateViewProps: (id: string, props: DraxViewProps) => void; + getViewEntry: (id: string) => ViewRegistryEntry | undefined; + + // ── Callback dispatch (JS thread, called via scheduleOnRN from gesture) ─ + handleDragStart: ( + draggedId: string, + absolutePosition: Position, + grabOffset: Position + ) => void; + handleReceiverChange: ( + oldReceiverId: string, + newReceiverId: string, + absolutePosition: Position, + draggedId: string, + startPosition: Position, + grabOffset: Position, + monitorIds?: string[] + ) => void; + handleDragEnd: ( + draggedId: string, + receiverId: string, + cancelled: boolean, + finalMonitorIds?: string[] + ) => void; + + // ── Hover content ────────────────────────────────────────────────── + setHoverContent: (content: ReactNode | null) => void; + + // ── Dropped items tracking ───────────────────────────────────────── + /** Map of receiverId → Set of draggedIds that have been dropped on it */ + droppedItemsRef: RefObject>>; + + // ── View refs ────────────────────────────────────────────────────── + rootViewRef: { current: HostInstance | null }; + parent?: DraxParentView; +} + +/** Payload for registering a Drax view */ +export interface RegisterViewPayload { + id: string; + parentId?: string; + scrollPosition?: SharedValue; + props: DraxViewProps; +} + +// ─── Provider / Subprovider Props ────────────────────────────────────────── + +/** Event data for provider-level drag callbacks */ +export interface DraxProviderDragEvent { + draggedId: string; + receiverId?: string; + position: Position; +} + +/** Optional props that can be passed to a DraxProvider */ +export interface DraxProviderProps { + style?: StyleProp; + debug?: boolean; + /** Called when any drag starts */ + onDragStart?: (event: DraxProviderDragEvent) => void; + /** Called on every gesture update during any drag */ + onDrag?: (event: DraxProviderDragEvent) => void; + /** Called when any drag ends (drop or cancel) */ + onDragEnd?: (event: DraxProviderDragEvent & { cancelled: boolean }) => void; + children?: ReactNode; +} + +/** Props that are passed to a DraxSubprovider */ +export interface DraxSubproviderProps { + parent: DraxParentView; +} + +// ─── Auto-scroll Types ───────────────────────────────────────────────────── + +/** Auto-scroll direction used internally by DraxScrollView and DraxList */ +export enum AutoScrollDirection { + Back = -1, + None = 0, + Forward = 1, +} + +/** Auto-scroll state used internally by DraxScrollView */ +export interface AutoScrollState { + x: AutoScrollDirection; + y: AutoScrollDirection; +} + +/** Props for auto-scroll options */ +export interface DraxAutoScrollProps { + autoScrollIntervalLength?: number; + autoScrollJumpRatio?: number; + autoScrollBackThreshold?: number; + autoScrollForwardThreshold?: number; +} + +// ─── ScrollView Props ────────────────────────────────────────────────────── + +/** Props for a DraxScrollView */ +export interface DraxScrollViewProps + extends ScrollViewProps, DraxAutoScrollProps { + id?: string; +} diff --git a/src/types/sortable.ts b/src/types/sortable.ts new file mode 100644 index 0000000..5ae2476 --- /dev/null +++ b/src/types/sortable.ts @@ -0,0 +1,44 @@ +// ─── Sortable Types (List-Agnostic) ───────────────────────────────────────── + +/** Reorder strategy for sortable lists */ +export type SortableReorderStrategy = 'insert' | 'swap'; + +/** Named animation preset for sortable item shift animations */ +export type SortableAnimationPreset = 'default' | 'spring' | 'gentle' | 'snappy' | 'none'; + +/** Custom animation configuration for sortable item shifts */ +export interface SortableAnimationCustomConfig { + /** Duration in ms for timing-based animations. Ignored when useSpring is true. @default 200 */ + shiftDuration?: number; + /** Use spring physics instead of timing. @default false */ + useSpring?: boolean; + /** Spring damping. @default 15 */ + springDamping?: number; + /** Spring stiffness. @default 150 */ + springStiffness?: number; + /** Spring mass. @default 1 */ + springMass?: number; +} + +/** Animation configuration: a preset name or custom config object */ +export type SortableAnimationConfig = SortableAnimationPreset | SortableAnimationCustomConfig; + +// ─── Sortable Item Types ─────────────────────────────────────────────────── + +/** Internal payload attached to each SortableItem's DraxView */ +export interface SortableItemPayload { + index: number; + originalIndex: number; +} + +/** Type guard for SortableItemPayload */ +export function isSortableItemPayload(value: unknown): value is SortableItemPayload { + return ( + typeof value === 'object' && + value !== null && + 'index' in value && + 'originalIndex' in value && + typeof value.index === 'number' && + typeof value.originalIndex === 'number' + ); +} diff --git a/src/types/view.ts b/src/types/view.ts new file mode 100644 index 0000000..c75fbdf --- /dev/null +++ b/src/types/view.ts @@ -0,0 +1,245 @@ +import type { ReactNode, RefObject } from 'react'; +import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import type { AnimatedStyle, SharedValue } from 'react-native-reanimated'; + +import type { + CollisionAlgorithm, + DraxViewMeasurementHandler, + DraxViewMeasurements, + DraxViewRegistration, + DraxParentView, + Position, + ViewDimensions, +} from './core'; +import type { DraxViewDragStatus, DraxViewReceiveStatus } from './core'; +import type { + DraxDragEndEventData, + DraxDragEventData, + DraxDragWithReceiverEndEventData, + DraxDragWithReceiverEventData, + DraxEventViewData, + DraxMonitorDragDropEventData, + DraxMonitorEndEventData, + DraxMonitorEventData, + DraxProtocolDragEndResponse, + DraxSnapData, + DraxSnapEndEventData, +} from './events'; + +// ─── Render Content Props ────────────────────────────────────────────────── + +/** Simplified view state for render content props */ +export interface DraxViewState { + dragStatus: DraxViewDragStatus; + receiveStatus: DraxViewReceiveStatus; + dragAbsolutePosition?: Position; + dragTranslation?: Position; + dragTranslationRatio?: Position; + dragOffset?: Position; + grabOffset?: Position; + grabOffsetRatio?: Position; + draggingOverReceiver?: DraxEventViewData; + receiveOffset?: Position; + receiveOffsetRatio?: Position; + receivingDrag?: DraxEventViewData; +} + +/** Tracking status indicating whether anything is being dragged/received */ +export interface DraxTrackingStatus { + dragging: boolean; + receiving: boolean; +} + +/** Props provided to a render function for a Drax view */ +export interface DraxRenderContentProps { + viewState?: DraxViewState; + trackingStatus?: DraxTrackingStatus; + hover: boolean; + children: ReactNode; + dimensions?: ViewDimensions; +} + +/** Props provided to a render function for a hovering copy of a Drax view */ +export interface DraxRenderHoverContentProps extends DraxRenderContentProps {} + +// ─── Style Types ─────────────────────────────────────────────────────────── + +/** Style prop for DraxView drag/receive states (flattened for worklets) */ +export type DraxStyleProp = StyleProp; + +/** Style prop for hover views (supports animated styles) */ +export type AnimatedViewStylePropWithoutLayout = + | StyleProp + | StyleProp>>; + +/** Style-related props for a Drax view */ +export interface DraxViewStyleProps { + style?: DraxStyleProp; + dragInactiveStyle?: DraxStyleProp; + draggingStyle?: DraxStyleProp; + draggingWithReceiverStyle?: DraxStyleProp; + draggingWithoutReceiverStyle?: DraxStyleProp; + dragReleasedStyle?: DraxStyleProp; + hoverStyle?: AnimatedViewStylePropWithoutLayout; + hoverDraggingStyle?: AnimatedViewStylePropWithoutLayout; + hoverDraggingWithReceiverStyle?: AnimatedViewStylePropWithoutLayout; + hoverDraggingWithoutReceiverStyle?: AnimatedViewStylePropWithoutLayout; + hoverDragReleasedStyle?: AnimatedViewStylePropWithoutLayout; + receiverInactiveStyle?: DraxStyleProp; + receivingStyle?: DraxStyleProp; + otherDraggingStyle?: DraxStyleProp; + otherDraggingWithReceiverStyle?: DraxStyleProp; + otherDraggingWithoutReceiverStyle?: DraxStyleProp; +} + +// ─── Custom render functions ─────────────────────────────────────────────── + +/** Custom render function for content of a DraxView */ +export interface DraxViewRenderContent { + (props: DraxRenderContentProps): ReactNode; +} + +/** Custom render function for content of hovering copy of a DraxView */ +export interface DraxViewRenderHoverContent { + (props: DraxRenderHoverContentProps): ReactNode; +} + +// ─── View Props ──────────────────────────────────────────────────────────── + +/** Props for a DraxView */ +export interface DraxViewProps + extends Omit, DraxViewStyleProps { + /** Custom render function for content of this view */ + renderContent?: DraxViewRenderContent; + /** Custom render function for content of hovering copy of this view, defaults to renderContent */ + renderHoverContent?: DraxViewRenderHoverContent; + /** If true, do not render hover view copies for this view when dragging */ + noHover?: boolean; + /** For external registration of this view, to access internal methods */ + registration?: (registration: DraxViewRegistration | undefined) => void; + /** For receiving view measurements externally */ + onMeasure?: DraxViewMeasurementHandler; + /** Unique Drax view id, auto-generated if omitted */ + id?: string; + /** Drax parent view, if nesting */ + parent?: DraxParentView; + /** If true, treat this view as a Drax parent view for nested children */ + isParent?: boolean; + /** Used internally - The view's scroll position, if it is a scrollable parent view */ + scrollPosition?: SharedValue; + /** Time in milliseconds view needs to be pressed before drag starts */ + longPressDelay?: number; + /** Cancel drag activation if finger moves more than this distance (px). + * Prevents accidental drags when the user is trying to scroll. + * Can be a number (symmetric) or [min, max] tuple per axis. */ + dragActivationFailOffset?: number; + + /** Hint that this view is inside a horizontal scroll container. + * On mobile web, sets `touch-action: pan-x` so the browser allows + * native horizontal scrolling before the long-press activates drag. + * Without this, items in horizontal lists default to `pan-y` which + * blocks horizontal scrolling on touch devices. */ + scrollHorizontal?: boolean; + + // ─── Callback props (formerly in DraxProtocol) ───────────────────── + + /** A function that can be used to conditionally enable or disable receiving */ + dynamicReceptiveCallback?: (data: { + targetId: string; + targetMeasurements: DraxViewMeasurements; + draggedId: string; + draggedPayload: unknown; + }) => boolean; + + /** Simpler convenience prop for conditional drop acceptance based on payload */ + acceptsDrag?: (draggedPayload: unknown) => boolean; + /** Maximum number of items this view can receive. Drops are auto-rejected + * when at capacity. Requires DraxProvider to track dropped items centrally. */ + capacity?: number; + + /** Called in the dragged view when a drag action begins */ + onDragStart?: (data: DraxDragEventData) => void; + /** Called in the dragged view repeatedly while dragged, not over any receiver */ + onDrag?: (data: DraxDragEventData) => void; + /** Called in the dragged view when initially dragged over a new receiver */ + onDragEnter?: (data: DraxDragWithReceiverEventData) => void; + /** Called in the dragged view repeatedly while dragged over a receiver */ + onDragOver?: (data: DraxDragWithReceiverEventData) => void; + /** Called in the dragged view when dragged off of a receiver */ + onDragExit?: (data: DraxDragWithReceiverEventData) => void; + /** Called in the dragged view when drag ends not over any receiver or is cancelled */ + onDragEnd?: (data: DraxDragEndEventData) => DraxProtocolDragEndResponse; + /** Called in the dragged view when drag ends over a receiver */ + onDragDrop?: ( + data: DraxDragWithReceiverEventData + ) => DraxProtocolDragEndResponse; + /** Called in the dragged view when drag release snap ends */ + onSnapEnd?: (data: DraxSnapEndEventData) => void; + /** Called in the receiver view when drag release snap ends */ + onReceiveSnapEnd?: (data: DraxSnapEndEventData) => void; + /** Called in the receiver view each time an item is initially dragged over it */ + onReceiveDragEnter?: (data: DraxDragWithReceiverEventData) => void; + /** Called in the receiver view repeatedly while an item is dragged over it */ + onReceiveDragOver?: (data: DraxDragWithReceiverEventData) => void; + /** Called in the receiver view when item is dragged off of it or drag is cancelled */ + onReceiveDragExit?: (data: DraxDragWithReceiverEndEventData) => void; + /** Called in the receiver view when drag ends over it */ + onReceiveDragDrop?: ( + data: DraxDragWithReceiverEventData + ) => DraxProtocolDragEndResponse; + /** Called in the monitor view when a drag action begins over it */ + onMonitorDragStart?: (data: DraxMonitorEventData) => void; + /** Called in the monitor view each time an item is initially dragged over it */ + onMonitorDragEnter?: (data: DraxMonitorEventData) => void; + /** Called in the monitor view repeatedly while an item is dragged over it */ + onMonitorDragOver?: (data: DraxMonitorEventData) => void; + /** Called in the monitor view when item is dragged off of it */ + onMonitorDragExit?: (data: DraxMonitorEventData) => void; + /** Called in the monitor view when drag ends over it while not over any receiver or drag is cancelled */ + onMonitorDragEnd?: ( + data: DraxMonitorEndEventData + ) => DraxProtocolDragEndResponse; + /** Called in the monitor view when drag ends over it while over a receiver */ + onMonitorDragDrop?: ( + data: DraxMonitorDragDropEventData + ) => DraxProtocolDragEndResponse; + + /** Whether or not to animate hover view snap after drag release, defaults to true */ + animateSnap?: boolean; + /** Delay in ms before hover view snap begins after drag is released */ + snapDelay?: number; + /** Duration in ms for hover view snap to complete */ + snapDuration?: number; + /** Function returning custom hover view snap animation */ + snapAnimator?: (data: DraxSnapData) => void; + + /** Payload that will be delivered to receiver views when this view is dragged; overrides `payload` */ + dragPayload?: unknown; + /** Payload that will be delivered to dragged views when this view receives them; overrides `payload` */ + receiverPayload?: unknown; + /** Convenience prop to provide one value for both `dragPayload` and `receiverPayload` */ + payload?: unknown; + + /** Whether the view can be dragged */ + draggable?: boolean; + /** Whether the view can receive drags */ + receptive?: boolean; + /** Whether the view can monitor drags */ + monitoring?: boolean; + /** If true, this view will not receive drags from its own children */ + rejectOwnChildren?: boolean; + /** @deprecated No longer needed — hover measurements are handled automatically */ + disableHoverViewMeasurementsOnLayout?: boolean; + /** If true, lock drag's x-position */ + lockDragXPosition?: boolean; + /** If true, lock drag's y position */ + lockDragYPosition?: boolean; + /** When true, drag is only activated via a descendant DraxHandle component */ + dragHandle?: boolean; + /** Internal: worklet config for UI-thread slot detection (set by DraxList) */ + sortableWorklet?: unknown; + /** Collision algorithm for receiving drags: 'center' (default), 'intersect', or 'contain' */ + collisionAlgorithm?: CollisionAlgorithm; + /** Ref to a View that constrains the drag area. The dragged view will be clamped within these bounds. */ + dragBoundsRef?: RefObject; +} From 3855ac7770eaa3b4917954d40b749438eaee9f83 Mon Sep 17 00:00:00 2001 From: Ovidiu Cristescu <55203625+LunatiqueCoder@users.noreply.github.com> Date: Sat, 28 Mar 2026 22:04:44 +0200 Subject: [PATCH 12/12] perf: eliminate cascading re-renders with SV-based cell positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route cell positions through SharedValues instead of React props. Cells use translateX/Y (stacked transforms) for zero Yoga relayout. Per-cell subscription via CellBindingStore + useSyncExternalStore ensures only recycled cells re-render. - RecycledCell: left:0/top:0 + stacked translateX/Y from basePositionSV + shiftSV - CellBindingStore: per-cell subscription, positions excluded from binding data - _contentPosition on DraxView: bypasses stale view.measure() for list cells - Hover render: direct JS→JS forceRender (no SV→UI→JS bounce) - cumulativeEndsSV: O(log N) binary search for single-column slot detection - Binding refresh after commitReorder: fixes stale dataIndex after reorder Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DraxHandle.tsx | 27 +- src/DraxList.tsx | 496 +++++++++++++++++++----------- src/DraxProvider.tsx | 26 +- src/DraxView.tsx | 41 ++- src/HoverLayer.tsx | 32 +- src/RecycledCell.tsx | 87 ++++-- src/SortableBoardContainer.tsx | 5 + src/hooks/useCallbackDispatch.tsx | 3 +- src/hooks/useDragGesture.ts | 34 +- src/hooks/useSortableList.ts | 138 ++++++++- src/types/view.ts | 4 + 11 files changed, 604 insertions(+), 289 deletions(-) diff --git a/src/DraxHandle.tsx b/src/DraxHandle.tsx index c8cb1f1..ea431c7 100644 --- a/src/DraxHandle.tsx +++ b/src/DraxHandle.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import { use, useRef } from 'react'; +import { use, useLayoutEffect, useRef } from 'react'; import type { StyleProp, ViewStyle } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import Reanimated from 'react-native-reanimated'; @@ -15,15 +15,10 @@ export function DraxHandle({ children, style }: DraxHandleProps) { const ctx = use(DraxHandleContext); const handleRef = useRef(null); - if (!ctx) { - return ( - - {children} - - ); - } - - const measureOffset = () => { + // New Architecture: useLayoutEffect + measureLayout runs synchronously before paint. + // Replaces onLayout callback for handle offset measurement. + useLayoutEffect(() => { + if (!ctx) return; const handle = handleRef.current; const parent = ctx.parentViewRef.current; if (!handle || !parent) return; @@ -34,11 +29,19 @@ export function DraxHandle({ children, style }: DraxHandleProps) { } catch { // measureLayout can fail if views aren't mounted yet } - }; + }); + + if (!ctx) { + return ( + + {children} + + ); + } return ( - + {children} diff --git a/src/DraxList.tsx b/src/DraxList.tsx index f69d4fa..5e56ad7 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -7,32 +7,32 @@ * * No FlatList. No Fabric/Reanimated race. No blink. */ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { memo, + startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, + useSyncExternalStore, } from 'react'; import type { - LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, } from 'react-native'; import { - ScrollView, StyleSheet, View, useWindowDimensions, } from 'react-native'; import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import Reanimated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; +import Reanimated, { useAnimatedRef, useAnimatedStyle, useScrollViewOffset, useSharedValue, withSpring } from 'react-native-reanimated'; import { DraxView } from './DraxView'; import { RecycledCell } from './RecycledCell'; import { useSortableBoardContext } from './SortableBoardContext'; @@ -150,10 +150,45 @@ export interface DraxListProps { ListLoadingComponent?: ReactNode; } -/** Cell binding: which cell shows which item (dataIndex looked up at render time) */ -interface CellBinding { - cellKey: string; +/** Cell binding data — content identity only. Positions flow through SharedValues. */ +interface CellBindingData { itemKey: string; + cellWidth: number | undefined; + cellHeight: number | undefined; + dataIndex: number; +} + +/** Per-cell subscription store. Only notifies a cell's subscriber when its binding actually changes. */ +class CellBindingStore { + bindings = new Map(); + private subscribers = new Map void>>(); + + subscribe(cellKey: string, cb: () => void): () => void { + let set = this.subscribers.get(cellKey); + if (!set) { set = new Set(); this.subscribers.set(cellKey, set); } + set.add(cb); + return () => { set.delete(cb); }; + } + + getBinding(cellKey: string): CellBindingData | undefined { + return this.bindings.get(cellKey); + } + + setBinding(cellKey: string, data: CellBindingData): void { + const prev = this.bindings.get(cellKey); + if (prev && prev.itemKey === data.itemKey && prev.dataIndex === data.dataIndex + && prev.cellWidth === data.cellWidth && prev.cellHeight === data.cellHeight) { + return; + } + this.bindings.set(cellKey, data); + this.subscribers.get(cellKey)?.forEach(fn => fn()); + } + + clearBinding(cellKey: string): void { + if (!this.bindings.has(cellKey)) return; + this.bindings.delete(cellKey); + this.subscribers.get(cellKey)?.forEach(fn => fn()); + } } // ─── Measured Content (single View replacing the old 2-View wrapper) ── @@ -208,6 +243,108 @@ const MeasuredContent = memo(({ MeasuredContent.displayName = 'MeasuredContent'; +// ─── Stable props ref for CellSlot (avoids re-renders from prop identity changes) ── + +interface CellSlotStableProps { + dataRef: RefObject; + renderItemRef: RefObject<(info: { item: unknown; index: number }) => ReactNode>; + draggedKeySV: import('react-native-reanimated').SharedValue; + hoverReadySV: import('react-native-reanimated').SharedValue; + skipShiftAnimationSV: import('react-native-reanimated').SharedValue; + springConfig: { damping: number; stiffness: number; mass: number } | null; + shiftDuration: number; + inactiveItemStyle?: Record; + registerCellBase: (key: string, sv: import('react-native-reanimated').SharedValue) => void; + unregisterCellBase: (key: string) => void; + registerCellShift: (key: string, sv: import('react-native-reanimated').SharedValue) => void; + unregisterCellShift: (key: string) => void; + dragHandle?: boolean; + longPressDelay: number; + itemDraxViewProps?: Record; + lockDragX: boolean; + lockDragY: boolean; + handleSnapEnd: (data: DraxSnapEndEventData) => void; + sortableWorkletConfig: unknown; + fillStyle: { flex: number } | undefined; + horizontal: boolean; + skipMeasurement: boolean; + itemAlignSelf: string; + handleItemLayout: (itemKey: string, height: number) => void; + cellLastHeightRef: RefObject>; + basePositionsRef: RefObject>; +} + +// Debug: count CellSlot renders per second +/** Per-cell component with independent subscription. Only re-renders when its binding changes. */ +const CellSlot = memo(function CellSlot({ + cellKey, + store, + sp, +}: { + cellKey: string; + store: CellBindingStore; + sp: RefObject; +}) { + const subscribe = useCallback((cb: () => void) => store.subscribe(cellKey, cb), [store, cellKey]); + const getSnapshot = useCallback(() => store.getBinding(cellKey), [store, cellKey]); + const binding = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + + if (!binding) return null; + + const { itemKey, cellWidth, cellHeight, dataIndex } = binding; + const p = sp.current; + const item = (p.dataRef.current as unknown[])[dataIndex]; + if (!item) return null; + const basePos = p.basePositionsRef.current.get(itemKey); + + return ( + + )} + lockDragXPosition={p.lockDragX} + lockDragYPosition={p.lockDragY} + payload={{ index: dataIndex, originalIndex: dataIndex }} + onSnapEnd={p.handleSnapEnd} + sortableWorklet={p.sortableWorkletConfig} + style={p.fillStyle} + _contentPosition={basePos} + > + + {p.renderItemRef.current({ item, index: dataIndex })} + + + + ); +}); + // ─── Component ──────────────────────────────────────────────────────── export const DraxList = (props: DraxListProps) => { @@ -253,11 +390,20 @@ export const DraxList = (props: DraxListProps) => { const viewportSize = horizontal ? screenWidth : screenHeight; const drawDistance = drawDistanceProp ?? viewportSize * 3; - const scrollRef = useRef(null); + const scrollAnimatedRef = useAnimatedRef(); - // ── Single re-render trigger (the ONLY thing that causes re-render) ── + // ── Re-render trigger — ONLY used for cell pool growth (adding new CellSlot elements) ── + // Normal scroll/data updates go through the CellBindingStore → per-cell useSyncExternalStore. const [, forceRender] = useReducer((x: number) => x + 1, 0); + // ── Per-cell subscription store ── + const cellStoreRef = useRef(new CellBindingStore()); + // Active cell keys — grows on demand, never shrinks during session. + // forceRender is called when this grows (to add CellSlot elements to the tree). + const activeCellKeysRef = useRef([]); + // Stable ref for props passed to CellSlot (avoids memo-busting from new object identity) + const stablePropsRef = useRef(null!); + // ── Core hook (all refs + SharedValues, no state) ── const sortable = useSortableList({ id: _idProp, @@ -280,9 +426,12 @@ export const DraxList = (props: DraxListProps) => { const int = sortable._internal; - // Register renderItem + forceRender so board can trigger re-renders on any column + // UI-thread scroll offset tracking via Reanimated (no JS onScroll needed). + // Passes int.scrollOffsetSV as the target SV — Reanimated writes directly to it. + useScrollViewOffset(scrollAnimatedRef, int.scrollOffsetSV); + + // Register renderItem so board can access it int.renderItemRef.current = renderItem as (info: any) => ReactNode; - int.forceRenderRef.current = forceRender; const resolvedAnimConfig = useMemo( () => resolveAnimationConfig(animationConfig), @@ -334,6 +483,7 @@ export const DraxList = (props: DraxListProps) => { isDraggingSV: int.isDraggingSV, containerMeasSV: int.containerMeasSV, cellShiftRecordSV: int.cellShiftRecordSV, + cumulativeEndsSV: int.cumulativeEndsSV, draggedKeySV: int.draggedKeySV, dropIndicatorPositionSV: int.dropIndicatorPositionSV, scrollOffsetSV: int.scrollOffsetSV, @@ -349,74 +499,88 @@ export const DraxList = (props: DraxListProps) => { ); // ── Cell pool (refs only) ── - const cellBindingsRef = useRef([]); const freeCellsRef = useRef([]); const cellLastHeightRef = useRef>(new Map()); // cellKey → last measured height const bindingMapRef = useRef>(new Map()); // itemKey → cellKey const visibleKeysRef = useRef(new Set()); // Reused across scroll ticks (no allocation) const nextCellIdRef = useRef(0); + /** Compute binding data for a single item (content identity + dimensions, NO position). */ + const computeCellBindingData = useCallback( + (itemKey: string): CellBindingData | null => { + const dataIndex = int.keyToIndexRef.current.get(itemKey); + if (dataIndex === undefined) return null; + const dims = int.itemDimensionsRef.current.get(itemKey); + const cw = int.containerWidthRef.current; + const cwg = cw > 0 ? (numColumns > 1 ? cw / numColumns : cw) : undefined; + const cellWidth = flexWrap ? dims?.width : horizontal ? undefined : (dims?.width ?? cwg); + const cellHeight = flexWrap ? dims?.height : horizontal ? cwg : getItemSpan ? dims?.height : undefined; + return { itemKey, cellWidth, cellHeight, dataIndex }; + }, + [int, flexWrap, horizontal, numColumns, getItemSpan] + ); + const lastIndicatorSlotRef = useRef(-1); // Track indicator's last-set slot (avoids worklet/JS SV race) const itemsMeasuredRef = useRef(false); // True after first item measurement cycle — prevents FOUC - // ── Scroll perf refs ── - const lastProcessedOffsetRef = useRef(0); // For scroll delta threshold (only updates on threshold crossings) - const lastScrollOffsetRef = useRef(0); // Actual last scroll offset (for velocity — updates every event) - const lastScrollTimeRef = useRef(0); // For velocity tracking - const scrollVelocityRef = useRef(0); // px/ms — positive = scrolling forward - // ── Container layout ── - const handleContainerLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width, height, x, y } = event.nativeEvent.layout; - const cw = horizontal ? height : width; - int.containerWidthRef.current = cw; - // ScrollView's offset within the monitoring DraxView (accounts for padding) - int.scrollContainerOffsetRef.current = { x, y }; - int.recomputeBasePositionsAndClearShifts(); - int.syncPositionsToWorklet(); - // Reset scroll delta tracking (positions just changed) - lastProcessedOffsetRef.current = int.scrollOffsetSV.value; - // Rebind cells with new positions (grid positions change after container measured) - updateVisibleCells(int.scrollOffsetSV.value); - forceRender(); - }, - [horizontal, int] - ); + // Scroll velocity for asymmetric buffer distribution (0 = symmetric buffer). + // TODO: track velocity on UI thread via worklet for asymmetric pre-rendering. + const scrollVelocityRef = useRef(0); + // ── Container measurement (synchronous on Fabric via JSI) ── + const measureContainer = useCallback(() => { + const node = scrollAnimatedRef.current; + if (!node) return; + (node as any).measure( + (_x: number, _y: number, width: number, height: number, pageX: number, pageY: number) => { + const cw = horizontal ? height : width; + if (cw === int.containerWidthRef.current) return; // No change + int.containerWidthRef.current = cw; + int.containerMeasRef.current = { x: pageX, y: pageY, width, height }; + int.recomputeBasePositionsAndClearShifts(); + int.syncPositionsToWorklet(); + int.pushBasePositionsToSVs(); + lastProcessedOffsetRef.current = int.scrollOffsetSV.value; + if (updateVisibleCells(int.scrollOffsetSV.value)) forceRender(); + } + ); + }, [horizontal, int]); + + // New Architecture: useLayoutEffect + measure() runs synchronously before paint. + // Container width available on first commit — no FOUC from async onLayout. + useLayoutEffect(() => { + measureContainer(); + }, [measureContainer]); + + // Container measurement handled by useLayoutEffect above (New Architecture pattern). + // No onLayout needed — useLayoutEffect + measure() is synchronous on Fabric. + + // ── Animated content container height (avoids React re-render on size change) ── + const totalSizeSV = useSharedValue(data.length * estimatedItemSize); + const contentContainerAnimStyle = useAnimatedStyle(() => { + return horizontal + ? { width: totalSizeSV.value, height: '100%' as any } + : { height: totalSizeSV.value, width: '100%' as any }; + }); // ── Scroll handling ── - const SCROLL_DELTA_THRESHOLD = 4; // px — skip updateVisibleCells if scroll moved less than this + // Scroll offset SV is tracked on UI thread via useScrollViewOffset (no scheduleOnUI needed). + // onScroll still handles visibility threshold + user callback on JS thread. + const SCROLL_DELTA_THRESHOLD = Math.max(4, estimatedItemSize / 4); + const lastProcessedOffsetRef = useRef(0); const handleScroll = useCallback( (event: NativeSyntheticEvent) => { const offset = horizontal ? event.nativeEvent.contentOffset.x : event.nativeEvent.contentOffset.y; - // Always sync scrollOffset to UI thread (worklet needs accurate offset for slot detection) - scheduleOnUI((_sv: typeof int.scrollOffsetSV, _v: number) => { - 'worklet'; - _sv.value = _v; - }, int.scrollOffsetSV, offset); - - // Track scroll velocity for asymmetric buffer distribution. - // Uses actual last scroll offset (not lastProcessedOffset which only updates on threshold). - const now = Date.now(); - const dt = now - lastScrollTimeRef.current; - if (dt > 0 && dt < 500) { - scrollVelocityRef.current = (offset - lastScrollOffsetRef.current) / dt; - } - lastScrollOffsetRef.current = offset; - lastScrollTimeRef.current = now; - - // Skip visible cell recalculation if scroll delta below threshold. - // drawDistance buffer (3x viewport) makes this safe. if (Math.abs(offset - lastProcessedOffsetRef.current) >= SCROLL_DELTA_THRESHOLD) { lastProcessedOffsetRef.current = offset; - updateVisibleCells(offset); + if (updateVisibleCells(offset)) forceRender(); } onScrollProp?.(event); }, - [horizontal, int.scrollOffsetSV, onScrollProp] + [horizontal, onScrollProp, SCROLL_DELTA_THRESHOLD] ); // ── Item measurement (synchronous) ── @@ -437,15 +601,17 @@ export const DraxList = (props: DraxListProps) => { } else if (shiftsEmpty) { int.recomputeBasePositions(); int.syncPositionsToWorklet(); - forceRender(); + int.pushBasePositionsToSVs(); + if (updateVisibleCells(int.scrollOffsetSV.value)) startTransition(forceRender); } }, [int] ); // ── Cell recycler ── + // Returns true if cell pool grew (caller must forceRender to add CellSlot elements). const updateVisibleCells = useCallback( - (scrollOffset: number) => { + (scrollOffset: number): boolean => { const keys = int.orderedKeysRef.current; const heights = int.itemHeightsRef.current; const containerSize = horizontal @@ -531,28 +697,29 @@ export const DraxList = (props: DraxListProps) => { // Diff: unbind items that left, bind items that entered const currentMap = bindingMapRef.current; - let changed = false; + const store = cellStoreRef.current; + let poolGrew = false; // Unbind (but never free the dragged item's cell) const dragKey = int.draggedKeySV.value; + const freedCells: string[] = []; for (const [itemKey, cellKey] of currentMap.entries()) { if (!visibleKeys.has(itemKey) && itemKey !== dragKey) { currentMap.delete(itemKey); freeCellsRef.current.push(cellKey); - changed = true; + freedCells.push(cellKey); } } // Bind let proactiveMeasured = false; + const newlyBound: [string, string][] = []; for (const itemKey of visibleKeys) { if (!currentMap.has(itemKey)) { let cellKey: string; if (freeCellsRef.current.length > 0) { cellKey = freeCellsRef.current.pop()!; // Proactive measurement: use cell's last known height for the new item. - // If the height matches (same-size item recycled), position is correct - // immediately with no onLayout wait. If different, onLayout corrects in 1 frame. const cellHeight = cellLastHeightRef.current.get(cellKey); if (cellHeight !== undefined && !int.itemHeightsRef.current.has(itemKey)) { int.recordItemHeight(itemKey, cellHeight); @@ -560,27 +727,33 @@ export const DraxList = (props: DraxListProps) => { } } else { cellKey = `cell-${nextCellIdRef.current++}`; + activeCellKeysRef.current.push(cellKey); + poolGrew = true; } currentMap.set(itemKey, cellKey); - changed = true; + newlyBound.push([itemKey, cellKey]); } } // Recompute positions if proactive measurements changed any heights. - // Without this, items stay at estimated positions when proactive height - // matches actual (onLayout won't fire → handleItemLayout won't recompute). if (proactiveMeasured) { int.recomputeBasePositions(); + int.syncPositionsToWorklet(); + int.pushBasePositionsToSVs(); // SV write → animatedStyle on UI thread, zero React re-renders } - if (changed) { - const newBindings: CellBinding[] = []; - for (const [itemKey, cellKey] of currentMap.entries()) { - newBindings.push({ cellKey, itemKey }); - } - cellBindingsRef.current = newBindings; - forceRender(); + // Clear freed cells FIRST — a freed cell may be reused in newlyBound (same cellKey). + // If we clear AFTER set, we'd destroy the new binding. + for (const cellKey of freedCells) { + store.clearBinding(cellKey); + } + // Then set newly bound cells (may reuse cellKeys that were just cleared) + for (const [itemKey, cellKey] of newlyBound) { + const bd = computeCellBindingData(itemKey); + if (bd) store.setBinding(cellKey, bd); } + + return poolGrew; }, [ int, @@ -591,9 +764,17 @@ export const DraxList = (props: DraxListProps) => { estimatedItemSize, numColumns, flexWrap, + computeCellBindingData, ] ); + // Register forceRenderRef for board cross-container triggers. + // Board calls this after insertKey/removeKey to update cell bindings. + int.forceRenderRef.current = () => { + const poolGrew = updateVisibleCells(int.scrollOffsetSV.value); + if (poolGrew) forceRender(); + }; + // ── Initial binding + data sync ── useLayoutEffect(() => { // Echo: parent echoed back our committed reorder. Shifts are permanent, visual is correct. @@ -604,6 +785,7 @@ export const DraxList = (props: DraxListProps) => { // PRE-SYNC position/height/orderedKeys SVs to worklet (safe here — after render, before paint). int.syncPositionsToWorklet(); + int.pushBasePositionsToSVs(); int.orderedKeysSV.value = [...int.orderedKeysRef.current]; if (int.pendingShiftClearRef.current) { @@ -619,7 +801,9 @@ export const DraxList = (props: DraxListProps) => { // Reset scroll delta tracking (positions/data just changed) lastProcessedOffsetRef.current = int.scrollOffsetSV.value; - updateVisibleCells(int.scrollOffsetSV.value); + // updateVisibleCells updates store → per-cell re-renders via useSyncExternalStore. + // forceRender only if pool grew (to add new CellSlot elements). + const poolGrew = updateVisibleCells(int.scrollOffsetSV.value); // Source list: dragged item was transferred out — clear drag state AFTER cell is unbound. // This prevents the flash (opacity 0→1 on the old cell before React removes it). @@ -632,10 +816,11 @@ export const DraxList = (props: DraxListProps) => { // totalContentSize, and clears permanent drag shifts. int.skipShiftAnimationSV.value = true; int.recomputeBasePositionsAndClearShifts(); + int.pushBasePositionsToSVs(); } } - forceRender(); + if (poolGrew) forceRender(); }, [data]); // ── Hover cleanup after cross-container transfer ── @@ -800,7 +985,7 @@ export const DraxList = (props: DraxListProps) => { const current = int.scrollOffsetSV.value; const target = direction === 'back' ? Math.max(0, current - jump) : current + jump; - scrollRef.current?.scrollTo?.({ + (scrollAnimatedRef.current as any)?.scrollTo?.({ [horizontal ? 'x' : 'y']: target, animated: true, }); @@ -1066,7 +1251,7 @@ export const DraxList = (props: DraxListProps) => { const fromIdx = int.dragStartIndexRef.current; const fromItem = int.dataRef.current[fromIdx]; boardContext.commitTransfer(); - updateVisibleCells(int.scrollOffsetSV.value); + if (updateVisibleCells(int.scrollOffsetSV.value)) forceRender(); if (fromItem !== undefined) { onDragEndProp?.({ index: fromIdx, item: fromItem as T, toIndex: fromIdx, cancelled: false }); } @@ -1084,7 +1269,15 @@ export const DraxList = (props: DraxListProps) => { int.syncWorkletToRefs(); } int.commitReorder(); - updateVisibleCells(int.scrollOffsetSV.value); + // After commitReorder, keyToIndexRef has the new order but existing cell bindings + // still have OLD dataIndex values. Re-set all bindings so CellSlot re-renders + // with correct dataIndex → correct item content. + const store = cellStoreRef.current; + for (const [ik, ck] of bindingMapRef.current) { + const bd = computeCellBindingData(ik); + if (bd) store.setBinding(ck, bd); + } + if (updateVisibleCells(int.scrollOffsetSV.value)) forceRender(); if (fromItem !== undefined) { onDragEndProp?.({ index: fromIdx, item: fromItem as T, toIndex: toIdx, cancelled: false }); } @@ -1093,22 +1286,17 @@ export const DraxList = (props: DraxListProps) => { ); // ── Render ── - const bindings = cellBindingsRef.current; - const totalSize = + // Sync totalSizeSV from ref (no React re-render needed for height changes) + const currentTotalSize = int.totalContentSizeRef.current || (numColumns > 1 ? Math.ceil(data.length / numColumns) * estimatedItemSize : data.length * estimatedItemSize); + // Sync totalSizeSV outside render (useEffect) to avoid "Reading from value during render" warning. + useEffect(() => { totalSizeSV.value = currentTotalSize; }, [currentTotalSize, totalSizeSV]); const containerWidth = int.containerWidthRef.current; - const cellWidthForGrid = - containerWidth > 0 - ? numColumns > 1 - ? containerWidth / numColumns - : containerWidth - : undefined; // Read cross-axis alignment from contentContainerStyle.alignItems. - // Applied to inner wrapper — handles centering + correct cross-axis measurement. const itemAlignSelf = useMemo(() => { const flat = contentContainerStyle ? StyleSheet.flatten(contentContainerStyle) @@ -1120,6 +1308,39 @@ export const DraxList = (props: DraxListProps) => { | 'stretch'; }, [contentContainerStyle]); + const fillStyle = !flexWrap && getItemSpan && numColumns > 1 ? { flex: 1 } : undefined; + + // Update stablePropsRef every render (before children render). + // CellSlot reads this via ref — memo on CellSlot is never busted by prop identity. + stablePropsRef.current = { + dataRef: int.dataRef as RefObject, + renderItemRef: int.renderItemRef as RefObject<(info: { item: unknown; index: number }) => ReactNode>, + draggedKeySV: int.draggedKeySV, + hoverReadySV, + skipShiftAnimationSV: int.skipShiftAnimationSV, + springConfig: cellSpringConfig, + shiftDuration: resolvedAnimConfig.shiftDuration, + inactiveItemStyle, + registerCellBase: int.registerCellBase, + unregisterCellBase: int.unregisterCellBase, + registerCellShift: int.registerCellShift, + unregisterCellShift: int.unregisterCellShift, + dragHandle, + longPressDelay, + itemDraxViewProps: itemDraxViewProps as Record | undefined, + lockDragX: !!(lockToMainAxis && !horizontal), + lockDragY: !!(lockToMainAxis && horizontal), + handleSnapEnd, + sortableWorkletConfig, + fillStyle, + horizontal, + skipMeasurement: !!getItemSize, + itemAlignSelf, + handleItemLayout, + cellLastHeightRef, + basePositionsRef: int.basePositionsRef as RefObject>, + }; + return ( (props: DraxListProps) => { onMonitorDragDrop={onMonitorDragDrop} style={style} > - {ListHeaderComponent} {data.length === 0 && ListEmptyComponent} {data.length > 0 && !itemsMeasuredRef.current && ListLoadingComponent} {data.length > 0 && containerWidth > 0 && ( - - {bindings.map((binding) => { - const { cellKey, itemKey } = binding; - // Look up dataIndex at render time (always fresh from keyToIndexRef) - const dataIndex = int.keyToIndexRef.current.get(itemKey); - if (dataIndex === undefined) return null; // Key removed from data (cross-container) - const item = int.dataRef.current[dataIndex]; - const basePos = int.basePositionsRef.current.get(itemKey); - if (!item || !basePos) return null; - // With useLayoutEffect + measure(), items render at estimated positions and - // correct before paint (single commit). No need to hide at -10000. - const isMeasured = true; - const dims = int.itemDimensionsRef.current.get(itemKey); - // Vertical: cell fills column width (users center via alignSelf on their card) - // Horizontal: cell auto-sizes to content (primary axis measurement) - // Grid: use computed dimensions - const itemCellWidth = flexWrap - ? dims?.width // flex-wrap: exact item width from packFlex - : horizontal - ? undefined // auto-size for primary axis measurement - : (dims?.width ?? cellWidthForGrid); // fill column - const itemCellHeight = flexWrap - ? dims?.height // flex-wrap: exact item height from packFlex - : horizontal - ? cellWidthForGrid // fill row height - : getItemSpan - ? dims?.height - : undefined; - // flex:1 for mixed-size grids (cells have explicit height from packGrid). - // NOT for flex-wrap (items have their own natural size). - const fillStyle = !flexWrap && getItemSpan && numColumns > 1 ? { flex: 1 } : undefined; - - return ( - - - - {renderItem({ item, index: dataIndex })} - - - - ); - })} - + {activeCellKeysRef.current.map((cellKey) => ( + + ))} + )} {ListFooterComponent} - + {/* Drop indicator — rendered outside ScrollView, positioned absolutely */} {renderDropIndicator && ( diff --git a/src/DraxProvider.tsx b/src/DraxProvider.tsx index 4ca5bc0..edf99ca 100644 --- a/src/DraxProvider.tsx +++ b/src/DraxProvider.tsx @@ -1,5 +1,5 @@ import type { ReactNode, RefObject } from 'react'; -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import type { HostInstance } from 'react-native'; import { StyleSheet, View } from 'react-native'; import { useSharedValue } from 'react-native-reanimated'; @@ -70,19 +70,20 @@ export const DraxProvider = ({ } = useSpatialIndex(); // ── Hover content (ref-based, zero provider re-renders) ───────────── - // Content stored in a ref. HoverLayer watches hoverTriggerSV via - // useAnimatedReaction and forces its own re-render — Provider never - // re-renders for hover changes. + // Content stored in a ref. setHoverContent calls hoverForceRenderRef + // directly (JS→JS) — no SV bounce. Provider never re-renders for hover changes. const hoverContentRef: RefObject = useRef(null); const hoverStylesRef: RefObject = useRef(null); - const hoverTriggerSV = useSharedValue(0); + // Direct JS→JS re-render trigger — no SV bounce through UI thread. + // HoverLayer registers its forceRender here; setHoverContent calls it directly. + const hoverForceRenderRef = useRef<(() => void) | undefined>(undefined); const setHoverContent = useCallback((content: ReactNode | null) => { hoverContentRef.current = content; if (content === null) { hoverStylesRef.current = null; } - hoverTriggerSV.value += 1; - }, [hoverTriggerSV]); + hoverForceRenderRef.current?.(); + }, []); // ── Callback dispatch ────────────────────────────────────────────── const { handleDragStart, handleReceiverChange, handleDragEnd } = @@ -115,8 +116,9 @@ export const DraxProvider = ({ rootViewRef.current = ref; }; - // Measure root view's screen position on layout - const handleRootLayout = useCallback(() => { + // New Architecture: useLayoutEffect + measure() runs synchronously before paint. + // Root view screen position measured on every render (catches layout changes). + useLayoutEffect(() => { const view = rootViewRef.current; if (view) { (view as unknown as { measure: (cb: (...args: number[]) => void) => void }) @@ -124,7 +126,7 @@ export const DraxProvider = ({ rootOffsetSV.value = { x: pageX, y: pageY }; }); } - }, [rootOffsetSV]); + }); // ── Stable context value ─────────────────────────────────────────── const contextValue = useMemo( @@ -200,7 +202,7 @@ export const DraxProvider = ({ return ( - + {children} {debug && ( = new Set([ 'dragActivationFailOffset', 'collisionAlgorithm', 'scrollHorizontal', + '_contentPosition', ]); /** Extract only ViewProps-compatible props by filtering out Drax-specific keys */ @@ -188,6 +189,15 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { * `measurements._transformDetected` to know whether shift subtraction is needed. */ const finalizeMeasurement = useCallback( (x: number, y: number, width: number, height: number, handler?: DraxViewMeasurementHandler, transformDetected = 0) => { + // Skip expensive downstream work when measurement hasn't changed. + // measureLayout (JSI) still runs, but spatial index update + callbacks are avoided. + const prev = measurementsRef.current; + if (prev && prev.x === x && prev.y === y + && prev.width === width && prev.height === height + && prev._transformDetected === transformDetected) { + handler?.(prev); + return; + } const measurements: DraxViewMeasurements = { height, x, y, width, _transformDetected: transformDetected }; measurementsRef.current = measurements; updateMeasurementsCtx(id, measurements); @@ -201,6 +211,24 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { const view = viewRef.current; if (!view || !parentViewRef.current) return; + // Fast path: recycled list cells provide authoritative position from basePositionsRef. + // Bypasses view.measure() which returns stale transform positions due to the + // timing gap between SharedValue writes (JS) and UI-thread transform application. + // LegendList avoids this by applying transforms as React props (committed before + // measurement). Our RecycledCell uses SVs for zero-render position updates, so + // we provide the known-correct position directly instead of measuring. + const contentPos = props._contentPosition; + if (contentPos) { + view.measureLayout( + parentViewRef.current, + (_x, _y, width, height) => { + finalizeMeasurement(contentPos.x, contentPos.y, width!, height!, handler, 1); + }, + () => {} + ); + return; + } + view.measureLayout( parentViewRef.current, (x, y, width, height) => { @@ -251,7 +279,7 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { }, () => {} ); - }, [id, parentId, viewRef, parentViewRef, getViewEntry, finalizeMeasurement]); + }, [id, parentId, viewRef, parentViewRef, getViewEntry, finalizeMeasurement, props._contentPosition]); // ── Register/unregister with context ──────────────────────────────── // Keep a ref to the latest props so registry always has current callbacks @@ -282,12 +310,10 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { } }); - const onLayout = () => { + // New Architecture: useLayoutEffect + measure() runs synchronously before paint. + // Replaces onLayout callback — measurement happens in same commit as render. + useLayoutEffect(() => { measureWithHandler(); - // Re-measure drag bounds on every layout change. The initial useEffect - // measurement may fire before the parent flex layout has settled (especially - // on native where Fabric commits layout asynchronously). By the time this - // DraxView receives onLayout, the bounds view's layout is also finalized. if (dragBoundsRef?.current && rootViewRef.current) { dragBoundsRef.current.measureLayout( rootViewRef.current, @@ -297,7 +323,7 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { () => {} ); } - }; + }); // External registration — useLayoutEffect so SortableItem's FLIP // useLayoutEffect (which runs after children) sees measureFnRef. @@ -422,7 +448,6 @@ export const DraxView = memo((props: DraxViewProps): ReactNode => { {...viewProps} style={[style, animatedDragStyle]} ref={viewRef} - onLayout={onLayout} collapsable={false} > {renderedContent} diff --git a/src/HoverLayer.tsx b/src/HoverLayer.tsx index 5349532..2ac1d9e 100644 --- a/src/HoverLayer.tsx +++ b/src/HoverLayer.tsx @@ -3,17 +3,16 @@ import { memo, useLayoutEffect, useReducer } from 'react'; import type { ViewStyle } from 'react-native'; import { StyleSheet } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import Reanimated, { useAnimatedReaction, useAnimatedStyle } from 'react-native-reanimated'; -import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; +import Reanimated, { useAnimatedStyle } from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; import type { DragPhase, FlattenedHoverStyles, Position } from './types'; interface HoverLayerProps { hoverContentRef: RefObject; - /** SharedValue trigger — incremented when hover content changes. - * HoverLayer watches this via useAnimatedReaction and forces a local re-render. - * This avoids re-rendering DraxProvider. */ - hoverTriggerSV: SharedValue; + /** Direct JS→JS re-render trigger. setHoverContent calls this to force a local re-render. + * Replaces the old hoverTriggerSV→useAnimatedReaction→scheduleOnRN bounce chain. */ + hoverForceRenderRef: RefObject<(() => void) | undefined>; hoverPositionSV: SharedValue; dragPhaseSV: SharedValue; receiverIdSV: SharedValue; @@ -32,22 +31,17 @@ interface HoverLayerProps { * All other DraxViews read draggedIdSV/receiverIdSV/dragPhaseSV which change ~5x per drag. * * Content is passed via ref. DraxProvider never re-renders for hover changes. - * Only this component re-renders when hover content changes (via hoverTriggerSV). + * Only this component re-renders when hover content changes (via direct forceRender). */ export const HoverLayer = memo( - ({ hoverContentRef, hoverTriggerSV, hoverPositionSV, dragPhaseSV, receiverIdSV, hoverReadySV, hoverDimsSV, hoverStylesRef }: HoverLayerProps) => { - // Local re-render trigger — only HoverLayer re-renders, not DraxProvider. - // hoverTriggerSV is incremented on the JS thread by setHoverContent. - // useAnimatedReaction picks it up and forces a local re-render. + ({ hoverContentRef, hoverForceRenderRef, hoverPositionSV, dragPhaseSV, receiverIdSV, hoverReadySV, hoverDimsSV, hoverStylesRef }: HoverLayerProps) => { + // Direct JS→JS re-render trigger. setHoverContent calls forceRender directly — + // no SV bounce, no useAnimatedReaction, no scheduleOnRN. Same-frame render. const [renderVersion, forceRender] = useReducer((x: number) => x + 1, 0); - useAnimatedReaction( - () => hoverTriggerSV.value, - (curr, prev) => { - if (prev !== null && curr !== prev) { - scheduleOnRN(forceRender); - } - } - ); + useLayoutEffect(() => { + hoverForceRenderRef.current = forceRender; + return () => { hoverForceRenderRef.current = undefined; }; + }, [hoverForceRenderRef, forceRender]); // After hover content is committed to the DOM, activate drag phase + signal readiness. // dragPhaseSV is NOT set in the gesture handler — it's set HERE, ensuring: diff --git a/src/RecycledCell.tsx b/src/RecycledCell.tsx index e1575b5..f4b05d0 100644 --- a/src/RecycledCell.tsx +++ b/src/RecycledCell.tsx @@ -1,14 +1,12 @@ /** * RecycledCell — A single cell in the recycling pool. * - * Position model: - * left/top: baseX/baseY (React props → Yoga → touch hit-testing) - * translateX/Y: shiftX/Y (per-cell SharedValue → Reanimated → visual offset during drag) - * Visual = (left + translateX, top + translateY) = (baseX + shiftX, baseY + shiftY) + * Position model (zero Yoga relayout): + * left/top: always 0 (no Yoga relayout on position change) + * translateX/Y = basePosition + shift (all via SharedValues on UI thread) * - * Each cell has its own SharedValue for shift — only cells with changed shifts - * re-evaluate their animated style on the UI thread. This eliminates full-record - * lookups per cell per frame. + * basePositionSV: written by pushBasePositionsToSVs (JS → SV, no React re-render) + * shiftSV: written by worklet during drag (UI thread) */ import type { ReactNode } from 'react'; import { memo, useLayoutEffect, useMemo } from 'react'; @@ -33,12 +31,11 @@ interface RecycledCellProps { draggedKeySV: SharedValue; hoverReadySV: SharedValue; skipShiftAnimationSV: SharedValue; - /** Pre-computed spring config (stable ref from useMemo). Null = use withTiming. */ springConfig: SpringConfig | null; shiftDuration: number; - /** Style applied to non-dragged items while a drag is active. */ inactiveItemStyle?: Record; - /** Register this cell's shift SV with the parent hook for targeted writes. */ + registerCellBase: (key: string, sv: SharedValue) => void; + unregisterCellBase: (key: string) => void; registerCellShift: (key: string, sv: SharedValue) => void; unregisterCellShift: (key: string) => void; children: ReactNode; @@ -56,62 +53,91 @@ export const RecycledCell = memo(({ springConfig, shiftDuration, inactiveItemStyle, + registerCellBase, + unregisterCellBase, registerCellShift, unregisterCellShift, children, }: RecycledCellProps) => { - // Per-cell shift SharedValue — only THIS cell re-evaluates when its shift changes + // Per-cell base position SV — all positioning via translateX/Y (no Yoga relayout) + const basePositionSV = useSharedValue({ x: baseX, y: baseY }); + // Per-cell shift SV — drag reorder animation const shiftSV = useSharedValue({ x: 0, y: 0 }); - // Register with parent hook so it can write to this cell's SV + // Register base position SV + useLayoutEffect(() => { + if (!itemKey) return; + registerCellBase(itemKey, basePositionSV); + return () => unregisterCellBase(itemKey); + }, [itemKey, basePositionSV, registerCellBase, unregisterCellBase]); + + // Register shift SV useLayoutEffect(() => { if (!itemKey) return; registerCellShift(itemKey, shiftSV); return () => unregisterCellShift(itemKey); }, [itemKey, shiftSV, registerCellShift, unregisterCellShift]); - // Memoize spring config with overshootClamping — MUST be stable reference, - // NOT created inside useAnimatedStyle (new object every frame → spring restarts) + // Sync base position from props on mount/recycle + useLayoutEffect(() => { + basePositionSV.value = { x: baseX, y: baseY }; + }, [baseX, baseY, basePositionSV]); + const clampedSpringConfig = useMemo( () => springConfig ? { ...springConfig, overshootClamping: true } : null, [springConfig], ); - // Memoize the static style to avoid inline object allocation per render + // Static: position absolute at origin, dimensions from props const staticStyle = useMemo( - () => ({ position: 'absolute' as const, left: baseX, top: baseY, width: cellWidth, height: cellHeight }), - [baseX, baseY, cellWidth, cellHeight], + () => ({ position: 'absolute' as const, left: 0, top: 0, width: cellWidth, height: cellHeight }), + [cellWidth, cellHeight], ); + // All positioning via translateX/Y — no Yoga relayout + // + // CRITICAL: base position and shift are SEPARATE transforms, not combined. + // Combining them (`translateX: base.x + withSpring(shift.x)`) causes Reanimated + // to reset/misinterpret the spring when the worklet re-evaluates (e.g., on + // draggedKeySV change), making all cells jump to wrong positions. + // Stacking transforms avoids this: base is always direct, shift is always animated. const animatedStyle = useAnimatedStyle(() => { if (!itemKey) return { opacity: 0 }; - const shift = shiftSV.value; // Direct atomic read — no full-record lookup + const base = basePositionSV.value; + const shift = shiftSV.value; const isDragged = draggedKeySV.value === itemKey && hoverReadySV.value; const dragActive = draggedKeySV.value !== ''; const isInactive = dragActive && !isDragged; - const shiftX = shift.x; - const shiftY = shift.y; - // Skip animation during position reset (snap instantly) if (skipShiftAnimationSV.value) { return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: shiftX }, { translateY: shiftY }], + transform: [ + { translateX: base.x }, + { translateY: base.y }, + { translateX: shift.x }, + { translateY: shift.y }, + ], ...(isInactive && inactiveItemStyle ? inactiveItemStyle : {}), }; } const animatedX = clampedSpringConfig - ? withSpring(shiftX, clampedSpringConfig) - : withTiming(shiftX, { duration: shiftDuration }); + ? withSpring(shift.x, clampedSpringConfig) + : withTiming(shift.x, { duration: shiftDuration }); const animatedY = clampedSpringConfig - ? withSpring(shiftY, clampedSpringConfig) - : withTiming(shiftY, { duration: shiftDuration }); + ? withSpring(shift.y, clampedSpringConfig) + : withTiming(shift.y, { duration: shiftDuration }); return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: animatedX }, { translateY: animatedY }], + transform: [ + { translateX: base.x }, + { translateY: base.y }, + { translateX: animatedX }, + { translateY: animatedY }, + ], ...(isInactive && inactiveItemStyle ? inactiveItemStyle : {}), }; }); @@ -119,12 +145,7 @@ export const RecycledCell = memo(({ if (!itemKey) return null; return ( - + {children} ); diff --git a/src/SortableBoardContainer.tsx b/src/SortableBoardContainer.tsx index d6544b6..ec92ffb 100644 --- a/src/SortableBoardContainer.tsx +++ b/src/SortableBoardContainer.tsx @@ -324,8 +324,13 @@ export const SortableBoardContainer = ({ sourceCol.dropIndicatorGenSV.value++; // New indicator on source → snap (no spring from old position) sourceCol.dropIndicatorVisibleSV.value = true; } + // Source needs to re-render to bind a cell for the returned item + sourceCol.forceRenderRef.current?.(); } + // Also trigger forceRender on the previous target to unbind the transferred cell + prevTarget?.forceRenderRef.current?.(); + transferRef.current = undefined; } }, [columns, draxViewProps]); diff --git a/src/hooks/useCallbackDispatch.tsx b/src/hooks/useCallbackDispatch.tsx index aaa53f9..f29ccc0 100644 --- a/src/hooks/useCallbackDispatch.tsx +++ b/src/hooks/useCallbackDispatch.tsx @@ -220,7 +220,8 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { // them when it re-renders. deps.hoverStylesRef.current = draggedEntry.flattenedHoverStyles ?? null; - // Setup hover content + // Setup hover content — synchronous, renders HoverLayer in same frame. + // hoverReadySV gates visibility (opacity 0) until HoverLayer's useLayoutEffect fires. if (isDraggable(draggedEntry.props) && !draggedEntry.props.noHover) { const renderFn = draggedEntry.props.renderHoverContent ?? diff --git a/src/hooks/useDragGesture.ts b/src/hooks/useDragGesture.ts index 97baae6..f48b4ca 100644 --- a/src/hooks/useDragGesture.ts +++ b/src/hooks/useDragGesture.ts @@ -26,6 +26,7 @@ export interface SortableWorkletConfig { isDraggingSV: SharedValue; containerMeasSV: SharedValue<{ x: number; y: number; width: number; height: number } | null>; cellShiftRecordSV: SharedValue>>; + cumulativeEndsSV: SharedValue; draggedKeySV: SharedValue; dropIndicatorPositionSV: SharedValue; scrollOffsetSV: SharedValue; @@ -34,7 +35,7 @@ export interface SortableWorkletConfig { horizontal: boolean; estimatedItemSize: number; reorderStrategy: string; - getSlotFromPositionWorklet: (contentX: number, contentY: number, boundaries: any[], cols: number, horiz: boolean) => number; + getSlotFromPositionWorklet: (contentX: number, contentY: number, boundaries: any[], cumulativeEnds: number[], cols: number, horiz: boolean) => number; recomputeShiftsWorklet: (dragKey: string, targetSlot: number, keys: string[], basePosRecord: Record, heightsRecord: Record, cellShiftRecord: Record>, estItemSize: number, horiz: boolean, strategy: string) => string[] | null; } @@ -252,7 +253,7 @@ export const useDragGesture = ( const scrollOff = sw.scrollOffsetSV.value; const cX = hitTestPos.x - cm.x + (sw.horizontal ? scrollOff : 0); const cY = hitTestPos.y - cm.y + (sw.horizontal ? 0 : scrollOff); - const slot = sw.getSlotFromPositionWorklet(cX, cY, sw.frozenBoundariesSV.value, sw.numColumns, sw.horizontal); + const slot = sw.getSlotFromPositionWorklet(cX, cY, sw.frozenBoundariesSV.value, sw.cumulativeEndsSV.value, sw.numColumns, sw.horizontal); if (slot !== sw.currentSlotSV.value) { sw.currentSlotSV.value = slot; const dragKey = sw.draggedKeySV.value; @@ -277,17 +278,24 @@ export const useDragGesture = ( } } - // Pass static SVs as args to avoid cross-thread reads on JS thread. - // draggedIdSV, startPositionSV, grabOffsetSV are set once in onActivate and never change during drag. - scheduleOnRN(handleReceiverChange, - oldReceiver, - candidateReceiverId, - hitTestPos, - draggedIdSV.value, - startPositionSV.value, - grabOffsetSV.value, - result.monitorIds - ); + // Skip JS bounce when the UI-thread sortable worklet is handling reorder + // AND receiver hasn't changed AND no monitors need updating. + // This eliminates ~60 cross-thread calls/sec during intra-list single-column + // drag. For non-sortable views or views with monitors, always bounce to JS + // so continuous callbacks (onDrag, onDragOver, onReceiveDragOver) still fire. + const sortableHandled = sortableWorklet && sortableWorklet.isDraggingSV.value && sortableWorklet.numColumns === 1; + const hasMonitors = result.monitorIds.length > 0; + if (!sortableHandled || receiverChanged || hasMonitors) { + scheduleOnRN(handleReceiverChange, + oldReceiver, + candidateReceiverId, + hitTestPos, + draggedIdSV.value, + startPositionSV.value, + grabOffsetSV.value, + result.monitorIds + ); + } }, onDeactivate: (_event) => { 'worklet'; diff --git a/src/hooks/useSortableList.ts b/src/hooks/useSortableList.ts index 7723ec1..55e3010 100644 --- a/src/hooks/useSortableList.ts +++ b/src/hooks/useSortableList.ts @@ -116,9 +116,12 @@ export interface SortableListInternal { /** When true, cells snap shifts instantly (no spring/timing). Set during cross-container reset. */ skipShiftAnimationSV: ReturnType>; - // ── Per-cell shift SharedValues (UI-thread perf: only moved cells re-evaluate) ── + // ── Per-cell SharedValues (position + shift, zero React re-renders) ── + registerCellBase: (key: string, sv: SharedValue) => void; + unregisterCellBase: (key: string) => void; registerCellShift: (key: string, sv: SharedValue) => void; unregisterCellShift: (key: string) => void; + pushBasePositionsToSVs: () => void; // ── Worklet-accessible SharedValues (for UI-thread slot detection) ── frozenBoundariesSV: SharedValue<{ key: string; x: number; y: number; width: number; height: number }[]>; @@ -132,9 +135,11 @@ export interface SortableListInternal { syncRefsToWorklet: () => void; syncWorkletToRefs: () => void; syncPositionsToWorklet: () => void; + cumulativeEndsSV: SharedValue; getSlotFromPositionWorklet: ( contentX: number, contentY: number, boundaries: { key: string; x: number; y: number; width: number; height: number }[], + cumulativeEnds: number[], cols: number, horiz: boolean, ) => number; recomputeShiftsWorklet: ( @@ -272,6 +277,8 @@ export const useSortableList = ( /** Snap target position (worklet writes here during shift computation for O(1) snap at drag end). */ const snapTargetSV = useSharedValue({ x: 0, y: 0 }); const containerMeasSV = useSharedValue<{ x: number; y: number; width: number; height: number } | null>(null); + /** Pre-computed cumulative item ends for O(log N) binary search slot detection (single-column). */ + const cumulativeEndsSV = useSharedValue([]); // ── Drop indicator ── const dropIndicatorPositionSV = useSharedValue({ x: 0, y: 0 }); @@ -285,6 +292,26 @@ export const useSortableList = ( } | undefined>(undefined); const forceRenderRef = useRef<(() => void) | undefined>(undefined); + // ── Per-cell base position SharedValues (position changes via SV, zero React re-renders) ── + const cellBaseRegistryRef = useRef(new Map>()); + const registerCellBase = useCallback((key: string, sv: SharedValue) => { + cellBaseRegistryRef.current.set(key, sv); + // Set correct base position on registration (cell mount or recycle) + const pos = basePositionsRef.current.get(key); + if (pos) sv.value = { x: pos.x, y: pos.y }; + }, []); + const unregisterCellBase = useCallback((key: string) => { + cellBaseRegistryRef.current.delete(key); + }, []); + + /** Push base positions to all registered cells via SharedValues (zero React re-renders). */ + function pushBasePositionsToSVs() { + for (const [key, sv] of cellBaseRegistryRef.current) { + const pos = basePositionsRef.current.get(key); + if (pos) sv.value = { x: pos.x, y: pos.y }; + } + } + // ── Per-cell shift SharedValues (UI-thread perf: only moved cells re-evaluate) ── const cellShiftRegistryRef = useRef(new Map>()); const registerCellShift = useCallback((key: string, sv: SharedValue) => { @@ -315,6 +342,12 @@ export const useSortableList = ( const cs: Record> = {}; for (const [k, v] of cellShiftRegistryRef.current) cs[k] = v; cellShiftRecordSV.value = cs; + } else { + // Not dragging: set correct shift for this item on recycle. + // After echo, shiftsSV has permanent reorder offsets per key. + // After clearShifts, all are {0,0}. New object avoids frozen ref. + const existing = shiftsSV.value[key]; + sv.value = existing ? { x: existing.x, y: existing.y } : { x: 0, y: 0 }; } }, []); const unregisterCellShift = useCallback((key: string) => { @@ -590,14 +623,42 @@ export const useSortableList = ( * Creates fresh Record copies (Reanimated freezes SV values — never share with refs). * Call OUTSIDE render: useLayoutEffect, callbacks, commitReorder. */ function syncPositionsToWorklet() { - const bp: Record = {}; - for (const [k, v] of basePositionsRef.current) bp[k] = v; - basePositionsRecordRef.current = bp; - basePositionsSV.value = bp; - const ih: Record = {}; - for (const [k, v] of itemHeightsRef.current) ih[k] = v; - itemHeightsRecordRef.current = ih; - itemHeightsSV.value = ih; + // Create SEPARATE objects for ref and SV — Reanimated freezes SV values, + // so sharing the same object would make the ref point to a frozen object. + const bpForRef: Record = {}; + const bpForSV: Record = {}; + for (const [k, v] of basePositionsRef.current) { + bpForRef[k] = v; + bpForSV[k] = v; + } + basePositionsRecordRef.current = bpForRef; + basePositionsSV.value = bpForSV; + const ihForRef: Record = {}; + const ihForSV: Record = {}; + for (const [k, v] of itemHeightsRef.current) { + ihForRef[k] = v; + ihForSV[k] = v; + } + itemHeightsRecordRef.current = ihForRef; + itemHeightsSV.value = ihForSV; + + // Single-column: update cumulative ends for O(log N) binary search slot detection. + // Flat number[] is ~6x cheaper to write to SV than the object[] frozenBoundaries. + syncCumulativeEnds(); + } + + /** Compute and write cumulative item end positions for single-column binary search. + * Called from syncPositionsToWorklet (data change) and commitReorder (after reorder). */ + function syncCumulativeEnds() { + if (numColumns !== 1 || flexWrap) return; + const keys = orderedKeysRef.current; + const ends: number[] = new Array(keys.length); + let cursor = 0; + for (let i = 0; i < keys.length; i++) { + cursor += itemHeightsRef.current.get(keys[i]!) ?? estimatedItemSize; + ends[i] = cursor; + } + cumulativeEndsSV.value = ends; } /** Clear all shifts (snap to 0). Caller must ensure base positions are already current. */ @@ -677,16 +738,20 @@ export const useSortableList = ( const freezeSlotBoundaries = useCallback(() => { const keys = orderedKeysRef.current; const currentDragKey = draggedKeySV.value; - const keysUnchanged = keys === frozenKeysRef.current && frozenBoundariesRef.current.length > 0; const dragKeyUnchanged = currentDragKey === frozenDragKeyRef.current; - // Skip boundaries recomputation if keys unchanged; always recompute gap layout if drag key changed - if (keysUnchanged && dragKeyUnchanged) return; - // Recompute boundaries only when keys changed (not just drag key) + // Single-column: cumulativeEndsSV is kept current by syncPositionsToWorklet + + // commitReorder. No frozenBoundariesSV write needed (saves 139-145ms). + if (numColumns === 1 && !flexWrap) { + frozenDragKeyRef.current = currentDragKey; + frozenKeysRef.current = keys; + return; + } + + // Grid/flex-wrap: compute frozen boundaries (small N, fast SV write) + const keysUnchanged = keys === frozenKeysRef.current && frozenBoundariesRef.current.length > 0; if (!keysUnchanged) { frozenKeysRef.current = keys; - // Build boundaries from CURRENT key order with VISUAL positions (base + permanentShift). - // Must iterate orderedKeysRef (not shadowBoundariesRef) because shadow is in OLD order. const shifts = shiftsSV.value; const basePositions = basePositionsRef.current; const dimensions = itemDimensionsRef.current; @@ -705,6 +770,8 @@ export const useSortableList = ( frozenBoundariesSV.value = frozenBoundariesRef.current; } + if (dragKeyUnchanged) return; + // Virtual slot: pack grid WITHOUT the dragged item to create a stable "gap layout." // Recomputed when keys OR drag key changes (different item picked up). frozenDragKeyRef.current = currentDragKey; @@ -884,10 +951,24 @@ export const useSortableList = ( contentX: number, contentY: number, boundaries: { key: string; x: number; y: number; width: number; height: number }[], + cumulativeEnds: number[], cols: number, horiz: boolean, ): number { 'worklet'; + // Single-column: O(log N) binary search on pre-computed cumulative ends + if (cols === 1 && cumulativeEnds.length > 0) { + const pos = horiz ? contentX : contentY; + let lo = 0; + let hi = cumulativeEnds.length - 1; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (cumulativeEnds[mid]! <= pos) lo = mid + 1; + else hi = mid; + } + return Math.min(lo, cumulativeEnds.length - 1); + } + // Grid: O(N) center-distance (grids have small N) if (boundaries.length === 0) return 0; if (cols > 1) { let bestIdx = 0; @@ -899,6 +980,7 @@ export const useSortableList = ( } return bestIdx; } + // Fallback: 1D linear scan for (let i = 0; i < boundaries.length - 1; i++) { const current = boundaries[i]!; const next = boundaries[i + 1]!; @@ -1054,6 +1136,9 @@ export const useSortableList = ( const removeKey = useCallback((key: string) => { orderedKeysRef.current = orderedKeysRef.current.filter(k => k !== key); + // Clean up stale entries — avoids accumulating data for transferred items + basePositionsRef.current.delete(key); + delete basePositionsRecordRef.current[key]; recomputeAllShifts(); }, []); @@ -1064,6 +1149,22 @@ export const useSortableList = ( } orderedKeysRef.current = keys; itemHeightsRef.current.set(key, height); + + // Pre-compute where the new key WILL land, set as its base position. + // New item: shift = target - target = 0 (appears at insertion point). + // Existing items: shift = newTarget - oldBase (animate to make room). + // Without this, recomputeAllShifts sees no basePos for the new key and + // sets shift = target (huge value) instead of 0. + const preview = computeGridPositions(keys); + const newKeyTarget = preview.positions.get(key); + if (newKeyTarget) { + basePositionsRef.current.set(key, newKeyTarget); + // Create new object — Reanimated freezes objects assigned to SharedValues, + // so basePositionsRecordRef.current may be non-extensible. + basePositionsRecordRef.current = { ...basePositionsRecordRef.current, [key]: newKeyTarget }; + } + totalContentSizeRef.current = preview.totalHeight; + recomputeAllShifts(); }, []); @@ -1107,11 +1208,12 @@ export const useSortableList = ( awaitingEchoRef.current = reorderedData; // PRE-SYNC orderedKeys so next drag start doesn't need O(N) copy orderedKeysSV.value = [...keys]; + // Update cumulative ends for binary search slot detection at next drag + syncCumulativeEnds(); // Clear drag state isDraggingRef.current = false; draggedKeySV.value = ''; - // Notification — parent stores data for persistence, library already committed if (fromItem !== undefined && toItem !== undefined) { onReorder({ @@ -1156,8 +1258,11 @@ export const useSortableList = ( draggedKeySV, scrollOffsetSV, skipShiftAnimationSV, + registerCellBase, + unregisterCellBase, registerCellShift, unregisterCellShift, + pushBasePositionsToSVs, frozenBoundariesSV, orderedKeysSV, basePositionsSV, @@ -1166,6 +1271,7 @@ export const useSortableList = ( isDraggingSV, containerMeasSV, cellShiftRecordSV, + cumulativeEndsSV, snapTargetSV, syncRefsToWorklet, syncWorkletToRefs, diff --git a/src/types/view.ts b/src/types/view.ts index c75fbdf..abd9b9f 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -242,4 +242,8 @@ export interface DraxViewProps collisionAlgorithm?: CollisionAlgorithm; /** Ref to a View that constrains the drag area. The dragged view will be clamped within these bounds. */ dragBoundsRef?: RefObject; + /** @internal Authoritative content-relative position for recycled list cells. + * Bypasses view.measure() timing gap with SV-driven transforms. + * Set by CellSlot from basePositionsRef. */ + _contentPosition?: Position; }