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
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Admin
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
5
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
6
|
+
in the Software without restriction, including without limitation the rights
|
|
7
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
9
|
+
furnished to do so, subject to the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all
|
|
12
|
+
copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
17
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
18
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
19
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
20
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
# react-native-smart-grid
|
|
2
|
+
|
|
3
|
+
A draggable, variable-sized tile grid for React Native. Think iOS home screen meets Pinterest meets Trello — with collision detection, auto-arrange, multi-select, and smooth spring animations.
|
|
4
|
+
|
|
5
|
+
> **Nothing like this exists in the RN ecosystem.** Every other grid library uses uniform tile sizes. This one doesn't.
|
|
6
|
+
|
|
7
|
+
<!-- Add a GIF demo here before publishing -->
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Variable-sized tiles** — 1×1, 1×2, 2×2, 2×4, any grid unit combination
|
|
12
|
+
- **Drag to reorder** — long press to lift, pan to move, spring to land
|
|
13
|
+
- **Collision detection** — push or swap modes
|
|
14
|
+
- **Auto-arrange** — bin-packing algorithm, callable via ref
|
|
15
|
+
- **Gravity** — tiles compact upward or leftward after every drop
|
|
16
|
+
- **Resize handles** — drag the corner to resize any tile in edit mode
|
|
17
|
+
- **Multi-select** — long press enters selection mode; tap to add/remove tiles
|
|
18
|
+
- **Serialization** — save and restore layouts, storage-agnostic
|
|
19
|
+
- **Virtualized** — only renders tiles in the viewport, handles thousands of items
|
|
20
|
+
- **Haptic callbacks** — bring your own haptics library, zero dependencies added
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
npm install react-native-smart-grid
|
|
28
|
+
# or
|
|
29
|
+
yarn add react-native-smart-grid
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Peer dependencies
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install react-native-gesture-handler react-native-reanimated
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Follow the setup guides for each:
|
|
39
|
+
- [react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation)
|
|
40
|
+
- [react-native-reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started)
|
|
41
|
+
|
|
42
|
+
Wrap your app root (or at minimum the screen containing `SmartGrid`) with `GestureHandlerRootView`:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
46
|
+
|
|
47
|
+
export default function App() {
|
|
48
|
+
return (
|
|
49
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
50
|
+
{/* your app */}
|
|
51
|
+
</GestureHandlerRootView>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { useState } from 'react';
|
|
62
|
+
import { Text, View } from 'react-native';
|
|
63
|
+
import { SmartGrid } from 'react-native-smart-grid';
|
|
64
|
+
import type { Tile, LayoutItem } from 'react-native-smart-grid';
|
|
65
|
+
|
|
66
|
+
type CardData = { label: string; color: string };
|
|
67
|
+
|
|
68
|
+
// Simplest form — no position, no size. Every tile defaults to 1×1 and
|
|
69
|
+
// is auto-placed in order using bin-packing.
|
|
70
|
+
const TILES: Tile<CardData>[] = [
|
|
71
|
+
{ id: '1', data: { label: 'Music', color: '#6366f1' } },
|
|
72
|
+
{ id: '2', data: { label: 'Photos', color: '#f59e0b' } },
|
|
73
|
+
{ id: '3', data: { label: 'Notes', color: '#10b981' } },
|
|
74
|
+
{ id: '4', data: { label: 'Calendar', color: '#ef4444' } },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export default function App() {
|
|
78
|
+
const [tiles, setTiles] = useState(TILES);
|
|
79
|
+
|
|
80
|
+
function handleLayoutChange(layout: LayoutItem[]) {
|
|
81
|
+
setTiles(prev =>
|
|
82
|
+
prev.map(t => {
|
|
83
|
+
const updated = layout.find(l => l.id === t.id);
|
|
84
|
+
return updated ? { ...t, ...updated } : t;
|
|
85
|
+
})
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<SmartGrid
|
|
91
|
+
data={tiles}
|
|
92
|
+
columns={4}
|
|
93
|
+
rowHeight={100}
|
|
94
|
+
gap={8}
|
|
95
|
+
padding={12}
|
|
96
|
+
onLayoutChange={handleLayoutChange}
|
|
97
|
+
renderTile={({ item, isActive }) => (
|
|
98
|
+
<View style={{
|
|
99
|
+
flex: 1,
|
|
100
|
+
backgroundColor: item.data.color,
|
|
101
|
+
borderRadius: 12,
|
|
102
|
+
opacity: isActive ? 0.4 : 1,
|
|
103
|
+
}}>
|
|
104
|
+
<Text style={{ color: '#fff', padding: 8 }}>{item.data.label}</Text>
|
|
105
|
+
</View>
|
|
106
|
+
)}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Tile data model
|
|
115
|
+
|
|
116
|
+
`position` and `size` are both optional. SmartGrid auto-places any tile that is missing either.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// ── Option 1: explicit position + size ────────────────────────────────────────
|
|
120
|
+
// Full control. Use this when restoring a saved layout from storage/server.
|
|
121
|
+
const tiles: Tile<MyData>[] = [
|
|
122
|
+
{ id: '1', position: { x: 0, y: 0 }, size: { w: 2, h: 2 }, data: { label: 'Large' } },
|
|
123
|
+
{ id: '2', position: { x: 2, y: 0 }, size: { w: 2, h: 1 }, data: { label: 'Wide' } },
|
|
124
|
+
{ id: '3', position: { x: 2, y: 1 }, size: { w: 1, h: 1 }, data: { label: 'Small' } },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
// ── Option 2: size only, no position ──────────────────────────────────────────
|
|
128
|
+
// Grid auto-places each tile using bin-packing. Good when you know the sizes
|
|
129
|
+
// but don't care where tiles land initially.
|
|
130
|
+
const tiles: Tile<MyData>[] = [
|
|
131
|
+
{ id: '1', size: { w: 2, h: 2 }, data: { label: 'Large' } },
|
|
132
|
+
{ id: '2', size: { w: 2, h: 1 }, data: { label: 'Wide' } },
|
|
133
|
+
{ id: '3', size: { w: 1, h: 1 }, data: { label: 'Small' } },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
// ── Option 3: no position, no size ────────────────────────────────────────────
|
|
137
|
+
// Simplest form. Every tile defaults to 1×1 and is auto-placed in order.
|
|
138
|
+
const tiles: Tile<MyData>[] = [
|
|
139
|
+
{ id: '1', data: { label: 'Music' } },
|
|
140
|
+
{ id: '2', data: { label: 'Photos' } },
|
|
141
|
+
{ id: '3', data: { label: 'Notes' } },
|
|
142
|
+
];
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Props
|
|
148
|
+
|
|
149
|
+
| Prop | Type | Default | Description |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| `data` | `Tile<TData>[]` | **required** | Array of tiles. `position` and `size` are optional. |
|
|
152
|
+
| `renderTile` | `(info: RenderTileInfo) => ReactNode` | **required** | Render function for each tile. |
|
|
153
|
+
| `columns` | `number` | `4` | Number of grid columns. |
|
|
154
|
+
| `rowHeight` | `number` | `100` | Height of one grid row in pixels. |
|
|
155
|
+
| `gap` | `number` | `8` | Gap between tiles in pixels. |
|
|
156
|
+
| `padding` | `number` | `8` | Outer padding of the grid in pixels. |
|
|
157
|
+
| `collisionBehavior` | `'push' \| 'swap'` | `'push'` | How dropped tiles interact with others at the target position. |
|
|
158
|
+
| `gravity` | `'none' \| 'up' \| 'left'` | `'none'` | Compact tiles toward origin after each drop. |
|
|
159
|
+
| `isEditing` | `boolean` | `false` | Show resize handle on each tile's bottom-right corner. |
|
|
160
|
+
| `draggable` | `boolean` | `true` | Master switch — `false` disables drag on all tiles. |
|
|
161
|
+
| `selectable` | `boolean` | `true` | Master switch — `false` disables selection on all tiles. |
|
|
162
|
+
| `multiSelect` | `boolean` | `true` | `false` switches to single-select (new selection replaces old). |
|
|
163
|
+
| `onLayoutChange` | `(layout: LayoutItem[]) => void` | — | Fired after every drag, drop, or resize. Merge into your state. |
|
|
164
|
+
| `onTilePress` | `(tile: Tile) => void` | — | Quick tap (no drag). Open detail views, folders, etc. |
|
|
165
|
+
| `onTileDragStart` | `(tile: Tile) => void` | — | Fired when the 300ms long-press activates drag. |
|
|
166
|
+
| `onTileDrop` | `(tile, position) => void` | — | Fired when a tile lands at a new position. |
|
|
167
|
+
| `onTileResize` | `(tile, newSize) => void` | — | Fired when a tile is resized via the handle. |
|
|
168
|
+
| `onSelectionChange` | `(ids: string[]) => void` | — | Fired whenever the selection array changes. |
|
|
169
|
+
| `onHaptic` | `(event: HapticEvent) => void` | — | Fired at pick-up, snap, drop, and resize moments. |
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Ref API
|
|
174
|
+
|
|
175
|
+
```tsx
|
|
176
|
+
import { useRef } from 'react';
|
|
177
|
+
import type { SmartGridRef } from 'react-native-smart-grid';
|
|
178
|
+
|
|
179
|
+
const gridRef = useRef<SmartGridRef>(null);
|
|
180
|
+
|
|
181
|
+
<SmartGrid ref={gridRef} ... />
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Method | Description |
|
|
185
|
+
|---|---|
|
|
186
|
+
| `autoArrange()` | Re-packs all tiles using bin-packing (largest first). Fires `onLayoutChange`. |
|
|
187
|
+
| `serializeLayout()` | Returns `LayoutItem[]` — save anywhere, no `data` field included. |
|
|
188
|
+
| `restoreLayout(layout)` | Restores a previously serialized layout. Fires `onLayoutChange`. |
|
|
189
|
+
| `clearSelection()` | Clears the selection array. Fires `onSelectionChange`. |
|
|
190
|
+
| `setSelection(ids)` | Programmatically sets the selection. Fires `onSelectionChange`. |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Usage examples
|
|
195
|
+
|
|
196
|
+
### Collision behavior
|
|
197
|
+
|
|
198
|
+
`'push'` displaces tiles to the next free slot. `'swap'` exchanges the dragged tile with the one at the drop center.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
// Default — displaced tiles cascade to the next available space
|
|
202
|
+
<SmartGrid collisionBehavior="push" ... />
|
|
203
|
+
|
|
204
|
+
// Drop center tile and dragged tile switch places, others untouched
|
|
205
|
+
<SmartGrid collisionBehavior="swap" ... />
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
### Gravity
|
|
211
|
+
|
|
212
|
+
Automatically compacts the layout after every drop.
|
|
213
|
+
|
|
214
|
+
```tsx
|
|
215
|
+
// No compaction — tiles stay exactly where dropped
|
|
216
|
+
<SmartGrid gravity="none" ... />
|
|
217
|
+
|
|
218
|
+
// Tiles slide upward to fill empty rows
|
|
219
|
+
<SmartGrid gravity="up" ... />
|
|
220
|
+
|
|
221
|
+
// Tiles slide leftward to fill empty columns
|
|
222
|
+
<SmartGrid gravity="left" ... />
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### Edit mode and resize
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
231
|
+
|
|
232
|
+
<Button title={isEditing ? 'Done' : 'Edit'} onPress={() => setIsEditing(e => !e)} />
|
|
233
|
+
|
|
234
|
+
<SmartGrid
|
|
235
|
+
isEditing={isEditing}
|
|
236
|
+
onTileResize={(tile, newSize) => {
|
|
237
|
+
console.log(`${tile.id} resized to ${newSize.w}×${newSize.h}`);
|
|
238
|
+
}}
|
|
239
|
+
onLayoutChange={handleLayoutChange}
|
|
240
|
+
...
|
|
241
|
+
/>
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Use `locked` on a tile to hide its resize handle while still allowing drag:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
{ id: 'header', size: { w: 4, h: 1 }, data: { label: 'Header' }, locked: true }
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
### Tap to open
|
|
253
|
+
|
|
254
|
+
```tsx
|
|
255
|
+
<SmartGrid
|
|
256
|
+
onTilePress={(tile) => {
|
|
257
|
+
navigation.navigate('Detail', { id: tile.id, data: tile.data });
|
|
258
|
+
}}
|
|
259
|
+
renderTile={({ item }) => (
|
|
260
|
+
<View style={styles.card}>
|
|
261
|
+
<Text>{item.data.label}</Text>
|
|
262
|
+
</View>
|
|
263
|
+
)}
|
|
264
|
+
...
|
|
265
|
+
/>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`onTilePress` fires on a quick tap (< 200ms). It is suppressed while a selection is active — tapping a tile in selection mode toggles it instead.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### Selection — multi-select (default)
|
|
273
|
+
|
|
274
|
+
Long press a tile to enter selection mode. While selected, tapping any tile adds or removes it. A real drag-and-drop clears the selection.
|
|
275
|
+
|
|
276
|
+
```tsx
|
|
277
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
278
|
+
const gridRef = useRef<SmartGridRef>(null);
|
|
279
|
+
|
|
280
|
+
<SmartGrid
|
|
281
|
+
ref={gridRef}
|
|
282
|
+
onSelectionChange={(ids) => setSelectedIds(ids)}
|
|
283
|
+
renderTile={({ item, isSelected }) => (
|
|
284
|
+
<View style={[styles.card, isSelected && styles.cardSelected]}>
|
|
285
|
+
<Text>{item.data.label}</Text>
|
|
286
|
+
{isSelected && (
|
|
287
|
+
<TouchableOpacity onPress={() => deleteTile(item.id)}>
|
|
288
|
+
<Text>✕ Delete</Text>
|
|
289
|
+
</TouchableOpacity>
|
|
290
|
+
)}
|
|
291
|
+
</View>
|
|
292
|
+
)}
|
|
293
|
+
...
|
|
294
|
+
/>
|
|
295
|
+
|
|
296
|
+
{selectedIds.length > 0 && (
|
|
297
|
+
<Button
|
|
298
|
+
title={`Delete ${selectedIds.length} tiles`}
|
|
299
|
+
onPress={() => {
|
|
300
|
+
setTiles(prev => prev.filter(t => !selectedIds.includes(t.id)));
|
|
301
|
+
gridRef.current?.clearSelection();
|
|
302
|
+
}}
|
|
303
|
+
/>
|
|
304
|
+
)}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
### Selection — single-select
|
|
310
|
+
|
|
311
|
+
```tsx
|
|
312
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
313
|
+
|
|
314
|
+
<SmartGrid
|
|
315
|
+
multiSelect={false}
|
|
316
|
+
onSelectionChange={([id]) => setActiveId(id ?? null)}
|
|
317
|
+
renderTile={({ item, isSelected }) => (
|
|
318
|
+
<View style={[styles.card, isSelected && styles.cardActive]}>
|
|
319
|
+
<Text>{item.data.label}</Text>
|
|
320
|
+
</View>
|
|
321
|
+
)}
|
|
322
|
+
...
|
|
323
|
+
/>
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
### Programmatic selection
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
const gridRef = useRef<SmartGridRef>(null);
|
|
332
|
+
|
|
333
|
+
// Select specific tiles
|
|
334
|
+
gridRef.current?.setSelection(['tile-1', 'tile-3']);
|
|
335
|
+
|
|
336
|
+
// Clear all
|
|
337
|
+
gridRef.current?.clearSelection();
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
### Disable drag, keep selection
|
|
343
|
+
|
|
344
|
+
When `draggable={false}`, tiles can still be long-pressed to enter selection. Selection fires **immediately** at the long-press threshold (300ms) rather than on release — identical to how the iOS Photos app behaves.
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
<SmartGrid
|
|
348
|
+
draggable={false} // tiles stay in place
|
|
349
|
+
onSelectionChange={(ids) => console.log('selected:', ids)}
|
|
350
|
+
renderTile={({ item, isSelected }) => (
|
|
351
|
+
<View style={[styles.card, isSelected && styles.cardSelected]}>
|
|
352
|
+
<Text>{item.data.label}</Text>
|
|
353
|
+
</View>
|
|
354
|
+
)}
|
|
355
|
+
...
|
|
356
|
+
/>
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
### Grid-level switches
|
|
362
|
+
|
|
363
|
+
Master switches that override all per-tile flags.
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
// View-only — nothing moves, nothing selects
|
|
367
|
+
<SmartGrid draggable={false} selectable={false} ... />
|
|
368
|
+
|
|
369
|
+
// Locked layout — tiles visible, no interaction
|
|
370
|
+
<SmartGrid draggable={false} selectable={false} ... />
|
|
371
|
+
|
|
372
|
+
// Selection off — long press does nothing, onTilePress still fires on tap
|
|
373
|
+
<SmartGrid selectable={false} onTilePress={openDetail} ... />
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
### Per-tile draggable / selectable
|
|
379
|
+
|
|
380
|
+
Fine-grained control on individual tiles. Grid-level switches take priority when set to `false`.
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
const tiles: Tile<MyData>[] = [
|
|
384
|
+
// Normal tile — draggable and selectable
|
|
385
|
+
{ id: '1', data: { label: 'Drag me' } },
|
|
386
|
+
|
|
387
|
+
// Pinned tile — stays in place, cannot be dragged
|
|
388
|
+
{ id: '2', data: { label: 'Pinned' }, draggable: false },
|
|
389
|
+
|
|
390
|
+
// Info tile — long press fires onTilePress instead of entering selection
|
|
391
|
+
{ id: '3', data: { label: 'Info' }, selectable: false },
|
|
392
|
+
|
|
393
|
+
// Fully locked — no drag, no resize
|
|
394
|
+
{ id: '4', data: { label: 'Header' }, locked: true },
|
|
395
|
+
];
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
### Auto-arrange
|
|
401
|
+
|
|
402
|
+
```tsx
|
|
403
|
+
const gridRef = useRef<SmartGridRef>(null);
|
|
404
|
+
|
|
405
|
+
<Button
|
|
406
|
+
title="Auto-arrange"
|
|
407
|
+
onPress={() => gridRef.current?.autoArrange()}
|
|
408
|
+
/>
|
|
409
|
+
|
|
410
|
+
<SmartGrid ref={gridRef} onLayoutChange={handleLayoutChange} ... />
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`autoArrange()` re-packs all tiles using a largest-first bin-packing algorithm and fires `onLayoutChange` with the new positions.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
### Saving and restoring layouts
|
|
418
|
+
|
|
419
|
+
`serializeLayout` returns a plain array of `{ id, position, size }` — no `data` field. Save it anywhere.
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
const gridRef = useRef<SmartGridRef>(null);
|
|
423
|
+
|
|
424
|
+
// Save to your backend or AsyncStorage
|
|
425
|
+
async function save() {
|
|
426
|
+
const layout = gridRef.current?.serializeLayout();
|
|
427
|
+
await AsyncStorage.setItem('grid-layout', JSON.stringify(layout));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Restore on next launch
|
|
431
|
+
async function restore() {
|
|
432
|
+
const raw = await AsyncStorage.getItem('grid-layout');
|
|
433
|
+
if (raw) gridRef.current?.restoreLayout(JSON.parse(raw));
|
|
434
|
+
}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
When restoring, pass the saved layout back through your `data` state before mounting the grid:
|
|
438
|
+
|
|
439
|
+
```tsx
|
|
440
|
+
// Merge saved geometry back into your tile objects on startup
|
|
441
|
+
const saved = await AsyncStorage.getItem('grid-layout');
|
|
442
|
+
const savedLayout: LayoutItem[] = saved ? JSON.parse(saved) : [];
|
|
443
|
+
|
|
444
|
+
const initialTiles = MY_TILES.map(t => {
|
|
445
|
+
const saved = savedLayout.find(l => l.id === t.id);
|
|
446
|
+
return saved ? { ...t, position: saved.position, size: saved.size } : t;
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
### Haptics
|
|
453
|
+
|
|
454
|
+
The library fires `onHaptic` at the right moments but has no opinion on which haptics library you use.
|
|
455
|
+
|
|
456
|
+
```tsx
|
|
457
|
+
import ReactNativeHapticFeedback from 'react-native-haptic-feedback';
|
|
458
|
+
|
|
459
|
+
<SmartGrid
|
|
460
|
+
onHaptic={(event) => {
|
|
461
|
+
if (event === 'pick-up') ReactNativeHapticFeedback.trigger('impactMedium');
|
|
462
|
+
if (event === 'snap') ReactNativeHapticFeedback.trigger('selection');
|
|
463
|
+
if (event === 'drop') ReactNativeHapticFeedback.trigger('notificationSuccess');
|
|
464
|
+
if (event === 'resize') ReactNativeHapticFeedback.trigger('impactLight');
|
|
465
|
+
}}
|
|
466
|
+
...
|
|
467
|
+
/>
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
| Event | When |
|
|
471
|
+
|---|---|
|
|
472
|
+
| `'pick-up'` | Long press activates (drag starts or tile selected) |
|
|
473
|
+
| `'snap'` | Ghost tile snaps to a new grid position mid-drag |
|
|
474
|
+
| `'drop'` | Tile is released |
|
|
475
|
+
| `'resize'` | Tile resize is committed |
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
### Size constraints
|
|
480
|
+
|
|
481
|
+
Limit how small or large a tile can be resized (only enforced when `isEditing={true}`).
|
|
482
|
+
|
|
483
|
+
```tsx
|
|
484
|
+
const tiles: Tile<MyData>[] = [
|
|
485
|
+
{
|
|
486
|
+
id: '1',
|
|
487
|
+
size: { w: 2, h: 2 },
|
|
488
|
+
minSize: { w: 1, h: 1 }, // can shrink to 1×1
|
|
489
|
+
maxSize: { w: 4, h: 4 }, // can grow up to 4×4
|
|
490
|
+
data: { label: 'Resizable' },
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
id: '2',
|
|
494
|
+
size: { w: 2, h: 1 },
|
|
495
|
+
locked: true, // resize handle hidden entirely
|
|
496
|
+
data: { label: 'Fixed' },
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
---
|
|
502
|
+
|
|
503
|
+
## Types
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
type Tile<TData = unknown> = {
|
|
507
|
+
id: string; // unique, stable across re-renders
|
|
508
|
+
position?: TilePosition; // { x, y } — omit to auto-place
|
|
509
|
+
size?: TileSize; // { w, h } in grid units — omit to default 1×1
|
|
510
|
+
data: TData; // your custom data, passed back to renderTile
|
|
511
|
+
locked?: boolean; // hides resize handle, prevents resize
|
|
512
|
+
minSize?: TileSize; // smallest allowed size (isEditing only)
|
|
513
|
+
maxSize?: TileSize; // largest allowed size (isEditing only)
|
|
514
|
+
draggable?: boolean; // false = tile cannot be dragged (default: true)
|
|
515
|
+
selectable?: boolean; // false = long press fires onTilePress (default: true)
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
type TileSize = { w: number; h: number }; // grid units
|
|
519
|
+
type TilePosition = { x: number; y: number }; // column / row index, 0-based
|
|
520
|
+
|
|
521
|
+
type LayoutItem = {
|
|
522
|
+
id: string;
|
|
523
|
+
position: TilePosition;
|
|
524
|
+
size: TileSize;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
type RenderTileInfo<TData> = {
|
|
528
|
+
item: Tile<TData>; // the tile being rendered
|
|
529
|
+
isActive: boolean; // true while this tile is being dragged (shows placeholder)
|
|
530
|
+
isSelected: boolean; // true when this tile is in the selection array
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
type SmartGridRef = {
|
|
534
|
+
autoArrange: () => void;
|
|
535
|
+
serializeLayout: () => LayoutItem[];
|
|
536
|
+
restoreLayout: (layout: LayoutItem[]) => void;
|
|
537
|
+
clearSelection: () => void;
|
|
538
|
+
setSelection: (ids: string[]) => void;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
type CollisionBehavior = 'push' | 'swap';
|
|
542
|
+
type Gravity = 'none' | 'up' | 'left';
|
|
543
|
+
type HapticEvent = 'pick-up' | 'snap' | 'drop' | 'resize';
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
---
|
|
547
|
+
|
|
548
|
+
## Contributing
|
|
549
|
+
|
|
550
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
551
|
+
|
|
552
|
+
## License
|
|
553
|
+
|
|
554
|
+
MIT
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { memo, useEffect } from 'react';
|
|
4
|
+
import { StyleSheet } from 'react-native';
|
|
5
|
+
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
|
6
|
+
import { useGridDrag } from "../context/GridDragContext.js";
|
|
7
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
8
|
+
const LIFT_SPRING = {
|
|
9
|
+
damping: 15,
|
|
10
|
+
stiffness: 300,
|
|
11
|
+
mass: 0.4
|
|
12
|
+
};
|
|
13
|
+
function DragLayerInner({
|
|
14
|
+
tile: _tile,
|
|
15
|
+
initialRect,
|
|
16
|
+
children
|
|
17
|
+
}) {
|
|
18
|
+
const {
|
|
19
|
+
dragAbsX,
|
|
20
|
+
dragAbsY,
|
|
21
|
+
containerPageX,
|
|
22
|
+
containerPageY,
|
|
23
|
+
scrollYRef
|
|
24
|
+
} = useGridDrag();
|
|
25
|
+
const scale = useSharedValue(1);
|
|
26
|
+
const opacity = useSharedValue(0);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
scale.value = withSpring(1.06, LIFT_SPRING);
|
|
29
|
+
opacity.value = withSpring(1, LIFT_SPRING);
|
|
30
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
31
|
+
|
|
32
|
+
const animStyle = useAnimatedStyle(() => {
|
|
33
|
+
const x = dragAbsX.value - containerPageX.current - initialRect.width / 2;
|
|
34
|
+
const y = dragAbsY.value - containerPageY.current + scrollYRef.current - initialRect.height / 2;
|
|
35
|
+
return {
|
|
36
|
+
opacity: opacity.value,
|
|
37
|
+
transform: [{
|
|
38
|
+
translateX: x
|
|
39
|
+
}, {
|
|
40
|
+
translateY: y
|
|
41
|
+
}, {
|
|
42
|
+
scale: scale.value
|
|
43
|
+
}]
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
return /*#__PURE__*/_jsx(Animated.View, {
|
|
47
|
+
style: [styles.layer, {
|
|
48
|
+
width: initialRect.width,
|
|
49
|
+
height: initialRect.height
|
|
50
|
+
}, animStyle],
|
|
51
|
+
children: children
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export const DragLayer = /*#__PURE__*/memo(DragLayerInner);
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
layer: {
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
top: 0,
|
|
59
|
+
left: 0,
|
|
60
|
+
zIndex: 999,
|
|
61
|
+
shadowColor: '#000',
|
|
62
|
+
shadowOffset: {
|
|
63
|
+
width: 0,
|
|
64
|
+
height: 10
|
|
65
|
+
},
|
|
66
|
+
shadowOpacity: 0.4,
|
|
67
|
+
shadowRadius: 16,
|
|
68
|
+
elevation: 16
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
//# sourceMappingURL=DragLayer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"names":["memo","useEffect","StyleSheet","Animated","useAnimatedStyle","useSharedValue","withSpring","useGridDrag","jsx","_jsx","LIFT_SPRING","damping","stiffness","mass","DragLayerInner","tile","_tile","initialRect","children","dragAbsX","dragAbsY","containerPageX","containerPageY","scrollYRef","scale","opacity","value","animStyle","x","current","width","y","height","transform","translateX","translateY","View","style","styles","layer","DragLayer","create","position","top","left","zIndex","shadowColor","shadowOffset","shadowOpacity","shadowRadius","elevation"],"sourceRoot":"..\\..\\..\\src","sources":["components/DragLayer.tsx"],"mappings":";;AAAA,SAASA,IAAI,EAAEC,SAAS,QAAQ,OAAO;AACvC,SAASC,UAAU,QAAQ,cAAc;AACzC,OAAOC,QAAQ,IACbC,gBAAgB,EAChBC,cAAc,EACdC,UAAU,QACL,yBAAyB;AAChC,SAASC,WAAW,QAAQ,+BAA4B;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAIzD,MAAMC,WAAW,GAAG;EAAEC,OAAO,EAAE,EAAE;EAAEC,SAAS,EAAE,GAAG;EAAEC,IAAI,EAAE;AAAI,CAAC;AAQ9D,SAASC,cAAcA,CAAQ;EAAEC,IAAI,EAAEC,KAAK;EAAEC,WAAW;EAAEC;AAAuB,CAAC,EAAE;EACnF,MAAM;IAAEC,QAAQ;IAAEC,QAAQ;IAAEC,cAAc;IAAEC,cAAc;IAAEC;EAAW,CAAC,GAAGhB,WAAW,CAAC,CAAC;EAExF,MAAMiB,KAAK,GAAGnB,cAAc,CAAC,CAAC,CAAC;EAC/B,MAAMoB,OAAO,GAAGpB,cAAc,CAAC,CAAC,CAAC;EAEjCJ,SAAS,CAAC,MAAM;IACduB,KAAK,CAACE,KAAK,GAAGpB,UAAU,CAAC,IAAI,EAAEI,WAAW,CAAC;IAC3Ce,OAAO,CAACC,KAAK,GAAGpB,UAAU,CAAC,CAAC,EAAEI,WAAW,CAAC;EAC5C,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC;;EAER,MAAMiB,SAAS,GAAGvB,gBAAgB,CAAC,MAAM;IACvC,MAAMwB,CAAC,GAAGT,QAAQ,CAACO,KAAK,GAAGL,cAAc,CAACQ,OAAO,GAAGZ,WAAW,CAACa,KAAK,GAAG,CAAC;IACzE,MAAMC,CAAC,GACLX,QAAQ,CAACM,KAAK,GACdJ,cAAc,CAACO,OAAO,GACtBN,UAAU,CAACM,OAAO,GAClBZ,WAAW,CAACe,MAAM,GAAG,CAAC;IACxB,OAAO;MACLP,OAAO,EAAEA,OAAO,CAACC,KAAK;MACtBO,SAAS,EAAE,CAAC;QAAEC,UAAU,EAAEN;MAAE,CAAC,EAAE;QAAEO,UAAU,EAAEJ;MAAE,CAAC,EAAE;QAAEP,KAAK,EAAEA,KAAK,CAACE;MAAM,CAAC;IAC1E,CAAC;EACH,CAAC,CAAC;EAEF,oBACEjB,IAAA,CAACN,QAAQ,CAACiC,IAAI;IACZC,KAAK,EAAE,CACLC,MAAM,CAACC,KAAK,EACZ;MAAET,KAAK,EAAEb,WAAW,CAACa,KAAK;MAAEE,MAAM,EAAEf,WAAW,CAACe;IAAO,CAAC,EACxDL,SAAS,CACT;IAAAT,QAAA,EAEDA;EAAQ,CACI,CAAC;AAEpB;AAEA,OAAO,MAAMsB,SAAS,gBAAGxC,IAAI,CAACc,cAAc,CAA0B;AAEtE,MAAMwB,MAAM,GAAGpC,UAAU,CAACuC,MAAM,CAAC;EAC/BF,KAAK,EAAE;IACLG,QAAQ,EAAE,UAAU;IACpBC,GAAG,EAAE,CAAC;IACNC,IAAI,EAAE,CAAC;IACPC,MAAM,EAAE,GAAG;IACXC,WAAW,EAAE,MAAM;IACnBC,YAAY,EAAE;MAAEjB,KAAK,EAAE,CAAC;MAAEE,MAAM,EAAE;IAAG,CAAC;IACtCgB,aAAa,EAAE,GAAG;IAClBC,YAAY,EAAE,EAAE;IAChBC,SAAS,EAAE;EACb;AACF,CAAC,CAAC","ignoreList":[]}
|