react-native-drax 1.0.0 → 1.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.
@@ -33,7 +33,9 @@ import {
33
33
  import { isDraggable } from './useSpatialIndex';
34
34
 
35
35
  /** Style override to strip margins — hover is positioned via translateX/Y */
36
- const noMargin = {
36
+ /** Styles to strip from the hover content — margins and absolute positioning
37
+ * are not needed since hover is positioned via translateX/Y. */
38
+ const hoverResetStyle = {
37
39
  margin: 0,
38
40
  marginHorizontal: 0,
39
41
  marginVertical: 0,
@@ -41,6 +43,11 @@ const noMargin = {
41
43
  marginBottom: 0,
42
44
  marginLeft: 0,
43
45
  marginRight: 0,
46
+ position: 'relative',
47
+ left: 0,
48
+ top: 0,
49
+ right: undefined,
50
+ bottom: undefined,
44
51
  } as const;
45
52
 
46
53
  interface CallbackDispatchDeps {
@@ -238,7 +245,7 @@ export const useCallbackDispatch = (deps: CallbackDispatchDeps) => {
238
245
  <View style={[
239
246
  viewStyle,
240
247
  dims && { width: dims.width, height: dims.height },
241
- noMargin,
248
+ hoverResetStyle,
242
249
  ]}>
243
250
  {draggedEntry.props.children}
244
251
  </View>
@@ -9,9 +9,11 @@ import {
9
9
  defaultAutoScrollJumpRatio,
10
10
  defaultListItemLongPressDelay,
11
11
  } from '../params';
12
+ import { packGrid } from '../math';
12
13
  import type {
13
14
  DraxSnapbackTarget,
14
15
  DraxViewMeasurements,
16
+ GridItemSpan,
15
17
  Position,
16
18
  SortableItemMeasurement,
17
19
  SortableListHandle,
@@ -51,6 +53,7 @@ export const useSortableList = <T,>(
51
53
  autoScrollBackThreshold = defaultAutoScrollBackThreshold,
52
54
  autoScrollForwardThreshold = defaultAutoScrollForwardThreshold,
53
55
  animationConfig = 'default',
56
+ getItemSpan,
54
57
  inactiveItemStyle,
55
58
  itemEntering,
56
59
  itemExiting,
@@ -215,6 +218,108 @@ export const useSortableList = <T,>(
215
218
  // Alias for internal use
216
219
  const getMeasForOrigIdx = getMeasurementByOriginalIndex;
217
220
 
221
+ /** Get the span for an item at the given original data index */
222
+ const getSpanForOrigIdx = (origIdx: number): GridItemSpan => {
223
+ if (!getItemSpan) return { colSpan: 1, rowSpan: 1 };
224
+ const item = stableData[origIdx];
225
+ if (item === undefined) return { colSpan: 1, rowSpan: 1 };
226
+ return getItemSpan(item, origIdx);
227
+ };
228
+
229
+ /**
230
+ * Derive grid geometry (cell size + gaps) from current measurements.
231
+ * Only used when getItemSpan is provided and numColumns > 1.
232
+ */
233
+ const deriveGridGeometry = (): {
234
+ cellWidth: number;
235
+ cellHeight: number;
236
+ colGap: number;
237
+ rowGap: number;
238
+ startX: number;
239
+ startY: number;
240
+ } | undefined => {
241
+ if (!getItemSpan || originalIndexes.length === 0) return undefined;
242
+
243
+ const firstOrigIdx = originalIndexes[0];
244
+ const startMeas = firstOrigIdx !== undefined ? getMeasForOrigIdx(firstOrigIdx) : undefined;
245
+ if (!startMeas) return undefined;
246
+
247
+ // Pack original order to know grid positions for gap derivation
248
+ const origPacking = packGrid(
249
+ originalIndexes.length,
250
+ numColumns,
251
+ (displayIdx) => getSpanForOrigIdx(originalIndexes[displayIdx]!),
252
+ );
253
+
254
+ // Find cell dimensions from measurements of items with span 1
255
+ let cellWidth: number | undefined;
256
+ let cellHeight: number | undefined;
257
+
258
+ for (let i = 0; i < originalIndexes.length; i++) {
259
+ const origIdx = originalIndexes[i]!;
260
+ const span = getSpanForOrigIdx(origIdx);
261
+ const meas = getMeasForOrigIdx(origIdx);
262
+ if (!meas) continue;
263
+ if (span.colSpan === 1 && cellWidth === undefined) cellWidth = meas.width;
264
+ if (span.rowSpan === 1 && cellHeight === undefined) cellHeight = meas.height;
265
+ if (cellWidth !== undefined && cellHeight !== undefined) break;
266
+ }
267
+
268
+ // Fallback: derive from first item divided by its span
269
+ if (cellWidth === undefined || cellHeight === undefined) {
270
+ const firstSpan = getSpanForOrigIdx(firstOrigIdx!);
271
+ if (cellWidth === undefined) cellWidth = startMeas.width / firstSpan.colSpan;
272
+ if (cellHeight === undefined) cellHeight = startMeas.height / firstSpan.rowSpan;
273
+ }
274
+
275
+ // Derive column gap from two items at different grid columns
276
+ let colGap = 0;
277
+ for (let i = 0; i < origPacking.positions.length && colGap === 0; i++) {
278
+ for (let j = i + 1; j < origPacking.positions.length; j++) {
279
+ const pi = origPacking.positions[i]!;
280
+ const pj = origPacking.positions[j]!;
281
+ if (pi.col !== pj.col) {
282
+ const mi = getMeasForOrigIdx(originalIndexes[i]!);
283
+ const mj = getMeasForOrigIdx(originalIndexes[j]!);
284
+ if (mi && mj) {
285
+ const colDiff = Math.abs(pj.col - pi.col);
286
+ const xDiff = Math.abs(mj.x - mi.x);
287
+ colGap = xDiff / colDiff - cellWidth;
288
+ break;
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // Derive row gap from two items at different grid rows
295
+ let rowGap = 0;
296
+ for (let i = 0; i < origPacking.positions.length && rowGap === 0; i++) {
297
+ for (let j = i + 1; j < origPacking.positions.length; j++) {
298
+ const pi = origPacking.positions[i]!;
299
+ const pj = origPacking.positions[j]!;
300
+ if (pi.row !== pj.row) {
301
+ const mi = getMeasForOrigIdx(originalIndexes[i]!);
302
+ const mj = getMeasForOrigIdx(originalIndexes[j]!);
303
+ if (mi && mj) {
304
+ const rowDiff = Math.abs(pj.row - pi.row);
305
+ const yDiff = Math.abs(mj.y - mi.y);
306
+ rowGap = yDiff / rowDiff - cellHeight;
307
+ break;
308
+ }
309
+ }
310
+ }
311
+ }
312
+
313
+ return {
314
+ cellWidth,
315
+ cellHeight,
316
+ colGap: Math.max(colGap, 0),
317
+ rowGap: Math.max(rowGap, 0),
318
+ startX: startMeas.x,
319
+ startY: startMeas.y,
320
+ };
321
+ };
322
+
218
323
  // ── Shift application (merges ghost shifts for cross-container) ──
219
324
 
220
325
  const applyShifts = (shifts: Record<string, Position> | undefined) => {
@@ -309,7 +414,26 @@ export const useSortableList = <T,>(
309
414
  }
310
415
  displaySlot++;
311
416
  }
417
+ } else if (getItemSpan) {
418
+ // ── Mixed-size grid: bin-pack items into a 2D occupancy grid ──
419
+ const geo = deriveGridGeometry();
420
+ if (!geo) return undefined;
421
+
422
+ const packing = packGrid(
423
+ order.length,
424
+ numColumns,
425
+ (displayIdx) => getSpanForOrigIdx(order[displayIdx]!),
426
+ );
427
+
428
+ for (let i = 0; i < order.length; i++) {
429
+ const gp = packing.positions[i]!;
430
+ targetPositions.set(i, {
431
+ x: geo.startX + gp.col * (geo.cellWidth + geo.colGap),
432
+ y: geo.startY + gp.row * (geo.cellHeight + geo.rowGap),
433
+ });
434
+ }
312
435
  } else {
436
+ // ── Uniform grid: col = i % numColumns ──
313
437
  let cursorY = startMeas.y;
314
438
  const colXPositions: number[] = [];
315
439
  for (let c = 0; c < numColumns && c < originalIndexes.length; c++) {
@@ -652,8 +776,60 @@ export const useSortableList = <T,>(
652
776
  cursor += size + gap;
653
777
  }
654
778
  return itemCount - 1;
779
+ } else if (getItemSpan) {
780
+ // ── Mixed-size grid: map finger to cell, then to display index ──
781
+ const geo = deriveGridGeometry();
782
+ if (!geo) return draggedDisplayIndexRef.current ?? 0;
783
+
784
+ // Pack original order (stable positions during drag)
785
+ const origPacking = packGrid(
786
+ itemCount,
787
+ numColumns,
788
+ (displayIdx) => getSpanForOrigIdx(originalIndexes[displayIdx]!),
789
+ );
790
+
791
+ // Find which grid cell the finger is in
792
+ const cellCol = Math.max(0, Math.min(
793
+ Math.floor((contentPos.x - geo.startX + geo.colGap / 2) / (geo.cellWidth + geo.colGap)),
794
+ numColumns - 1,
795
+ ));
796
+ const cellRow = Math.max(0, Math.floor(
797
+ (contentPos.y - geo.startY + geo.rowGap / 2) / (geo.cellHeight + geo.rowGap),
798
+ ));
799
+
800
+ // Build cell → display index map (all cells each item occupies)
801
+ const cellOwner = new Map<string, number>();
802
+ for (let i = 0; i < origPacking.positions.length && i < itemCount; i++) {
803
+ const pos = origPacking.positions[i]!;
804
+ const span = getSpanForOrigIdx(originalIndexes[i]!);
805
+ for (let r = 0; r < span.rowSpan; r++) {
806
+ for (let c = 0; c < span.colSpan; c++) {
807
+ cellOwner.set(`${pos.row + r},${pos.col + c}`, i);
808
+ }
809
+ }
810
+ }
811
+
812
+ // Direct cell hit
813
+ const owner = cellOwner.get(`${cellRow},${cellCol}`);
814
+ if (owner !== undefined) return Math.min(owner, pending.length - 1);
815
+
816
+ // Empty cell — find nearest item by center distance
817
+ let minDist = Infinity;
818
+ let nearest = 0;
819
+ for (let i = 0; i < origPacking.positions.length && i < itemCount; i++) {
820
+ const meas = measurements[i];
821
+ if (!meas) continue;
822
+ const cx = meas.x + meas.width / 2;
823
+ const cy = meas.y + meas.height / 2;
824
+ const dist = Math.abs(contentPos.x - cx) + Math.abs(contentPos.y - cy);
825
+ if (dist < minDist) {
826
+ minDist = dist;
827
+ nearest = i;
828
+ }
829
+ }
830
+ return Math.min(nearest, pending.length - 1);
655
831
  } else {
656
- // Multi-column grid — find row then column
832
+ // ── Uniform grid — find row then column ──
657
833
  const firstMeas = measurements[0];
658
834
  if (!firstMeas) return 0;
659
835
  let cursorY = firstMeas.y;
@@ -750,8 +926,26 @@ export const useSortableList = <T,>(
750
926
  targetPos = horizontal
751
927
  ? { x: cursor, y: snapStartMeas.y }
752
928
  : { x: snapStartMeas.x, y: cursor };
929
+ } else if (getItemSpan) {
930
+ // Mixed-size grid — pack items and find target position
931
+ const geo = deriveGridGeometry();
932
+ if (!geo) return DraxSnapbackTargetPreset.Default;
933
+
934
+ const packing = packGrid(
935
+ pending.length,
936
+ numColumns,
937
+ (di) => getSpanForOrigIdx(pending[di]!),
938
+ );
939
+
940
+ const gp = packing.positions[displayIdx];
941
+ if (!gp) return DraxSnapbackTargetPreset.Default;
942
+
943
+ targetPos = {
944
+ x: geo.startX + gp.col * (geo.cellWidth + geo.colGap),
945
+ y: geo.startY + gp.row * (geo.cellHeight + geo.rowGap),
946
+ };
753
947
  } else {
754
- // Multi-column grid
948
+ // Uniform grid
755
949
  let cursorY = snapStartMeas.y;
756
950
  const targetRow = Math.floor(displayIdx / numColumns);
757
951
  const targetCol = displayIdx % numColumns;
@@ -801,6 +995,7 @@ export const useSortableList = <T,>(
801
995
  longPressDelay,
802
996
  lockToMainAxis,
803
997
  animationConfig,
998
+ getItemSpan,
804
999
  inactiveItemStyle,
805
1000
  itemEntering,
806
1001
  itemExiting,
package/src/index.ts CHANGED
@@ -25,8 +25,8 @@ export { useSortableList } from './hooks/useSortableList';
25
25
  export { useSortableBoard } from './hooks/useSortableBoard';
26
26
 
27
27
  // ── Public Utilities ─────────────────────────────────────────────────
28
- export { snapToAlignment } from './math';
29
- export type { SnapAlignment } from './math';
28
+ export { snapToAlignment, packGrid } from './math';
29
+ export type { SnapAlignment, GridPackResult } from './math';
30
30
 
31
31
  // ── Public Types ─────────────────────────────────────────────────────
32
32
  export type {
@@ -75,6 +75,7 @@ export type {
75
75
  // Sortable types
76
76
  UseSortableListOptions,
77
77
  SortableListHandle,
78
+ GridItemSpan,
78
79
  } from './types';
79
80
  export type { SortableItemContextValue } from './SortableItemContext';
80
81
  export type {
package/src/math.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  DraxViewMeasurements,
3
+ GridItemSpan,
3
4
  HitTestResult,
4
5
  Position,
5
6
  SpatialEntry,
@@ -249,3 +250,84 @@ export const snapToAlignment = (
249
250
 
250
251
  return { x: x + offset.x, y: y + offset.y };
251
252
  };
253
+
254
+ // ─── Grid Packing ───────────────────────────────────────────────────────
255
+
256
+ /** Result of packing items into a grid */
257
+ export interface GridPackResult {
258
+ /** Grid position (row, col) for each item, in input order */
259
+ positions: { row: number; col: number }[];
260
+ /** Total number of rows in the packed grid */
261
+ totalRows: number;
262
+ }
263
+
264
+ /**
265
+ * Pack items into a grid with the given number of columns.
266
+ * Items are placed left-to-right, top-to-bottom, filling the first
267
+ * available position where the item's span fits.
268
+ *
269
+ * This is the same algorithm used by mobile home screens: scan cells
270
+ * in reading order and place each item at the first slot that can
271
+ * accommodate its colSpan × rowSpan.
272
+ *
273
+ * @param count Number of items to pack
274
+ * @param numColumns Number of columns in the grid
275
+ * @param getSpan Returns the span for the item at the given index
276
+ */
277
+ export function packGrid(
278
+ count: number,
279
+ numColumns: number,
280
+ getSpan: (index: number) => GridItemSpan,
281
+ ): GridPackResult {
282
+ // Dynamic 2D occupancy grid — rows are added as needed
283
+ const occupied: boolean[][] = [];
284
+ const positions: { row: number; col: number }[] = [];
285
+ let maxRow = 0;
286
+
287
+ function ensureRow(row: number) {
288
+ while (occupied.length <= row) {
289
+ occupied.push(new Array<boolean>(numColumns).fill(false));
290
+ }
291
+ }
292
+
293
+ function isAvailable(row: number, col: number, cs: number, rs: number): boolean {
294
+ if (col + cs > numColumns) return false;
295
+ for (let r = row; r < row + rs; r++) {
296
+ ensureRow(r);
297
+ for (let c = col; c < col + cs; c++) {
298
+ if (occupied[r]![c]) return false;
299
+ }
300
+ }
301
+ return true;
302
+ }
303
+
304
+ function markOccupied(row: number, col: number, cs: number, rs: number) {
305
+ for (let r = row; r < row + rs; r++) {
306
+ ensureRow(r);
307
+ for (let c = col; c < col + cs; c++) {
308
+ occupied[r]![c] = true;
309
+ }
310
+ }
311
+ maxRow = Math.max(maxRow, row + rs - 1);
312
+ }
313
+
314
+ for (let i = 0; i < count; i++) {
315
+ const span = getSpan(i);
316
+ const cs = Math.max(1, Math.min(span.colSpan, numColumns));
317
+ const rs = Math.max(1, span.rowSpan);
318
+ let placed = false;
319
+ for (let r = 0; !placed; r++) {
320
+ ensureRow(r);
321
+ for (let c = 0; c <= numColumns - cs; c++) {
322
+ if (isAvailable(r, c, cs, rs)) {
323
+ markOccupied(r, c, cs, rs);
324
+ positions.push({ row: r, col: c });
325
+ placed = true;
326
+ break;
327
+ }
328
+ }
329
+ }
330
+ }
331
+
332
+ return { positions, totalRows: count > 0 ? maxRow + 1 : 0 };
333
+ }
package/src/types.ts CHANGED
@@ -39,6 +39,14 @@ export interface ViewDimensions {
39
39
  height: number;
40
40
  }
41
41
 
42
+ /** Grid span for a sortable item (columns and rows it occupies) */
43
+ export interface GridItemSpan {
44
+ /** Number of columns this item spans. @default 1 */
45
+ colSpan: number;
46
+ /** Number of rows this item spans. @default 1 */
47
+ rowSpan: number;
48
+ }
49
+
42
50
  /** Measurements of a Drax view for bounds checking purposes */
43
51
  export interface DraxViewMeasurements extends Position, ViewDimensions {
44
52
  /** 1 when DraxView auto-detected transform-based positioning
@@ -724,6 +732,10 @@ export interface UseSortableListOptions<T> {
724
732
  autoScrollForwardThreshold?: number;
725
733
  /** Animation config for item shift animations. @default 'default' */
726
734
  animationConfig?: SortableAnimationConfig;
735
+ /** Returns the grid span for an item. Enables non-uniform grid layout
736
+ * where items can span multiple columns and/or rows.
737
+ * Only used when numColumns > 1. */
738
+ getItemSpan?: (item: T, index: number) => GridItemSpan;
727
739
  /** Style applied to all non-dragged items while a drag is active.
728
740
  * Use for dimming/scaling inactive items (e.g., `{ opacity: 0.5 }`). */
729
741
  inactiveItemStyle?: ViewStyle;
@@ -762,6 +774,8 @@ export interface SortableListInternal<T> {
762
774
  longPressDelay: number;
763
775
  lockToMainAxis: boolean;
764
776
  animationConfig: SortableAnimationConfig;
777
+ /** Returns the grid span for an item (non-uniform grid layout) */
778
+ getItemSpan?: (item: T, index: number) => GridItemSpan;
765
779
  inactiveItemStyle?: ViewStyle;
766
780
  itemEntering?: EntryOrExitLayoutType;
767
781
  itemExiting?: EntryOrExitLayoutType;