react-native-smart-grid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +554 -0
- package/lib/module/components/DragLayer.js +71 -0
- package/lib/module/components/DragLayer.js.map +1 -0
- package/lib/module/components/DraggableTile.js +79 -0
- package/lib/module/components/DraggableTile.js.map +1 -0
- package/lib/module/components/GhostTile.js +37 -0
- package/lib/module/components/GhostTile.js.map +1 -0
- package/lib/module/components/GridTile.js +25 -0
- package/lib/module/components/GridTile.js.map +1 -0
- package/lib/module/components/ResizeHandle.js +72 -0
- package/lib/module/components/ResizeHandle.js.map +1 -0
- package/lib/module/components/SmartGrid.js +363 -0
- package/lib/module/components/SmartGrid.js.map +1 -0
- package/lib/module/context/GridDragContext.js +130 -0
- package/lib/module/context/GridDragContext.js.map +1 -0
- package/lib/module/engine/GridEngine.js +148 -0
- package/lib/module/engine/GridEngine.js.map +1 -0
- package/lib/module/engine/autoArrange.js +54 -0
- package/lib/module/engine/autoArrange.js.map +1 -0
- package/lib/module/engine/collisions.js +67 -0
- package/lib/module/engine/collisions.js.map +1 -0
- package/lib/module/hooks/useTileGesture.js +62 -0
- package/lib/module/hooks/useTileGesture.js.map +1 -0
- package/lib/module/index.js +9 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/layout/LayoutCalculator.js +29 -0
- package/lib/module/layout/LayoutCalculator.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/utils/pixelToGrid.js +22 -0
- package/lib/module/utils/pixelToGrid.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/components/DragLayer.d.ts +11 -0
- package/lib/typescript/src/components/DragLayer.d.ts.map +1 -0
- package/lib/typescript/src/components/DraggableTile.d.ts +14 -0
- package/lib/typescript/src/components/DraggableTile.d.ts.map +1 -0
- package/lib/typescript/src/components/GhostTile.d.ts +9 -0
- package/lib/typescript/src/components/GhostTile.d.ts.map +1 -0
- package/lib/typescript/src/components/GridTile.d.ts +9 -0
- package/lib/typescript/src/components/GridTile.d.ts.map +1 -0
- package/lib/typescript/src/components/ResizeHandle.d.ts +9 -0
- package/lib/typescript/src/components/ResizeHandle.d.ts.map +1 -0
- package/lib/typescript/src/components/SmartGrid.d.ts +214 -0
- package/lib/typescript/src/components/SmartGrid.d.ts.map +1 -0
- package/lib/typescript/src/context/GridDragContext.d.ts +44 -0
- package/lib/typescript/src/context/GridDragContext.d.ts.map +1 -0
- package/lib/typescript/src/engine/GridEngine.d.ts +35 -0
- package/lib/typescript/src/engine/GridEngine.d.ts.map +1 -0
- package/lib/typescript/src/engine/autoArrange.d.ts +4 -0
- package/lib/typescript/src/engine/autoArrange.d.ts.map +1 -0
- package/lib/typescript/src/engine/collisions.d.ts +3 -0
- package/lib/typescript/src/engine/collisions.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useTileGesture.d.ts +13 -0
- package/lib/typescript/src/hooks/useTileGesture.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +10 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/layout/LayoutCalculator.d.ts +15 -0
- package/lib/typescript/src/layout/LayoutCalculator.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +105 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/utils/pixelToGrid.d.ts +9 -0
- package/lib/typescript/src/utils/pixelToGrid.d.ts.map +1 -0
- package/package.json +161 -0
- package/src/components/DragLayer.tsx +71 -0
- package/src/components/DraggableTile.tsx +88 -0
- package/src/components/GhostTile.tsx +42 -0
- package/src/components/GridTile.tsx +27 -0
- package/src/components/ResizeHandle.tsx +74 -0
- package/src/components/SmartGrid.tsx +506 -0
- package/src/context/GridDragContext.tsx +191 -0
- package/src/engine/GridEngine.ts +148 -0
- package/src/engine/autoArrange.ts +59 -0
- package/src/engine/collisions.ts +87 -0
- package/src/hooks/useTileGesture.ts +88 -0
- package/src/index.tsx +29 -0
- package/src/layout/LayoutCalculator.ts +50 -0
- package/src/types.ts +113 -0
- package/src/utils/pixelToGrid.ts +31 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import {
|
|
10
|
+
ScrollView,
|
|
11
|
+
StyleSheet,
|
|
12
|
+
View,
|
|
13
|
+
type NativeScrollEvent,
|
|
14
|
+
type NativeSyntheticEvent,
|
|
15
|
+
} from 'react-native';
|
|
16
|
+
import type { Tile, PlacedTile, GridConfig, CollisionBehavior, Gravity, TilePosition, TileSize, HapticEvent, LayoutItem } from '../types';
|
|
17
|
+
import { GridEngine } from '../engine/GridEngine';
|
|
18
|
+
import { resolveCollisions } from '../engine/collisions';
|
|
19
|
+
import { autoArrange as autoArrangeAlgo, applyGravity } from '../engine/autoArrange';
|
|
20
|
+
import {
|
|
21
|
+
tileToPixelRect,
|
|
22
|
+
gridTotalHeight,
|
|
23
|
+
isInViewport,
|
|
24
|
+
} from '../layout/LayoutCalculator';
|
|
25
|
+
import { GridDragProvider, useGridDrag } from '../context/GridDragContext';
|
|
26
|
+
import { DraggableTile } from './DraggableTile';
|
|
27
|
+
import { GhostTile } from './GhostTile';
|
|
28
|
+
import { DragLayer } from './DragLayer';
|
|
29
|
+
|
|
30
|
+
/** Imperative handle exposed via `ref`. */
|
|
31
|
+
export type SmartGridRef = {
|
|
32
|
+
/** Re-packs all tiles using largest-first bin-packing. Fires `onLayoutChange`. */
|
|
33
|
+
autoArrange: () => void;
|
|
34
|
+
/** Returns a plain `LayoutItem[]` you can JSON.stringify and save anywhere. Does not include tile `data`. */
|
|
35
|
+
serializeLayout: () => LayoutItem[];
|
|
36
|
+
/** Restores a previously serialized layout. Fires `onLayoutChange`. */
|
|
37
|
+
restoreLayout: (layout: LayoutItem[]) => void;
|
|
38
|
+
/** Clears the entire selection. Equivalent to `setSelection([])`. */
|
|
39
|
+
clearSelection: () => void;
|
|
40
|
+
/** Programmatically set the selected tile IDs. Pass `[]` to clear. Fires `onSelectionChange`. */
|
|
41
|
+
setSelection: (ids: string[]) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Argument passed to `renderTile`. */
|
|
45
|
+
export type RenderTileInfo<TData> = {
|
|
46
|
+
/** The tile being rendered, including your custom `data`. */
|
|
47
|
+
item: Tile<TData>;
|
|
48
|
+
/** `true` while this tile is actively being dragged (the placeholder left behind). */
|
|
49
|
+
isActive: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* `true` when this tile is in the current selection.
|
|
52
|
+
* Selection is entered via long press — either immediately (when `draggable={false}`)
|
|
53
|
+
* or on release without moving (when draggable). Use to show contextual actions like a delete button.
|
|
54
|
+
*/
|
|
55
|
+
isSelected: boolean;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Props for `SmartGrid`.
|
|
60
|
+
*
|
|
61
|
+
* `position` and `size` on each tile are optional:
|
|
62
|
+
* - Both provided → tile is placed exactly where specified.
|
|
63
|
+
* - `size` only → tile is auto-placed via bin-packing.
|
|
64
|
+
* - Neither → tile defaults to 1×1 and is auto-placed in order.
|
|
65
|
+
*/
|
|
66
|
+
export type SmartGridProps<TData = unknown> = {
|
|
67
|
+
/** Array of tiles to render. `position` and `size` are optional — omit them to let the grid auto-place. */
|
|
68
|
+
data: Tile<TData>[];
|
|
69
|
+
/** Render function for each tile. Return any React Native view — SmartGrid handles absolute positioning. */
|
|
70
|
+
renderTile: (info: RenderTileInfo<TData>) => React.ReactNode;
|
|
71
|
+
|
|
72
|
+
/** Number of grid columns. @default 4 */
|
|
73
|
+
columns?: number;
|
|
74
|
+
/** Height of one grid row in pixels. @default 100 */
|
|
75
|
+
rowHeight?: number;
|
|
76
|
+
/** Gap between tiles in pixels. @default 8 */
|
|
77
|
+
gap?: number;
|
|
78
|
+
/** Outer padding of the grid in pixels. @default 8 */
|
|
79
|
+
padding?: number;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How dropped tiles interact with tiles already occupying the target space.
|
|
83
|
+
* - `'push'` — displaced tiles are moved to the next available slot.
|
|
84
|
+
* - `'swap'` — dragged tile and the tile at the drop center exchange positions.
|
|
85
|
+
* @default 'push'
|
|
86
|
+
*/
|
|
87
|
+
collisionBehavior?: CollisionBehavior;
|
|
88
|
+
/**
|
|
89
|
+
* Compact tiles toward the origin after every drop.
|
|
90
|
+
* - `'none'` — tiles stay where dropped.
|
|
91
|
+
* - `'up'` — tiles slide upward to fill empty rows.
|
|
92
|
+
* - `'left'` — tiles slide left to fill empty columns.
|
|
93
|
+
* @default 'none'
|
|
94
|
+
*/
|
|
95
|
+
gravity?: Gravity;
|
|
96
|
+
/** When `true`, shows a resize handle on each tile's bottom-right corner. @default false */
|
|
97
|
+
isEditing?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Master switch — when `false`, no tile in the grid can be dragged regardless of individual `tile.draggable` flags.
|
|
100
|
+
* @default true
|
|
101
|
+
*/
|
|
102
|
+
draggable?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Master switch — when `false`, no tile in the grid can be selected regardless of individual `tile.selectable` flags.
|
|
105
|
+
* @default true
|
|
106
|
+
*/
|
|
107
|
+
selectable?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* When `true`, long-pressing multiple tiles adds them all to the selection array.
|
|
110
|
+
* When `false`, selecting a tile deselects all others (single-select mode).
|
|
111
|
+
* @default true
|
|
112
|
+
*/
|
|
113
|
+
multiSelect?: boolean;
|
|
114
|
+
|
|
115
|
+
/** Called after every drag, drop, or resize with the updated `LayoutItem[]`. Merge this back into your state to keep the grid in sync. */
|
|
116
|
+
onLayoutChange?: (layout: LayoutItem[]) => void;
|
|
117
|
+
/** Called when the user taps a tile (quick press, no drag). Use this to open detail views, modals, folders, etc. */
|
|
118
|
+
onTilePress?: (tile: Tile<TData>) => void;
|
|
119
|
+
/** Called when the user begins dragging a tile (fires after the 300ms long-press activates, before the first move). */
|
|
120
|
+
onTileDragStart?: (tile: Tile<TData>) => void;
|
|
121
|
+
/** Called when a tile is dropped at a new position. */
|
|
122
|
+
onTileDrop?: (tile: Tile<TData>, position: TilePosition) => void;
|
|
123
|
+
/** Called when a tile is resized via the resize handle. */
|
|
124
|
+
onTileResize?: (tile: Tile<TData>, newSize: TileSize) => void;
|
|
125
|
+
/**
|
|
126
|
+
* Called whenever the selection array changes. Receives the full array of currently selected tile IDs.
|
|
127
|
+
*
|
|
128
|
+
* Selection changes when:
|
|
129
|
+
* - Long press on a tile toggles it (immediately if `draggable={false}`, on release otherwise).
|
|
130
|
+
* - Tap on a tile while selection is active toggles it.
|
|
131
|
+
* - A real drag-and-drop clears the entire selection.
|
|
132
|
+
* - `clearSelection()` or `setSelection()` is called on the ref.
|
|
133
|
+
*/
|
|
134
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
135
|
+
/**
|
|
136
|
+
* Called at key interaction moments so you can trigger haptic feedback
|
|
137
|
+
* with whichever haptics library you prefer — no dependency added.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* onHaptic={(event) => {
|
|
141
|
+
* if (event === 'pick-up') HapticFeedback.trigger('impactMedium');
|
|
142
|
+
* if (event === 'snap') HapticFeedback.trigger('selection');
|
|
143
|
+
* if (event === 'drop') HapticFeedback.trigger('notificationSuccess');
|
|
144
|
+
* if (event === 'resize') HapticFeedback.trigger('impactLight');
|
|
145
|
+
* }}
|
|
146
|
+
*/
|
|
147
|
+
onHaptic?: (event: HapticEvent) => void;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// ── Normalization ─────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
function normalizeTiles<TData>(tiles: Tile<TData>[], columns: number): PlacedTile<TData>[] {
|
|
153
|
+
const sized = tiles.map((t) => ({ ...t, size: t.size ?? { w: 1, h: 1 } }));
|
|
154
|
+
const withPos = sized.filter((t): t is typeof t & { position: TilePosition } => t.position != null);
|
|
155
|
+
const withoutPos = sized.filter((t) => t.position == null);
|
|
156
|
+
|
|
157
|
+
if (withoutPos.length === 0) return withPos as PlacedTile<TData>[];
|
|
158
|
+
|
|
159
|
+
const engine = new GridEngine(columns);
|
|
160
|
+
for (const t of withPos) engine.placeAt(t.id, t.position, t.size);
|
|
161
|
+
|
|
162
|
+
const result: PlacedTile<TData>[] = [...(withPos as PlacedTile<TData>[])];
|
|
163
|
+
for (const t of withoutPos) {
|
|
164
|
+
const pos = engine.findFirstFit(t.size);
|
|
165
|
+
if (pos) {
|
|
166
|
+
engine.placeAt(t.id, pos, t.size);
|
|
167
|
+
result.push({ ...t, position: pos } as PlacedTile<TData>);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Inner component (has access to GridDragContext) ───────────────────────────
|
|
174
|
+
|
|
175
|
+
type InnerProps<TData> = Omit<SmartGridProps<TData>, 'data'> & { data: PlacedTile<TData>[] };
|
|
176
|
+
|
|
177
|
+
function SmartGridInner<TData>({
|
|
178
|
+
data,
|
|
179
|
+
renderTile,
|
|
180
|
+
columns = 4,
|
|
181
|
+
rowHeight = 100,
|
|
182
|
+
gap = 8,
|
|
183
|
+
padding = 8,
|
|
184
|
+
}: InnerProps<TData>) {
|
|
185
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
186
|
+
const [viewportHeight, setViewportHeight] = useState(0);
|
|
187
|
+
const scrollTickRef = useRef(0);
|
|
188
|
+
const [scrollTick, setScrollTick] = useState(0);
|
|
189
|
+
|
|
190
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
191
|
+
const containerRef = useRef<any>(null);
|
|
192
|
+
const drag = useGridDrag();
|
|
193
|
+
|
|
194
|
+
const config: GridConfig = useMemo(
|
|
195
|
+
() => ({ columns, rowHeight, gap, padding }),
|
|
196
|
+
[columns, rowHeight, gap, padding]
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const totalHeight = useMemo(() => gridTotalHeight(data, config), [data, config]);
|
|
200
|
+
|
|
201
|
+
const handleLayout = useCallback(
|
|
202
|
+
(e: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
|
203
|
+
setContainerWidth(e.nativeEvent.layout.width);
|
|
204
|
+
setViewportHeight(e.nativeEvent.layout.height);
|
|
205
|
+
containerRef.current?.measure(
|
|
206
|
+
(_x: number, _y: number, _w: number, _h: number, pageX: number, pageY: number) => {
|
|
207
|
+
drag.containerPageX.current = pageX;
|
|
208
|
+
drag.containerPageY.current = pageY;
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
[drag]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const handleScroll = useCallback(
|
|
216
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
217
|
+
drag.scrollYRef.current = e.nativeEvent.contentOffset.y;
|
|
218
|
+
scrollTickRef.current += 1;
|
|
219
|
+
setScrollTick(scrollTickRef.current);
|
|
220
|
+
},
|
|
221
|
+
[drag]
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
// Compute rect once per tile and keep it alongside the tile — avoids a second
|
|
225
|
+
// tileToPixelRect call in the map below.
|
|
226
|
+
const visibleTiles = useMemo(() => {
|
|
227
|
+
if (containerWidth === 0) return [];
|
|
228
|
+
const result: Array<{ tile: PlacedTile<TData>; rect: ReturnType<typeof tileToPixelRect> }> = [];
|
|
229
|
+
for (const tile of data) {
|
|
230
|
+
const rect = tileToPixelRect(tile.position, tile.size, config, containerWidth);
|
|
231
|
+
if (isInViewport(rect, drag.scrollYRef.current, viewportHeight)) {
|
|
232
|
+
result.push({ tile, rect });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result;
|
|
236
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
237
|
+
}, [data, config, containerWidth, viewportHeight, scrollTick]);
|
|
238
|
+
|
|
239
|
+
// Set for O(1) isSelected lookup instead of O(n) Array.includes per rendered tile
|
|
240
|
+
const selectedSet = useMemo(
|
|
241
|
+
() => new Set(drag.selectedTileIds),
|
|
242
|
+
[drag.selectedTileIds]
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<View style={styles.root} ref={containerRef} onLayout={handleLayout}>
|
|
247
|
+
<ScrollView
|
|
248
|
+
onScroll={handleScroll}
|
|
249
|
+
scrollEventThrottle={16}
|
|
250
|
+
scrollEnabled={!drag.activeTile}
|
|
251
|
+
style={styles.scroll}
|
|
252
|
+
>
|
|
253
|
+
<View style={[styles.canvas, { height: totalHeight }]}>
|
|
254
|
+
{containerWidth > 0 &&
|
|
255
|
+
visibleTiles.map(({ tile, rect }) => {
|
|
256
|
+
const isActive = drag.activeTile?.id === tile.id;
|
|
257
|
+
const isSelected = selectedSet.has(tile.id);
|
|
258
|
+
return (
|
|
259
|
+
<DraggableTile
|
|
260
|
+
key={tile.id}
|
|
261
|
+
tile={tile}
|
|
262
|
+
rect={rect}
|
|
263
|
+
config={config}
|
|
264
|
+
containerWidth={containerWidth}
|
|
265
|
+
>
|
|
266
|
+
{renderTile({ item: tile, isActive, isSelected })}
|
|
267
|
+
</DraggableTile>
|
|
268
|
+
);
|
|
269
|
+
})}
|
|
270
|
+
{drag.activeTile && containerWidth > 0 && (
|
|
271
|
+
<GhostTile
|
|
272
|
+
activeTile={drag.activeTile}
|
|
273
|
+
config={config}
|
|
274
|
+
containerWidth={containerWidth}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
</View>
|
|
278
|
+
</ScrollView>
|
|
279
|
+
|
|
280
|
+
{drag.activeTile && drag.initialRect && (
|
|
281
|
+
<DragLayer tile={drag.activeTile} initialRect={drag.initialRect}>
|
|
282
|
+
{renderTile({ item: drag.activeTile as Tile<TData>, isActive: true, isSelected: false })}
|
|
283
|
+
</DragLayer>
|
|
284
|
+
)}
|
|
285
|
+
</View>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Public component (provides context + ref handle) ─────────────────────────
|
|
290
|
+
|
|
291
|
+
function SmartGridWithRef<TData = unknown>(
|
|
292
|
+
props: SmartGridProps<TData>,
|
|
293
|
+
ref: React.ForwardedRef<SmartGridRef>
|
|
294
|
+
) {
|
|
295
|
+
const columns = props.columns ?? 4;
|
|
296
|
+
const gravity = props.gravity ?? 'none';
|
|
297
|
+
|
|
298
|
+
const [selectedTileIds, setSelectedTileIds] = useState<string[]>([]);
|
|
299
|
+
|
|
300
|
+
const normalizedData = useMemo(
|
|
301
|
+
() => normalizeTiles(props.data, columns),
|
|
302
|
+
[props.data, columns]
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const handleSelect = useCallback(
|
|
306
|
+
(ids: string[]) => {
|
|
307
|
+
setSelectedTileIds(ids);
|
|
308
|
+
props.onSelectionChange?.(ids);
|
|
309
|
+
},
|
|
310
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
311
|
+
[props.onSelectionChange]
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
useImperativeHandle(
|
|
315
|
+
ref,
|
|
316
|
+
() => ({
|
|
317
|
+
autoArrange() {
|
|
318
|
+
const arranged = autoArrangeAlgo(normalizedData as PlacedTile[], columns);
|
|
319
|
+
props.onLayoutChange?.(
|
|
320
|
+
arranged.map((t) => ({ id: t.id, position: t.position, size: t.size }))
|
|
321
|
+
);
|
|
322
|
+
},
|
|
323
|
+
serializeLayout() {
|
|
324
|
+
return normalizedData.map((t) => ({ id: t.id, position: t.position, size: t.size }));
|
|
325
|
+
},
|
|
326
|
+
restoreLayout(layout) {
|
|
327
|
+
props.onLayoutChange?.(layout);
|
|
328
|
+
},
|
|
329
|
+
clearSelection() {
|
|
330
|
+
handleSelect([]);
|
|
331
|
+
},
|
|
332
|
+
setSelection(ids: string[]) {
|
|
333
|
+
handleSelect(ids);
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
337
|
+
[normalizedData, columns, props.onLayoutChange, handleSelect]
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const handleDrop = useCallback(
|
|
341
|
+
(tile: PlacedTile, newPosition: TilePosition) => {
|
|
342
|
+
const afterCollision = resolveCollisions(
|
|
343
|
+
normalizedData as PlacedTile[],
|
|
344
|
+
tile.id,
|
|
345
|
+
newPosition,
|
|
346
|
+
props.collisionBehavior ?? 'push',
|
|
347
|
+
columns
|
|
348
|
+
);
|
|
349
|
+
const afterGravity = applyGravity(afterCollision, columns, gravity);
|
|
350
|
+
const newLayout = afterGravity.map((t) => ({ id: t.id, position: t.position, size: t.size }));
|
|
351
|
+
|
|
352
|
+
const layoutChanged = newLayout.some((item) => {
|
|
353
|
+
const prev = normalizedData.find((t) => t.id === item.id);
|
|
354
|
+
return !prev || prev.position.x !== item.position.x || prev.position.y !== item.position.y;
|
|
355
|
+
});
|
|
356
|
+
if (!layoutChanged) return;
|
|
357
|
+
|
|
358
|
+
props.onTileDrop?.(tile as Tile<TData>, newPosition);
|
|
359
|
+
props.onLayoutChange?.(newLayout);
|
|
360
|
+
},
|
|
361
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
362
|
+
[normalizedData, props.collisionBehavior, columns, gravity, props.onTileDrop, props.onLayoutChange]
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
const handleResize = useCallback(
|
|
366
|
+
(tile: PlacedTile, newSize: TileSize) => {
|
|
367
|
+
const withNewSize = normalizedData.map((t) =>
|
|
368
|
+
t.id === tile.id ? { ...t, size: newSize } : t
|
|
369
|
+
);
|
|
370
|
+
const afterCollision = resolveCollisions(
|
|
371
|
+
withNewSize as PlacedTile[],
|
|
372
|
+
tile.id,
|
|
373
|
+
(tile as PlacedTile).position,
|
|
374
|
+
props.collisionBehavior ?? 'push',
|
|
375
|
+
columns
|
|
376
|
+
);
|
|
377
|
+
const newLayout = afterCollision.map((t) => ({ id: t.id, position: t.position, size: t.size }));
|
|
378
|
+
props.onTileResize?.(tile as Tile<TData>, newSize);
|
|
379
|
+
props.onLayoutChange?.(newLayout);
|
|
380
|
+
},
|
|
381
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
382
|
+
[normalizedData, props.collisionBehavior, columns, props.onTileResize, props.onLayoutChange]
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<GridDragProvider
|
|
387
|
+
isEditing={props.isEditing ?? false}
|
|
388
|
+
draggable={props.draggable ?? true}
|
|
389
|
+
selectable={props.selectable ?? true}
|
|
390
|
+
multiSelect={props.multiSelect ?? true}
|
|
391
|
+
selectedTileIds={selectedTileIds}
|
|
392
|
+
onDrop={handleDrop}
|
|
393
|
+
onResize={handleResize}
|
|
394
|
+
onSelect={handleSelect}
|
|
395
|
+
onTilePress={props.onTilePress as ((tile: PlacedTile) => void) | undefined}
|
|
396
|
+
onHaptic={props.onHaptic}
|
|
397
|
+
>
|
|
398
|
+
<SmartGridInner {...props} data={normalizedData} />
|
|
399
|
+
</GridDragProvider>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A draggable, variable-sized tile grid for React Native.
|
|
405
|
+
*
|
|
406
|
+
* ---
|
|
407
|
+
*
|
|
408
|
+
* ### Basic usage
|
|
409
|
+
* ```tsx
|
|
410
|
+
* const gridRef = useRef<SmartGridRef>(null);
|
|
411
|
+
*
|
|
412
|
+
* <SmartGrid
|
|
413
|
+
* ref={gridRef}
|
|
414
|
+
* data={tiles}
|
|
415
|
+
* columns={4}
|
|
416
|
+
* rowHeight={100}
|
|
417
|
+
* gap={8}
|
|
418
|
+
* collisionBehavior="push"
|
|
419
|
+
* gravity="up"
|
|
420
|
+
* isEditing={isEditing}
|
|
421
|
+
* onLayoutChange={(layout) =>
|
|
422
|
+
* setTiles(prev =>
|
|
423
|
+
* prev.map(t => {
|
|
424
|
+
* const updated = layout.find(l => l.id === t.id);
|
|
425
|
+
* return updated ? { ...t, ...updated } : t;
|
|
426
|
+
* })
|
|
427
|
+
* )
|
|
428
|
+
* }
|
|
429
|
+
* renderTile={({ item, isActive, isSelected }) => (
|
|
430
|
+
* <View style={{ flex: 1, opacity: isActive ? 0.5 : 1,
|
|
431
|
+
* borderWidth: isSelected ? 2 : 0, borderColor: '#fff' }}>
|
|
432
|
+
* <Text>{item.data.label}</Text>
|
|
433
|
+
* </View>
|
|
434
|
+
* )}
|
|
435
|
+
* />
|
|
436
|
+
* ```
|
|
437
|
+
*
|
|
438
|
+
* ---
|
|
439
|
+
*
|
|
440
|
+
* ### Selection (long press + release)
|
|
441
|
+
* Long-pressing a tile and releasing without moving selects it.
|
|
442
|
+
* Tapping any tile while a selection is active toggles that tile too.
|
|
443
|
+
*
|
|
444
|
+
* ```tsx
|
|
445
|
+
* // Multi-select (default) — accumulate selections
|
|
446
|
+
* <SmartGrid
|
|
447
|
+
* multiSelect // true by default, can omit
|
|
448
|
+
* onSelectionChange={(ids) => console.log('selected:', ids)}
|
|
449
|
+
* renderTile={({ item, isSelected }) => (
|
|
450
|
+
* <View>
|
|
451
|
+
* <Text>{item.data.label}</Text>
|
|
452
|
+
* {isSelected && <Text>✕ Delete</Text>}
|
|
453
|
+
* </View>
|
|
454
|
+
* )}
|
|
455
|
+
* />
|
|
456
|
+
*
|
|
457
|
+
* // Single-select — picking a new tile deselects the previous one
|
|
458
|
+
* <SmartGrid
|
|
459
|
+
* multiSelect={false}
|
|
460
|
+
* onSelectionChange={([id]) => setActive(id ?? null)}
|
|
461
|
+
* />
|
|
462
|
+
*
|
|
463
|
+
* // Clear selection imperatively
|
|
464
|
+
* gridRef.current?.clearSelection();
|
|
465
|
+
*
|
|
466
|
+
* // Or set it programmatically
|
|
467
|
+
* gridRef.current?.setSelection(['tile-1', 'tile-3']);
|
|
468
|
+
* ```
|
|
469
|
+
*
|
|
470
|
+
* ---
|
|
471
|
+
*
|
|
472
|
+
* ### Grid-level draggable / selectable master switches
|
|
473
|
+
* These override all per-tile flags when set to `false`.
|
|
474
|
+
* ```tsx
|
|
475
|
+
* // View-only mode — nothing can be dragged or selected
|
|
476
|
+
* <SmartGrid draggable={false} selectable={false} data={tiles} ... />
|
|
477
|
+
*
|
|
478
|
+
* // Read-only layout — tiles are visible but locked in place
|
|
479
|
+
* <SmartGrid draggable={false} data={tiles} ... />
|
|
480
|
+
*
|
|
481
|
+
* // Selection disabled — long press does nothing (onTilePress still fires on tap)
|
|
482
|
+
* <SmartGrid selectable={false} data={tiles} ... />
|
|
483
|
+
* ```
|
|
484
|
+
*
|
|
485
|
+
* ### Per-tile draggable / selectable flags
|
|
486
|
+
* Fine-grained control per tile when the grid-level switches are on.
|
|
487
|
+
* ```tsx
|
|
488
|
+
* const tiles: Tile<MyData>[] = [
|
|
489
|
+
* { id: '1', data: { label: 'Free' } },
|
|
490
|
+
* // Stays pinned in place — cannot be dragged
|
|
491
|
+
* { id: '2', data: { label: 'Pinned' }, draggable: false },
|
|
492
|
+
* // Ignored by selection — tapping fires onTilePress instead
|
|
493
|
+
* { id: '3', data: { label: 'Info' }, selectable: false },
|
|
494
|
+
* ];
|
|
495
|
+
* ```
|
|
496
|
+
*/
|
|
497
|
+
// Cast preserves the generic type parameter through forwardRef
|
|
498
|
+
export const SmartGrid = forwardRef(SmartGridWithRef) as <TData = unknown>(
|
|
499
|
+
props: SmartGridProps<TData> & { ref?: React.Ref<SmartGridRef> }
|
|
500
|
+
) => React.ReactElement | null;
|
|
501
|
+
|
|
502
|
+
const styles = StyleSheet.create({
|
|
503
|
+
root: { flex: 1 },
|
|
504
|
+
scroll: { flex: 1 },
|
|
505
|
+
canvas: { position: 'relative' },
|
|
506
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import React, { createContext, useContext, useRef, useState } from 'react';
|
|
2
|
+
import { useSharedValue } from 'react-native-reanimated';
|
|
3
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
4
|
+
import type { PlacedTile, TilePosition, TileSize, HapticEvent } from '../types';
|
|
5
|
+
import type { PixelRect } from '../layout/LayoutCalculator';
|
|
6
|
+
|
|
7
|
+
export type DragState = {
|
|
8
|
+
dragAbsX: SharedValue<number>;
|
|
9
|
+
dragAbsY: SharedValue<number>;
|
|
10
|
+
isDragging: SharedValue<boolean>;
|
|
11
|
+
|
|
12
|
+
activeTile: PlacedTile | null;
|
|
13
|
+
ghostPosition: TilePosition | null;
|
|
14
|
+
initialRect: PixelRect | null;
|
|
15
|
+
selectedTileIds: string[];
|
|
16
|
+
|
|
17
|
+
containerPageX: React.MutableRefObject<number>;
|
|
18
|
+
containerPageY: React.MutableRefObject<number>;
|
|
19
|
+
scrollYRef: React.MutableRefObject<number>;
|
|
20
|
+
|
|
21
|
+
startDrag: (tile: PlacedTile, rect: PixelRect) => void;
|
|
22
|
+
updateGhost: (pos: TilePosition) => void;
|
|
23
|
+
endDrag: (finalPosition: TilePosition | null) => void;
|
|
24
|
+
selectTile: (tile: PlacedTile) => void;
|
|
25
|
+
onTilePress: (tile: PlacedTile) => void;
|
|
26
|
+
|
|
27
|
+
isEditing: boolean;
|
|
28
|
+
/** Grid-level master switch — false disables drag on every tile. */
|
|
29
|
+
draggable: boolean;
|
|
30
|
+
/** Grid-level master switch — false disables selection on every tile. */
|
|
31
|
+
selectable: boolean;
|
|
32
|
+
commitResize: (tile: PlacedTile, newSize: TileSize) => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const GridDragContext = createContext<DragState | null>(null);
|
|
36
|
+
|
|
37
|
+
export function useGridDrag(): DragState {
|
|
38
|
+
const ctx = useContext(GridDragContext);
|
|
39
|
+
if (!ctx) throw new Error('useGridDrag must be used within SmartGrid');
|
|
40
|
+
return ctx;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Props = {
|
|
44
|
+
children: React.ReactNode;
|
|
45
|
+
isEditing: boolean;
|
|
46
|
+
draggable: boolean;
|
|
47
|
+
selectable: boolean;
|
|
48
|
+
multiSelect: boolean;
|
|
49
|
+
selectedTileIds: string[];
|
|
50
|
+
onDrop: (tile: PlacedTile, newPosition: TilePosition) => void;
|
|
51
|
+
onResize: (tile: PlacedTile, newSize: TileSize) => void;
|
|
52
|
+
onSelect: (ids: string[]) => void;
|
|
53
|
+
onTilePress?: (tile: PlacedTile) => void;
|
|
54
|
+
onHaptic?: (event: HapticEvent) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export function GridDragProvider({
|
|
58
|
+
children,
|
|
59
|
+
isEditing,
|
|
60
|
+
draggable,
|
|
61
|
+
selectable,
|
|
62
|
+
multiSelect,
|
|
63
|
+
selectedTileIds,
|
|
64
|
+
onDrop,
|
|
65
|
+
onResize,
|
|
66
|
+
onSelect,
|
|
67
|
+
onTilePress,
|
|
68
|
+
onHaptic,
|
|
69
|
+
}: Props) {
|
|
70
|
+
const dragAbsX = useSharedValue(0);
|
|
71
|
+
const dragAbsY = useSharedValue(0);
|
|
72
|
+
const isDragging = useSharedValue(false);
|
|
73
|
+
|
|
74
|
+
const [activeTile, setActiveTile] = useState<PlacedTile | null>(null);
|
|
75
|
+
const [ghostPosition, setGhostPosition] = useState<TilePosition | null>(null);
|
|
76
|
+
const [initialRect, setInitialRect] = useState<PixelRect | null>(null);
|
|
77
|
+
|
|
78
|
+
const ghostRef = useRef<TilePosition | null>(null);
|
|
79
|
+
const activeTileRef = useRef<PlacedTile | null>(null);
|
|
80
|
+
|
|
81
|
+
const containerPageX = useRef(0);
|
|
82
|
+
const containerPageY = useRef(0);
|
|
83
|
+
const scrollYRef = useRef(0);
|
|
84
|
+
|
|
85
|
+
function startDrag(tile: PlacedTile, rect: PixelRect) {
|
|
86
|
+
activeTileRef.current = tile;
|
|
87
|
+
ghostRef.current = tile.position;
|
|
88
|
+
isDragging.value = true;
|
|
89
|
+
setActiveTile(tile);
|
|
90
|
+
setInitialRect(rect);
|
|
91
|
+
setGhostPosition(tile.position);
|
|
92
|
+
onHaptic?.('pick-up');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function selectTile(tile: PlacedTile) {
|
|
96
|
+
const already = selectedTileIds.includes(tile.id);
|
|
97
|
+
if (already) {
|
|
98
|
+
onSelect(selectedTileIds.filter((id) => id !== tile.id));
|
|
99
|
+
} else if (multiSelect) {
|
|
100
|
+
onSelect([...selectedTileIds, tile.id]);
|
|
101
|
+
} else {
|
|
102
|
+
onSelect([tile.id]);
|
|
103
|
+
}
|
|
104
|
+
onHaptic?.('pick-up');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function updateGhost(pos: TilePosition) {
|
|
108
|
+
if (pos.x === ghostRef.current?.x && pos.y === ghostRef.current?.y) return;
|
|
109
|
+
ghostRef.current = pos;
|
|
110
|
+
setGhostPosition(pos);
|
|
111
|
+
onHaptic?.('snap');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function endDrag(finalPosition: TilePosition | null) {
|
|
115
|
+
const tile = activeTileRef.current;
|
|
116
|
+
isDragging.value = false;
|
|
117
|
+
activeTileRef.current = null;
|
|
118
|
+
ghostRef.current = null;
|
|
119
|
+
setActiveTile(null);
|
|
120
|
+
setGhostPosition(null);
|
|
121
|
+
setInitialRect(null);
|
|
122
|
+
onHaptic?.('drop');
|
|
123
|
+
|
|
124
|
+
if (!tile || !finalPosition) return;
|
|
125
|
+
|
|
126
|
+
const stationary =
|
|
127
|
+
finalPosition.x === tile.position.x && finalPosition.y === tile.position.y;
|
|
128
|
+
|
|
129
|
+
if (stationary && selectable && tile.selectable !== false) {
|
|
130
|
+
const already = selectedTileIds.includes(tile.id);
|
|
131
|
+
if (already) {
|
|
132
|
+
onSelect(selectedTileIds.filter((id) => id !== tile.id));
|
|
133
|
+
} else if (multiSelect) {
|
|
134
|
+
onSelect([...selectedTileIds, tile.id]);
|
|
135
|
+
} else {
|
|
136
|
+
onSelect([tile.id]);
|
|
137
|
+
}
|
|
138
|
+
} else if (!stationary) {
|
|
139
|
+
onSelect([]);
|
|
140
|
+
onDrop(tile, finalPosition);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function commitResize(tile: PlacedTile, newSize: TileSize) {
|
|
145
|
+
onHaptic?.('resize');
|
|
146
|
+
onResize(tile, newSize);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function handleTilePress(tile: PlacedTile) {
|
|
150
|
+
if (selectable && selectedTileIds.length > 0 && tile.selectable !== false) {
|
|
151
|
+
const already = selectedTileIds.includes(tile.id);
|
|
152
|
+
if (already) {
|
|
153
|
+
onSelect(selectedTileIds.filter((id) => id !== tile.id));
|
|
154
|
+
} else if (multiSelect) {
|
|
155
|
+
onSelect([...selectedTileIds, tile.id]);
|
|
156
|
+
} else {
|
|
157
|
+
onSelect([tile.id]);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
onTilePress?.(tile);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<GridDragContext.Provider
|
|
166
|
+
value={{
|
|
167
|
+
dragAbsX,
|
|
168
|
+
dragAbsY,
|
|
169
|
+
isDragging,
|
|
170
|
+
activeTile,
|
|
171
|
+
ghostPosition,
|
|
172
|
+
initialRect,
|
|
173
|
+
selectedTileIds,
|
|
174
|
+
containerPageX,
|
|
175
|
+
containerPageY,
|
|
176
|
+
scrollYRef,
|
|
177
|
+
startDrag,
|
|
178
|
+
updateGhost,
|
|
179
|
+
endDrag,
|
|
180
|
+
selectTile,
|
|
181
|
+
onTilePress: handleTilePress,
|
|
182
|
+
isEditing,
|
|
183
|
+
draggable,
|
|
184
|
+
selectable,
|
|
185
|
+
commitResize,
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
{children}
|
|
189
|
+
</GridDragContext.Provider>
|
|
190
|
+
);
|
|
191
|
+
}
|