diff --git a/CLAUDE.md b/CLAUDE.md index 1c03811..522cd75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,30 @@ 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 + +### 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 @@ -19,7 +43,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 @@ -47,7 +71,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 @@ -56,6 +80,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 @@ -176,7 +209,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/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/example/screens/reorderable-list.tsx b/example/screens/reorderable-list.tsx index 12c286d..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 @@ -63,22 +78,34 @@ 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)} 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/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/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 fd71324..8889b67 100644 --- a/src/DraxList.tsx +++ b/src/DraxList.tsx @@ -7,31 +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 { runOnJS, runOnUI } from 'react-native-worklets'; +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'; @@ -115,8 +116,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 */ @@ -145,12 +150,201 @@ 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) ── + +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(() => { + if (skipMeasurement) 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; + if (primary > 0) { + onMeasure(itemKey, primary); + onCellHeight.current.set(cellKey, primary); + } + }); + }, [itemKey]); + + return ( + + {children} + + ); +}); + +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) => { @@ -164,7 +358,7 @@ export const DraxList = (props: DraxListProps) => { id: _idProp, numColumns = 1, horizontal = false, - drawDistance = 250, + drawDistance: drawDistanceProp, animationConfig = 'default', longPressDelay = 250, lockToMainAxis, @@ -172,6 +366,8 @@ export const DraxList = (props: DraxListProps) => { dragHandle, getItemSpan, gridGap, + flexWrap, + getItemSize, onScroll: onScrollProp, scrollEventThrottle = 16, style, @@ -187,12 +383,27 @@ export const DraxList = (props: DraxListProps) => { } = props; const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - - const scrollRef = useRef(null); - // ── Single re-render trigger (the ONLY thing that causes re-render) ── + // 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 scrollAnimatedRef = useAnimatedRef(); + + // ── 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, @@ -209,13 +420,18 @@ export const DraxList = (props: DraxListProps) => { drawDistance, getItemSpan, gridGap, + flexWrap, + getItemSize, }); 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), @@ -255,8 +471,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, @@ -265,9 +483,11 @@ 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, + snapTargetSV: int.snapTargetSV, numColumns, horizontal, estimatedItemSize, @@ -275,231 +495,268 @@ 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) ── - 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 velocity tracking (for velocity-aware buffer distribution) ── - const prevScrollOffsetRef = useRef(0); - const scrollDirectionRef = useRef<'forward' | 'back'>('forward'); - // ── Range caching (skip updateVisibleCells when scroll stays in safe range) ── - const safeBoundsRef = useRef<{ start: number; end: number } | null>(null); - - // ── Container layout ── - const handleContainerLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - const cw = horizontal ? height : width; - if (cw === int.containerWidthRef.current) return; // No-op — skip redundant recompute - int.containerWidthRef.current = cw; - int.recomputeBasePositionsAndClearShifts(); - safeBoundsRef.current = null; // Invalidate range cache - // 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 ── + // 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; - runOnUI((_sv: typeof int.scrollOffsetSV, _v: number) => { - 'worklet'; - _sv.value = _v; - })(int.scrollOffsetSV, offset); - - // Track scroll direction for velocity-aware buffer - if (offset > prevScrollOffsetRef.current) scrollDirectionRef.current = 'forward'; - else if (offset < prevScrollOffsetRef.current) scrollDirectionRef.current = 'back'; - prevScrollOffsetRef.current = offset; - - // Range caching: skip updateVisibleCells if scroll is within the safe range - const safe = safeBoundsRef.current; - if (safe && offset >= safe.start && offset <= safe.end) { - onScrollProp?.(event); - return; + if (Math.abs(offset - lastProcessedOffsetRef.current) >= SCROLL_DELTA_THRESHOLD) { + lastProcessedOffsetRef.current = offset; + if (updateVisibleCells(offset)) forceRender(); } - // Rebind cells for new visible range - updateVisibleCells(offset); onScrollProp?.(event); }, - [horizontal, int.scrollOffsetSV, onScrollProp] + [horizontal, onScrollProp, SCROLL_DELTA_THRESHOLD] ); - // ── 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.itemHeightsRef.current.set(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) { + int.itemHeightsSV.value = { + ...int.itemHeightsSV.value, + [itemKey]: height, + }; + } else if (shiftsEmpty) { + int.recomputeBasePositions(); + int.syncPositionsToWorklet(); + int.pushBasePositionsToSVs(); + if (updateVisibleCells(int.scrollOffsetSV.value)) startTransition(forceRender); } }, [int] ); // ── Cell recycler ── - /** Returns true if it triggered forceRender, false otherwise. */ + // Returns true if cell pool grew (caller must forceRender to add CellSlot elements). const updateVisibleCells = useCallback( (scrollOffset: number): boolean => { 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); - // Velocity-aware buffer: 70% ahead, 30% behind - const dir = scrollDirectionRef.current; - const bufferAhead = drawDistance * 0.7; - const bufferBehind = drawDistance * 0.3; - const visibleStart = scrollOffset - (dir === 'forward' ? bufferBehind : bufferAhead); - const visibleEnd = scrollOffset + viewportSize + (dir === 'forward' ? bufferAhead : bufferBehind); - + // 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; - // Only read shiftsSV when actually dragging (avoids JSI cross-thread read on every scroll) - const shifts = isDragging ? int.shiftsSV.value : null; + 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; - const basePositions = int.basePositionsRef.current; visibleKeysRef.current.clear(); const visibleKeys = visibleKeysRef.current; - // Binary search for the first visible item, then linear scan to end of visible range. - // Keys are in display order and basePositions are monotonically increasing along the main axis. - // Falls back to linear scan when: dragging (shifts reorder), grid layout (same-row items share Y), - // or base positions incomplete. - const canBinarySearch = !isDragging && numColumns === 1 && basePositions.size === keys.length && keys.length > 0; - - if (canBinarySearch && keys.length > 16) { - // Binary search: find first key where position + size >= visibleStart + // 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 = keys.length - 1; + let hi = sorted.length - 1; while (lo < hi) { - const mid = Math.floor((lo + hi) / 2); - const midKey = keys[mid]!; - const midPos = basePositions.get(midKey); - if (!midPos) { lo = 0; break; } // Fallback - const mainEnd = horizontal - ? midPos.x + (int.itemDimensionsRef.current.get(midKey)?.width ?? heights.get(midKey) ?? estimatedItemSize) - : midPos.y + (heights.get(midKey) ?? estimatedItemSize); - if (mainEnd < visibleStart) lo = mid + 1; + const mid = (lo + hi) >>> 1; + if (sorted[mid]!.end < visibleStart) lo = mid + 1; else hi = mid; } - - // Linear scan from lo to find all visible items - for (let i = lo; i < keys.length; i++) { - const key = keys[i]!; - const basePos = basePositions.get(key); - if (!basePos) { visibleKeys.add(key); continue; } - if (horizontal) { - const visualX = basePos.x; - if (visualX > visibleEnd) break; // Past visible range — done - const w = int.itemDimensionsRef.current.get(key)?.width ?? heights.get(key) ?? estimatedItemSize; - if (visualX + w >= visibleStart) visibleKeys.add(key); - } else { - const visualY = basePos.y; - if (visualY > visibleEnd) break; // Past visible range — done - const h = heights.get(key) ?? estimatedItemSize; - if (visualY + h >= visibleStart) visibleKeys.add(key); + // 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 { - // Linear scan fallback (during drag, grids, or small lists) + // 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 ? shifts[key] : undefined; + 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); + const w = + int.itemDimensionsRef.current.get(key)?.width ?? + heights.get(key) ?? + estimatedItemSize; + 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); + if (visualY + h >= visibleStart && visualY <= visibleEnd) + visibleKeys.add(key); } } - } - // Update range cache: safe bounds where current visible set remains valid. - // Margin = 25% of drawDistance — recalculate before items actually enter/leave. - if (!isDragging) { - const margin = drawDistance * 0.25; - safeBoundsRef.current = { - start: scrollOffset - margin, - end: scrollOffset + margin, - }; - } else { - safeBoundsRef.current = null; // Disable caching during drag } // 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. + 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++}`; + activeCellKeysRef.current.push(cellKey); + poolGrew = true; } currentMap.set(itemKey, cellKey); - changed = true; + newlyBound.push([itemKey, cellKey]); } } - if (changed) { - const newBindings: CellBinding[] = []; - for (const [itemKey, cellKey] of currentMap.entries()) { - newBindings.push({ cellKey, itemKey }); - } - cellBindingsRef.current = newBindings; - forceRender(); - return true; + // Recompute positions if proactive measurements changed any heights. + if (proactiveMeasured) { + int.recomputeBasePositions(); + int.syncPositionsToWorklet(); + int.pushBasePositionsToSVs(); // SV write → animatedStyle on UI thread, zero React re-renders + } + + // 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 false; + + return poolGrew; + }, [ int, @@ -509,26 +766,50 @@ export const DraxList = (props: DraxListProps) => { drawDistance, 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(() => { - safeBoundsRef.current = null; // Invalidate range cache on data change + // 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.pushBasePositionsToSVs(); + int.orderedKeysSV.value = [...int.orderedKeysRef.current]; + - // Cross-container: new items arrived — recompute positions + clear shifts atomically. - // 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; } - const didUpdate = updateVisibleCells(int.scrollOffsetSV.value); + + // Reset scroll delta tracking (positions/data just changed) + lastProcessedOffsetRef.current = 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). @@ -541,10 +822,12 @@ export const DraxList = (props: DraxListProps) => { // totalContentSize, and clears permanent drag shifts. int.skipShiftAnimationSV.value = true; int.recomputeBasePositionsAndClearShifts(); + int.pushBasePositionsToSVs(); } } - if (!didUpdate) forceRender(); // Only if updateVisibleCells didn't already trigger one + if (poolGrew) forceRender(); + }, [data]); // ── Hover cleanup after cross-container transfer ── @@ -557,7 +840,7 @@ export const DraxList = (props: DraxListProps) => { if (hoverClearDeferredRef.current) { hoverClearDeferredRef.current = false; - runOnUI( + scheduleOnUI( ( _hoverReadySV: typeof hoverReadySV, _dragPhaseSV: typeof dragPhaseSV, @@ -574,9 +857,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, @@ -612,25 +894,34 @@ 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(); + // 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. + // 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 }; + } + } + // 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 runOnUI), 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); @@ -648,22 +939,24 @@ 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. } }, [ int, keyExtractor, + getItemSpan, + numColumns, + hoverDimsSV, renderDropIndicator, dropIndicatorPositionSV, dropIndicatorVisibleSV, @@ -699,7 +992,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, }); @@ -756,10 +1049,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. @@ -769,14 +1064,21 @@ 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; + // Re-render if content area grew (prevents clipping shifted items) + if (int.totalContentSizeRef.current > prevSize) { + updateVisibleCells(int.scrollOffsetSV.value); + forceRender(); + } } } @@ -831,6 +1133,7 @@ export const DraxList = (props: DraxListProps) => { horizontal, startAutoScroll, stopAutoScroll, + updateVisibleCells, renderDropIndicator, dropIndicatorPositionSV, dropIndicatorVisibleSV, @@ -853,40 +1156,37 @@ export const DraxList = (props: DraxListProps) => { const containerMeas = int.containerMeasRef.current; 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) { - const keys = int.orderedKeysSV.value; - const heights = int.itemHeightsSV.value; - let cursor = 0; - for (const key of keys) { - if (key === dragKey) break; - cursor += heights[key] ?? estimatedItemSize; - } - 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; } - return { - x: - containerMeas.x + - visualX - - (horizontal ? int.scrollOffsetSV.value : 0), - y: - containerMeas.y + - visualY - - (horizontal ? 0 : int.scrollOffsetSV.value), - }; + const scOffset = int.scrollContainerOffsetRef.current; + 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); + return { x: snapX, y: snapY }; } return DraxSnapbackTargetPreset.Default; - }, [int, stopAutoScroll, boardContext, horizontal]); + }, [int, stopAutoScroll, boardContext, horizontal, sortableWorkletConfig]); const onMonitorDragEnd = useCallback( (_eventData: DraxMonitorEndEventData): DraxProtocolDragEndResponse => { @@ -927,8 +1227,8 @@ export const DraxList = (props: DraxListProps) => { const fromIdx = int.dragStartIndexRef.current; const fromItem = int.dataRef.current[fromIdx]; boardContext.commitTransfer(); - safeBoundsRef.current = null; // Invalidate — data about to change - updateVisibleCells(int.scrollOffsetSV.value); + if (updateVisibleCells(int.scrollOffsetSV.value)) forceRender(); + if (fromItem !== undefined) { onDragEndProp?.({ index: fromIdx, item: fromItem as T, toIndex: fromIdx, cancelled: false }); } @@ -946,8 +1246,16 @@ export const DraxList = (props: DraxListProps) => { int.syncWorkletToRefs(); } int.commitReorder(); - safeBoundsRef.current = null; // Invalidate — base positions about to change on data echo - 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 }); } @@ -956,22 +1264,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) @@ -983,6 +1286,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; - 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 = 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 only for mixed-size grids (getItemSpan provided) - const fillStyle = getItemSpan ? { flex: 1 } : undefined; - - return ( - - - { - // Primary axis from outer wrapper (fills cell) - const primary = horizontal - ? e.nativeEvent.layout.width - : e.nativeEvent.layout.height; - handleItemLayout(itemKey, 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 })} - - - - - ); - })} - + {activeCellKeysRef.current.map((cellKey) => ( + + ))} + )} {ListFooterComponent} - + {/* Drop indicator — rendered outside ScrollView, positioned absolutely */} {renderDropIndicator && ( @@ -1162,8 +1419,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, }; @@ -1180,8 +1438,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..edf99ca 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, useLayoutEffect, 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,18 +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. 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 [hoverVersion, setHoverVersion] = useState(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; } - setHoverVersion((v) => v + 1); + hoverForceRenderRef.current?.(); }, []); // ── Callback dispatch ────────────────────────────────────────────── @@ -114,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 }) @@ -123,7 +126,7 @@ export const DraxProvider = ({ rootOffsetSV.value = { x: pageX, y: pageY }; }); } - }, [rootOffsetSV]); + }); // ── Stable context value ─────────────────────────────────────────── const contextValue = useMemo( @@ -199,7 +202,7 @@ export const DraxProvider = ({ return ( - + {children} {debug && ( 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/DraxView.tsx b/src/DraxView.tsx index 630ab3c..41d8ee9 100644 --- a/src/DraxView.tsx +++ b/src/DraxView.tsx @@ -90,6 +90,7 @@ const DRAX_PROP_KEYS: ReadonlySet = 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 ae1df18..2ac1d9e 100644 --- a/src/HoverLayer.tsx +++ b/src/HoverLayer.tsx @@ -1,26 +1,18 @@ 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 { 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; + /** 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; @@ -38,29 +30,37 @@ 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 direct forceRender). */ export const HoverLayer = memo( - ({ hoverContentRef, hoverVersion, hoverPositionSV, dragPhaseSV, receiverIdSV, hoverReadySV, hoverDimsSV, hoverStylesRef }: HoverLayerProps) => { + ({ 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); + 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: // 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/RecycledCell.tsx b/src/RecycledCell.tsx index f1ae2f5..47ddf08 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,87 +53,113 @@ 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) { if (isInactive && inactiveItemStyle) { return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: shiftX }, { translateY: shiftY }], + transform: [ + { translateX: base.x }, { translateY: base.y }, + { translateX: shift.x }, { translateY: shift.y }, + ], ...inactiveItemStyle, }; } return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: shiftX }, { translateY: shiftY }], + transform: [ + { translateX: base.x }, { translateY: base.y }, + { translateX: shift.x }, { translateY: shift.y }, + ], }; } 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 }); if (isInactive && inactiveItemStyle) { return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: animatedX }, { translateY: animatedY }], + transform: [ + { translateX: base.x }, { translateY: base.y }, + { translateX: animatedX }, { translateY: animatedY }, + ], ...inactiveItemStyle, }; } return { opacity: isDragged ? 0 : 1, - transform: [{ translateX: animatedX }, { translateY: animatedY }], + transform: [ + { translateX: base.x }, { translateY: base.y }, + { translateX: animatedX }, { translateY: animatedY }, + ], }; }); if (!itemKey) return null; return ( - + {children} ); diff --git a/src/SortableBoardContainer.tsx b/src/SortableBoardContainer.tsx index c7ec8ef..ec92ffb 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'; @@ -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/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/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/hooks/useCallbackDispatch.tsx b/src/hooks/useCallbackDispatch.tsx index b70c31c..f29ccc0 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,17 +215,13 @@ 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 + // 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 ?? @@ -277,7 +273,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 +282,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { absolutePosition: Position, draggedId: string, startPosition: Position, + grabOffset: Position, monitorIds?: string[] ) => { @@ -307,9 +304,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 +364,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 +372,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { 'worklet'; _receiverIdSV.value = ''; _rejectedReceiverIdSV.value = _rejectedId; - })(deps.receiverIdSV, deps.rejectedReceiverIdSV, newReceiverId); + }, deps.receiverIdSV, deps.rejectedReceiverIdSV, newReceiverId); } } @@ -380,7 +381,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 +406,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 +429,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 +450,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 +509,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 +526,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 +537,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 +562,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 +584,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 +654,9 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => { if (receiverId && !cancelled) { const receiverData = buildReceiverViewData( receiverId, - absolutePosition + absolutePosition, + cachedSpatialEntries, + cachedScrollOffsets ); if (receiverData) { const monitorDropResponse = @@ -671,8 +685,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 +706,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 +766,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 +790,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 +803,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 +817,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 +828,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 +872,7 @@ function performSnapback( withTiming(toValue, { duration: snapDuration }, (finished) => { 'worklet'; if (finished) { - runOnJS(onSnapComplete)(); + scheduleOnRN(onSnapComplete); } }) ); @@ -866,8 +880,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 035a5e4..f48b4ca 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 @@ -26,14 +26,16 @@ 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; + snapTargetSV: SharedValue; numColumns: number; 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; } @@ -97,6 +99,12 @@ export const useDragGesture = ( if (!isDragAllowedSV.value) return; isDragAllowedSV.value = false; // Lock — released in onSnapComplete + // 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; const rootRelX = event.absoluteX - rootOffset.x; @@ -151,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 @@ -245,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; @@ -255,22 +263,39 @@ 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 }; + } + } } } } } - // 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)( - oldReceiver, - candidateReceiverId, - hitTestPos, - draggedIdSV.value, - startPositionSV.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'; @@ -299,16 +324,19 @@ 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 - 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; @@ -327,8 +355,9 @@ export const useDragGesture = ( dragPhaseSV.value = 'releasing'; receiverIdSV.value = ''; + if (sortableWorklet) sortableWorklet.isDraggingSV.value = false; - 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/useSortableList.ts b/src/hooks/useSortableList.ts index 5a1f158..f61de91 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, @@ -23,7 +24,7 @@ import type { SortableAnimationConfig, SortableReorderStrategy, } from '../types'; -import { packGrid } from '../math'; +import { packFlex, packGrid } from '../math'; import { useDraxId } from './useDraxId'; // ─── Public Types ───────────────────────────────────────────────────── @@ -53,6 +54,10 @@ export interface UseSortableListOptions { 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 { @@ -85,10 +90,14 @@ 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; 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>; @@ -97,6 +106,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>>; @@ -105,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 }[]>; @@ -120,9 +134,12 @@ export interface SortableListInternal { cellShiftRecordSV: SharedValue>>; 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: ( @@ -154,10 +171,21 @@ 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; + /** 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. */ + 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; @@ -192,6 +220,8 @@ export const useSortableList = ( drawDistance = 250, getItemSpan, gridGap = 0, + flexWrap = false, + getItemSize, } = options; const id = useDraxId(options.id); @@ -201,20 +231,35 @@ 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); + const scrollContainerOffsetRef = useRef({ x: 0, y: 0 }); const containerWidthRef = useRef(0); const dataRef = useRef(externalData); const keyToIndexRef = useRef>(new Map()); 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 ); + 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; + + // ── Shadow data (maintained alongside Maps for O(1) worklet sync at drag start) ── + const basePositionsRecordRef = useRef>({}); + const itemHeightsRecordRef = useRef>({}); // ── SharedValues ── const shiftsSV = useSharedValue>({}); @@ -229,7 +274,11 @@ 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); + /** 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 }); @@ -243,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>()); // Shadow Record maintained incrementally — avoids full Map-to-Record rebuild on register/unregister @@ -257,7 +326,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)) { @@ -272,8 +341,17 @@ export const useSortableList = ( } } - // Incremental update: assign shadow Record (already has the new key) - cellShiftRecordSV.value = { ...cellShiftRecordRef.current }; + // Rebuild worklet Record from current registry + 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) => { @@ -287,22 +365,42 @@ 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: use shadow Record (already maintained incrementally) - cellShiftRecordSV.value = { ...cellShiftRecordRef.current }; + 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; + + // 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. */ @@ -321,14 +419,6 @@ export const useSortableList = ( // Cell shift record SV — worklet needs Record access (not Map) const cellShiftRecordSV = useSharedValue>>({}); - // ── Drag state refs ── - const isDraggingRef = useRef(false); - const dragStartIndexRef = useRef(0); - const currentSlotRef = useRef(0); - const frozenBoundariesRef = useRef([]); - /** Set during render when cross-container adds new keys. Cleared in useLayoutEffect. */ - const pendingShiftClearRef = useRef(false); - // ── Layout helpers ── // Pooled Maps for computeGridPositions — reused across calls to avoid allocation @@ -348,8 +438,39 @@ export const useSortableList = ( recomputeBasePositions(); } - /** Compute pixel positions from keys using packGrid (mixed-size) or modulo (uniform). - * Returns pooled Maps — caller must read before next call (Maps are reused). */ + + // ── 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); + /** 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); + const dragStartIndexRef = useRef(0); + const currentSlotRef = useRef(0); + 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); + /** 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 ── + + /** Compute pixel positions from keys using packGrid (mixed-size) or modulo (uniform). */ + function computeGridPositions(keys: string[]) { const cw = containerWidthRef.current; const cellSize = cw > 0 @@ -361,6 +482,28 @@ export const useSortableList = ( positions.clear(); dimensions.clear(); + // 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 @@ -393,19 +536,23 @@ 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) { // 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); @@ -415,9 +562,29 @@ 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; + // 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) { - const h = heights.get(key) ?? estimatedItemSize; + 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 }); @@ -427,11 +594,34 @@ export const useSortableList = ( } cursor += h; } + sortedPositionsRef.current = sorted; 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). */ + /** 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); @@ -439,29 +629,76 @@ export const useSortableList = ( basePositionsRef.current = new Map(result.positions); itemDimensionsRef.current = new Map(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; } - /** 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). + /** 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() { + // 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. */ + 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. 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(); @@ -474,44 +711,151 @@ 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. 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; - // 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. + // 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). pendingShiftClearRef.current = true; } } - // 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) ── 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 dragKeyUnchanged = currentDragKey === frozenDragKeyRef.current; + + // 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; + } - 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]); + // 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; + 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, + }; + }); + 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; + 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; + } 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, flexWrap]); const getSlotFromPosition = useCallback((contentX: number, contentY: number): number => { const boundaries = frozenBoundariesRef.current; @@ -519,8 +863,68 @@ 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) { - // 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++) { @@ -553,7 +957,7 @@ export const useSortableList = ( } } return boundaries.length - 1; - }, [numColumns, horizontal]); + }, [numColumns, horizontal, flexWrap]); // ── Worklet: slot detection (runs on UI thread) ── @@ -562,10 +966,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; @@ -577,6 +995,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]!; @@ -684,8 +1103,18 @@ export const useSortableList = ( } } shiftsSV.value = newShifts; // Keep for JS-thread reads (visibility, snap) - // Copy pooled Maps — callers may hold the reference across future computeGridPositions calls - return { positions: new Map(result.positions), dimensions: new Map(result.dimensions), totalHeight: result.totalHeight }; + // 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; + } + // 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]); // ── Board integration: remove/insert keys for cross-container ── @@ -722,9 +1151,11 @@ export const useSortableList = ( } const removeKey = useCallback((key: string) => { - const keys = orderedKeysRef.current; - const idx = keys.indexOf(key); - if (idx >= 0) keys.splice(idx, 1); + 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(); }, []); @@ -735,6 +1166,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(); }, []); @@ -766,12 +1213,25 @@ 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; + // 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 = ''; - - // 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, @@ -798,9 +1258,11 @@ export const useSortableList = ( orderedKeysRef, basePositionsRef, itemHeightsRef, + measuredAvgHeightRef, itemCrossAxisRef, totalContentSizeRef, containerMeasRef, + scrollContainerOffsetRef, containerWidthRef, dataRef, keyExtractorRef, @@ -808,12 +1270,16 @@ export const useSortableList = ( renderItemRef, itemDimensionsRef, getItemSpanRef, + sortedPositionsRef, shiftsSV, draggedKeySV, scrollOffsetSV, skipShiftAnimationSV, + registerCellBase, + unregisterCellBase, registerCellShift, unregisterCellShift, + pushBasePositionsToSVs, frozenBoundariesSV, orderedKeysSV, basePositionsSV, @@ -822,8 +1288,11 @@ export const useSortableList = ( isDraggingSV, containerMeasSV, cellShiftRecordSV, + cumulativeEndsSV, + snapTargetSV, syncRefsToWorklet, syncWorkletToRefs, + syncPositionsToWorklet, getSlotFromPositionWorklet, recomputeShiftsWorklet, dropIndicatorPositionSV, @@ -836,8 +1305,13 @@ export const useSortableList = ( currentSlotRef, frozenBoundariesRef, pendingShiftClearRef, + echoSkipRef, + frozenGridResultRef, + snapTargetPositionRef, + recordItemHeight, computeGridPositions, recomputeBasePositions, + clearShifts, recomputeBasePositionsAndClearShifts, freezeSlotBoundaries, getSlotFromPosition, 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/index.ts b/src/index.ts index 1fb63c8..2a6d2a0 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 { @@ -43,6 +43,7 @@ export type { DraxRenderContentProps, DraxRenderHoverContentProps, DraxStyleProp, + AnimatedViewStylePropWithoutLayout, DraxViewStyleProps, DraxViewRenderContent, DraxViewRenderHoverContent, diff --git a/src/math.ts b/src/math.ts index 28c8eef..efdce66 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,74 @@ 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 }; +} + +// ─── 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 deleted file mode 100644 index 01215dd..0000000 --- a/src/types.ts +++ /dev/null @@ -1,939 +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) ───────────────────────────────────────────── - -/** 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; -} - -// ─── 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 runOnJS from gesture) ─ - handleDragStart: ( - draggedId: string, - absolutePosition: Position, - grabOffset: Position - ) => void; - handleReceiverChange: ( - oldReceiverId: string, - newReceiverId: string, - absolutePosition: Position, - draggedId: string, - startPosition: 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; - /** 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; - 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..abd9b9f --- /dev/null +++ b/src/types/view.ts @@ -0,0 +1,249 @@ +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; + /** @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; +}