Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions example/app/(tabs)/sortable-flex.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { clientScreen } from '../../components/ClientOnly';
export default clientScreen(() => import('../../screens/sortable-flex'), 'Sortable Flex');
8 changes: 8 additions & 0 deletions example/screens/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 6 additions & 3 deletions example/screens/mixed-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,12 @@ export default function MixedGrid() {
longPressDelay={200}
onReorder={({ data: newData }) => setData(newData)}
renderItem={({ item }) => (
<View style={[styles.tile, {
backgroundColor: itemColor(item.color, isDark),
}]}>
<View
testID={`mixed-tile-${item.id}`}
style={[styles.tile, {
backgroundColor: itemColor(item.color, isDark),
}]}
>
<Text style={[styles.tileText, { color: isDark ? '#e0e0e0' : '#333' }]}>
{item.label}
</Text>
Expand Down
74 changes: 53 additions & 21 deletions example/screens/reorderable-list.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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,
]);
}}
Expand All @@ -52,7 +65,9 @@ export default function ReorderableList() {
<Text style={styles.btnText}>+ Add Top</Text>
</Pressable>
<Pressable
onPress={() => data.length > 0 && setData(prev => prev.slice(1))}
onPress={() =>
data.length > 0 && setData((prev) => prev.slice(1))
}
style={styles.btn}
>
<Text style={styles.btnText}>- Remove Top</Text>
Expand All @@ -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 }) => (
<View style={[styles.item, {
height: item.height,
backgroundColor: itemColor(item.color, isDark),
}]}>
<Text style={[styles.itemText, { color: isDark ? '#e0e0e0' : '#333' }]}>
{item.label}
</Text>
<Text style={[styles.indexText, { color: isDark ? '#999' : '#666' }]}>
#{index} · {item.height}px
</Text>
</View>
<View
style={[
styles.item,
{
height: item.height,
backgroundColor: itemColor(item.color, isDark),
},
]}
>
<Text
style={[
styles.itemText,
{ color: isDark ? '#e0e0e0' : '#333' },
]}
>
{item.label}
</Text>
<Text
style={[styles.indexText, { color: isDark ? '#999' : '#666' }]}
>
#{index} · {item.height}px
</Text>
</View>
)}
style={styles.list}
/>
Expand All @@ -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: {
Expand Down
99 changes: 99 additions & 0 deletions example/screens/sortable-flex.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DraxProvider>
<View style={[styles.container, { backgroundColor: theme.bg }]}>
<View style={styles.header}>
<Text style={[styles.headerText, { color: theme.muted }]}>
Flex-wrap tags — drag to reorder
</Text>
</View>
<DraxList<Tag>
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 }) => (
<View
testID={`flex-tag-${item.id}`}
style={[styles.tag, {
backgroundColor: itemColor(item.color, isDark),
}]}
>
<Text style={[styles.tagText, { color: isDark ? '#e0e0e0' : '#333' }]}>
{item.label}
</Text>
</View>
)}
style={[styles.list, { paddingHorizontal: padding }]}
/>
</View>
</DraxProvider>
);
}

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' },
});
27 changes: 15 additions & 12 deletions src/DraxHandle.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,15 +15,10 @@ export function DraxHandle({ children, style }: DraxHandleProps) {
const ctx = use(DraxHandleContext);
const handleRef = useRef<any>(null);

if (!ctx) {
return (
<Reanimated.View style={style}>
{children}
</Reanimated.View>
);
}

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;
Expand All @@ -34,11 +29,19 @@ export function DraxHandle({ children, style }: DraxHandleProps) {
} catch {
// measureLayout can fail if views aren't mounted yet
}
};
});

if (!ctx) {
return (
<Reanimated.View style={style}>
{children}
</Reanimated.View>
);
}

return (
<GestureDetector gesture={ctx.gesture}>
<Reanimated.View ref={handleRef} style={style} onLayout={measureOffset}>
<Reanimated.View ref={handleRef} style={style}>
{children}
</Reanimated.View>
</GestureDetector>
Expand Down
Loading