react-native-layout-dnd 0.1.0-alpha.1 → 0.1.0-alpha.2

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.
Files changed (38) hide show
  1. package/README.md +124 -57
  2. package/lib/module/components/DndProvider.js +44 -5
  3. package/lib/module/components/DndProvider.js.map +1 -1
  4. package/lib/module/components/GridController.js +48 -0
  5. package/lib/module/components/GridController.js.map +1 -1
  6. package/lib/module/components/SortableGrid.js +31 -4
  7. package/lib/module/components/SortableGrid.js.map +1 -1
  8. package/lib/module/components/SortableList.js +15 -1
  9. package/lib/module/components/SortableList.js.map +1 -1
  10. package/lib/module/controller/dndSession.js +8 -1
  11. package/lib/module/controller/dndSession.js.map +1 -1
  12. package/lib/module/pointer/geometry.js +5 -2
  13. package/lib/module/pointer/geometry.js.map +1 -1
  14. package/lib/module/runtime/committedSize.js +42 -0
  15. package/lib/module/runtime/committedSize.js.map +1 -0
  16. package/lib/typescript/src/components/DndProvider.d.ts +1 -1
  17. package/lib/typescript/src/components/DndProvider.d.ts.map +1 -1
  18. package/lib/typescript/src/components/GridController.d.ts +1 -1
  19. package/lib/typescript/src/components/GridController.d.ts.map +1 -1
  20. package/lib/typescript/src/components/SortableGrid.d.ts.map +1 -1
  21. package/lib/typescript/src/components/SortableList.d.ts.map +1 -1
  22. package/lib/typescript/src/components/gridTypes.d.ts +6 -0
  23. package/lib/typescript/src/components/gridTypes.d.ts.map +1 -1
  24. package/lib/typescript/src/controller/dndSession.d.ts +6 -0
  25. package/lib/typescript/src/controller/dndSession.d.ts.map +1 -1
  26. package/lib/typescript/src/pointer/geometry.d.ts +1 -1
  27. package/lib/typescript/src/pointer/geometry.d.ts.map +1 -1
  28. package/lib/typescript/src/runtime/committedSize.d.ts +19 -0
  29. package/lib/typescript/src/runtime/committedSize.d.ts.map +1 -0
  30. package/package.json +1 -1
  31. package/src/components/DndProvider.tsx +55 -5
  32. package/src/components/GridController.tsx +57 -0
  33. package/src/components/SortableGrid.tsx +65 -6
  34. package/src/components/SortableList.tsx +38 -1
  35. package/src/components/gridTypes.ts +6 -0
  36. package/src/controller/dndSession.ts +14 -0
  37. package/src/pointer/geometry.ts +4 -0
  38. package/src/runtime/committedSize.ts +65 -0
@@ -226,6 +226,7 @@ export function DndProvider<T>({
226
226
  onDragEnd,
227
227
  onDragSettled,
228
228
  onValidationError,
229
+ onValidationChange,
229
230
  onDiagnostic,
230
231
  responseTimeoutMs,
231
232
  getGridItemLayout,
@@ -312,6 +313,53 @@ export function DndProvider<T>({
312
313
  },
313
314
  [reportSettled],
314
315
  );
316
+ // Issues of the value and options (from the coordinator) and of mounted
317
+ // adapters and handles (by owner), published together as one list. A new
318
+ // value reaches the adapters over several synchronous commits, so the list
319
+ // is read once they are done; intermediate lists are never published.
320
+ const issues = useRef(
321
+ new Map<string, { key: string; issues: LayoutValidationIssue[] }>(),
322
+ );
323
+ const sessionIssues = useRef<LayoutValidationIssue[]>([]);
324
+ const validation = useRef({
325
+ published: '[]',
326
+ queued: false,
327
+ onChange: onValidationChange,
328
+ }).current;
329
+ const mounted = useRef(true);
330
+ const publishValidation = useCallback(() => {
331
+ if (!mounted.current || validation.queued) return;
332
+ validation.queued = true;
333
+ (
334
+ globalThis as typeof globalThis & {
335
+ queueMicrotask(callback: () => void): void;
336
+ }
337
+ ).queueMicrotask(() => {
338
+ validation.queued = false;
339
+ if (!mounted.current) return;
340
+ const current = [
341
+ ...sessionIssues.current,
342
+ ...Array.from(issues.current.keys())
343
+ .sort()
344
+ .flatMap(owner => issues.current.get(owner)!.issues),
345
+ ];
346
+ const key = JSON.stringify(current);
347
+ if (key === validation.published) return;
348
+ validation.published = key;
349
+ try {
350
+ validation.onChange?.(current);
351
+ } catch (error) {
352
+ console.error('[LayoutDnD] Validation callback failed', error);
353
+ }
354
+ });
355
+ }, [validation]);
356
+ const reportSessionIssues = useCallback(
357
+ (next: LayoutValidationIssue[]) => {
358
+ sessionIssues.current = next;
359
+ publishValidation();
360
+ },
361
+ [publishValidation],
362
+ );
315
363
  const [coordinator] = useState(
316
364
  () =>
317
365
  new DndSessionCoordinator({
@@ -321,6 +369,7 @@ export function DndProvider<T>({
321
369
  onDragStart,
322
370
  onDragEnd: endSession,
323
371
  onValidationError,
372
+ onValidationChange: reportSessionIssues,
324
373
  onDiagnostic,
325
374
  responseTimeoutMs,
326
375
  getGridItemLayout,
@@ -380,9 +429,7 @@ export function DndProvider<T>({
380
429
  const nextHandleId = useRef(0);
381
430
  const [gestureRegistry] = useState(createDndGestureRegistry);
382
431
  const registry = useRef(new Map<object, RegisteredList>());
383
- const issues = useRef(new Map<string, string>());
384
432
  const observer = useRef(onValidationError);
385
- const mounted = useRef(true);
386
433
  const handlePublication = useRef({
387
434
  queued: false,
388
435
  generation: 0,
@@ -648,11 +695,12 @@ export function DndProvider<T>({
648
695
  const reportIssues = useCallback(
649
696
  (owner: string, next: LayoutValidationIssue[]) => {
650
697
  const key = JSON.stringify(next);
651
- const old = issues.current.get(owner);
652
- if (next.length) issues.current.set(owner, key);
698
+ const old = issues.current.get(owner)?.key;
699
+ if (next.length) issues.current.set(owner, { key, issues: next });
653
700
  else issues.current.delete(owner);
654
701
  if (!mounted.current) return;
655
702
  setRegistryInvalid(issues.current.size > 0);
703
+ publishValidation();
656
704
  if (next.length && old !== key) {
657
705
  coordinator.cancel('disabled');
658
706
  try {
@@ -662,7 +710,7 @@ export function DndProvider<T>({
662
710
  }
663
711
  }
664
712
  },
665
- [coordinator],
713
+ [coordinator, publishValidation],
666
714
  );
667
715
  useLayoutEffect(() => {
668
716
  mounted.current = true;
@@ -677,6 +725,7 @@ export function DndProvider<T>({
677
725
  targetSession.set({ ...current, policyRevision: nextPolicyRevision });
678
726
  });
679
727
  observer.current = onValidationError;
728
+ validation.onChange = onValidationChange;
680
729
  if (
681
730
  currentConfig.current.dragAxis !== dragAxis ||
682
731
  currentConfig.current.delayMs !== activation?.delayMs
@@ -695,6 +744,7 @@ export function DndProvider<T>({
695
744
  onDragStart,
696
745
  onDragEnd: endSession,
697
746
  onValidationError,
747
+ onValidationChange: reportSessionIssues,
698
748
  onDiagnostic,
699
749
  responseTimeoutMs,
700
750
  getGridItemLayout,
@@ -58,6 +58,7 @@ export function GridController<T>({
58
58
  onDragStart,
59
59
  onDragEnd,
60
60
  onValidationError,
61
+ onValidationChange,
61
62
  onDiagnostic,
62
63
  searchBudget,
63
64
  activation,
@@ -130,6 +131,61 @@ export function GridController<T>({
130
131
  [onValidationError, valueValid],
131
132
  );
132
133
 
134
+ // The current list: the value's own issues while it is invalid, otherwise
135
+ // the provider's (zone geometry, handles, options) in grid API codes.
136
+ const providerIssues = useRef<LayoutValidationIssue[]>([]);
137
+ // Both sources settle at the end of the provider's commits; publish once.
138
+ const validation = useRef({
139
+ valueIssues: converted.issues,
140
+ onChange: onValidationChange,
141
+ published: '[]',
142
+ queued: false,
143
+ active: true,
144
+ }).current;
145
+ const publishValidation = useCallback(() => {
146
+ if (!validation.active || validation.queued) return;
147
+ validation.queued = true;
148
+ (
149
+ globalThis as typeof globalThis & {
150
+ queueMicrotask(callback: () => void): void;
151
+ }
152
+ ).queueMicrotask(() => {
153
+ validation.queued = false;
154
+ if (!validation.active) return;
155
+ const current = validation.valueIssues.length
156
+ ? validation.valueIssues
157
+ : gridIssues(providerIssues.current);
158
+ const key = JSON.stringify(current);
159
+ if (key === validation.published) return;
160
+ validation.published = key;
161
+ try {
162
+ validation.onChange?.(current);
163
+ } catch (error) {
164
+ console.error('[LayoutDnD] Validation callback failed', error);
165
+ }
166
+ });
167
+ }, [validation]);
168
+ useEffect(() => {
169
+ validation.active = true;
170
+ return () => {
171
+ validation.active = false;
172
+ };
173
+ }, [validation]);
174
+ useEffect(() => {
175
+ validation.valueIssues = converted.issues;
176
+ validation.onChange = onValidationChange;
177
+ publishValidation();
178
+ }, [converted.issues, onValidationChange, publishValidation, validation]);
179
+ const handleProviderChange = useCallback(
180
+ (issues: LayoutValidationIssue[]) => {
181
+ providerIssues.current = issues;
182
+ // A resolved issue that comes back is new again for onValidationError.
183
+ if (issues.length === 0) lastProviderIssues.current = null;
184
+ publishValidation();
185
+ },
186
+ [publishValidation],
187
+ );
188
+
133
189
  const handleChange = useCallback(
134
190
  (next: DndValue<T>, proposal: DndProposal<T>) => {
135
191
  pendingProposal.current = proposal;
@@ -194,6 +250,7 @@ export function GridController<T>({
194
250
  onDragStart={onDragStart}
195
251
  onDragSettled={handleEnd}
196
252
  onValidationError={handleProviderIssues}
253
+ onValidationChange={handleProviderChange}
197
254
  onDiagnostic={onDiagnostic}
198
255
  searchBudget={searchBudget}
199
256
  activation={activation}
@@ -20,6 +20,11 @@ import {
20
20
  import type { GridItemRenderArgs, SortableGridProps } from './dndTypes';
21
21
  import { presentationRect } from '../runtime/dndPresentation';
22
22
  import { getCellGeometry, itemRect } from '../pointer/geometry';
23
+ import {
24
+ readCommittedSize,
25
+ sameViewSize,
26
+ type ViewSize,
27
+ } from '../runtime/committedSize';
23
28
 
24
29
  function GridCellContent<T>({
25
30
  item,
@@ -191,9 +196,12 @@ export function SortableGrid<T>({
191
196
  };
192
197
  }, []);
193
198
  const ref = useAnimatedRef();
194
- const [size, setSize] = useState<{ width: number; height: number } | null>(
195
- null,
196
- );
199
+ // Last known native size: onLayout, or the committed layout read below.
200
+ const [size, setSize] = useState<ViewSize | null>(null);
201
+ // Size read from the latest commit that changed size-related inputs.
202
+ const committedSize = useRef<ViewSize | null>(null);
203
+ // Key of a misfit confirmed against native layout; such cells are hidden.
204
+ const [clipped, setClipped] = useState<string | null>(null);
197
205
  const zone = snapshot.zone;
198
206
  const display = snapshot.display;
199
207
  const revision = snapshot.revision;
@@ -216,10 +224,45 @@ export function SortableGrid<T>({
216
224
  };
217
225
  });
218
226
  const pixelRatio = PixelRatio.get();
219
- const cells =
227
+ const fitted =
220
228
  zone?.kind === 'grid' && size
221
229
  ? getCellGeometry(zone, geometry, size.width, size.height, pixelRatio)
222
230
  : null;
231
+ // When props and the view size change in one commit (rotation), `size` is
232
+ // still the previous one here. Fixed cells do not depend on it, so keep
233
+ // them mounted at their new positions; the layout effect decides fit
234
+ // against this commit's native size.
235
+ const unfitted =
236
+ !fitted && zone?.kind === 'grid' && size
237
+ ? getCellGeometry(
238
+ zone,
239
+ geometry,
240
+ size.width,
241
+ size.height,
242
+ pixelRatio,
243
+ false,
244
+ )
245
+ : null;
246
+ const misfitKey =
247
+ unfitted && size && zone?.kind === 'grid'
248
+ ? JSON.stringify([
249
+ geometry.rowGap,
250
+ geometry.columnGap,
251
+ geometry.padding.top,
252
+ geometry.padding.right,
253
+ geometry.padding.bottom,
254
+ geometry.padding.left,
255
+ unfitted.cellWidth,
256
+ unfitted.cellHeight,
257
+ zone.rows,
258
+ zone.columns,
259
+ size.width,
260
+ size.height,
261
+ pixelRatio,
262
+ ])
263
+ : null;
264
+ const fits = fitted !== null;
265
+ const cells = fitted ?? (misfitKey !== clipped ? unfitted : null);
223
266
  // Compare values so mutating a geometry object in place cannot preserve stale
224
267
  // hit-test coordinates. Registered geometry never aliases caller-owned values.
225
268
  const geometryKey = cells
@@ -268,7 +311,19 @@ export function SortableGrid<T>({
268
311
  [snapshot.items],
269
312
  );
270
313
  useLayoutEffect(() => {
271
- const invalid = zone?.kind !== 'grid' || (size !== null && !measured);
314
+ // Fabric has laid out this commit already. A size newer than the last
315
+ // onLayout event re-renders synchronously, before paint, with that size.
316
+ // Other commits, such as drag previews, skip the native read.
317
+ const next = readCommittedSize(ref.current);
318
+ committedSize.current = next;
319
+ if (next && !sameViewSize(next, size)) setSize(next);
320
+ }, [ref, size, style, zone, geometry, measured]);
321
+ useLayoutEffect(() => {
322
+ // The re-render scheduled above validates; never mix the new props with
323
+ // the previous size.
324
+ if (committedSize.current && !sameViewSize(committedSize.current, size))
325
+ return;
326
+ const invalid = zone?.kind !== 'grid' || (size !== null && !fits);
272
327
  reportIssues(
273
328
  `grid:${zoneId}`,
274
329
  invalid
@@ -285,10 +340,12 @@ export function SortableGrid<T>({
285
340
  ]
286
341
  : [],
287
342
  );
288
- if (zone?.kind !== 'grid' || !measured) {
343
+ if (zone?.kind !== 'grid' || !fits || !measured) {
289
344
  unregisterZone(zoneId, identity);
345
+ if (misfitKey) setClipped(misfitKey);
290
346
  return;
291
347
  }
348
+ setClipped(null);
292
349
  registerZone(
293
350
  {
294
351
  kind: 'grid',
@@ -328,6 +385,8 @@ export function SortableGrid<T>({
328
385
  ref,
329
386
  measured,
330
387
  size,
388
+ fits,
389
+ misfitKey,
331
390
  registerZone,
332
391
  unregisterZone,
333
392
  reportIssues,
@@ -50,6 +50,11 @@ import {
50
50
  type DndZonePresentation,
51
51
  } from '../runtime/dndPresentation';
52
52
  import type { RetainSlotsRule } from '../runtime/dndRenderStore';
53
+ import {
54
+ readCommittedSize,
55
+ sameViewSize,
56
+ type ViewSize,
57
+ } from '../runtime/committedSize';
53
58
  import type { ListItemRenderArgs, SortableListProps } from './dndTypes';
54
59
  import { useListCellMotion } from '../pointer/listCellMotion';
55
60
 
@@ -447,7 +452,12 @@ export function SortableList<T>({
447
452
  typeof itemSize === 'function' ? false : virtualized ? retainSlots : true,
448
453
  );
449
454
  const identity = useRef({}).current;
450
- const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 });
455
+ // Last known native viewport: onLayout, or the committed layout read below.
456
+ const [viewportSize, setViewportSize] = useState<ViewSize>({
457
+ width: 0,
458
+ height: 0,
459
+ });
460
+ const committedViewport = useRef<ViewSize | null>(null);
451
461
  const [measurementVersion, setMeasurementVersion] = useState(0);
452
462
  const zone = snapshot.zone;
453
463
  const displayZone = snapshot.display;
@@ -520,6 +530,26 @@ export function SortableList<T>({
520
530
  // frame. Its newer version must not be acknowledged by this older render.
521
531
  }, [measurements, renderedMeasurementVersion]);
522
532
  const scrollRef = useAnimatedRef<ScrollView>();
533
+ useLayoutEffect(() => {
534
+ // Fabric has laid out this commit already. A viewport newer than the last
535
+ // onLayout event re-renders synchronously, before paint, so cells never
536
+ // combine new props with the previous cross size. Other commits, such as
537
+ // drag previews, skip the native read.
538
+ const next = readCommittedSize(scrollRef.current);
539
+ committedViewport.current = next;
540
+ if (next && !sameViewSize(next, viewportSize)) setViewportSize(next);
541
+ }, [
542
+ scrollRef,
543
+ viewportSize,
544
+ style,
545
+ orientation,
546
+ virtualized,
547
+ itemSize,
548
+ estimatedItemSize,
549
+ gap,
550
+ paddingStart,
551
+ paddingEnd,
552
+ ]);
523
553
  const offset = useSharedValue(0);
524
554
  const { motion, animation, presentation, committedRevision } = runtime;
525
555
  // Virtual cells position their content with a React-committed slot offset
@@ -728,6 +758,12 @@ export function SortableList<T>({
728
758
  );
729
759
  });
730
760
  useLayoutEffect(() => {
761
+ // The re-render scheduled with this commit's viewport registers instead.
762
+ if (
763
+ committedViewport.current &&
764
+ !sameViewSize(committedViewport.current, viewportSize)
765
+ )
766
+ return;
731
767
  if (!base?.valid || !display?.valid || !validScroll || !windowing.valid) {
732
768
  unregisterZone(zoneId, identity);
733
769
  return;
@@ -771,6 +807,7 @@ export function SortableList<T>({
771
807
  virtualized,
772
808
  prepareDisplay,
773
809
  items,
810
+ viewportSize,
774
811
  ]);
775
812
  useLayoutEffect(
776
813
  () => () => {
@@ -63,7 +63,13 @@ export type GridControllerProps<T> = Omit<ViewProps, 'children'> & {
63
63
  onDragStart?(event: DragStartEvent): void;
64
64
  /** Fires once the drag preview has settled into its cell, or at once when nothing animates. */
65
65
  onDragEnd?(event: DragEndEvent): void;
66
+ /** New issues only; it is not called when issues are resolved. */
66
67
  onValidationError?(issues: ValidationIssue[]): void;
68
+ /**
69
+ * The complete current issue list whenever it changes, `[]` once every
70
+ * issue is resolved. Not called while the list stays empty.
71
+ */
72
+ onValidationChange?(issues: ValidationIssue[]): void;
67
73
  /** Optional JS-only observation. Exceptions are ignored; omit to disable timing. */
68
74
  onDiagnostic?(event: GridDiagnosticEvent): void;
69
75
  /** Defaults to 10,000 bounded search steps. */
@@ -104,7 +104,13 @@ export type DndSessionOptions<T> = {
104
104
  canDrop?(proposal: DndProposal<T>): DropDecision;
105
105
  onDragStart?(event: DragStartEvent): void;
106
106
  onDragEnd?(event: DndDragEndEvent<T>): void;
107
+ /** New issues only; it is not called when issues are resolved. */
107
108
  onValidationError?(issues: LayoutValidationIssue[]): void;
109
+ /**
110
+ * The complete current issue list whenever it changes, `[]` once every
111
+ * issue is resolved. Not called while the list stays empty.
112
+ */
113
+ onValidationChange?(issues: LayoutValidationIssue[]): void;
108
114
  /** Optional JS-only observation. Exceptions are ignored; omit to disable timing. */
109
115
  onDiagnostic?(event: GridDiagnosticEvent): void;
110
116
  responseTimeoutMs?: number;
@@ -324,6 +330,7 @@ function checkOptions<T>(options: DndSessionOptions<T>) {
324
330
  'onDragStart',
325
331
  'onDragEnd',
326
332
  'onValidationError',
333
+ 'onValidationChange',
327
334
  'getGridItemLayout',
328
335
  ].some(key => {
329
336
  const callback = options[key as keyof DndSessionOptions<T>];
@@ -558,6 +565,13 @@ export class DndSessionCoordinator<T> extends SessionCore<
558
565
  reportCallbackError(error);
559
566
  }
560
567
  }
568
+ if (typeof options.onValidationChange === 'function') {
569
+ try {
570
+ options.onValidationChange(checked.issues);
571
+ } catch (error) {
572
+ reportCallbackError(error);
573
+ }
574
+ }
561
575
  }
562
576
  });
563
577
  }
@@ -108,6 +108,9 @@ export function getCellGeometry(
108
108
  width: number,
109
109
  height: number,
110
110
  pixelRatio = 1,
111
+ // false: fixed cells even when they overflow the viewport. Renderers use it
112
+ // to keep cells mounted until a misfit is confirmed against native layout.
113
+ requireFit = true,
111
114
  ): CellGeometry | null {
112
115
  'worklet';
113
116
  if (
@@ -170,6 +173,7 @@ export function getCellGeometry(
170
173
  return Number.isFinite(requiredWidth) &&
171
174
  Number.isFinite(requiredHeight) &&
172
175
  (geometry.mode !== 'fixed' ||
176
+ !requireFit ||
173
177
  (requiredWidth - width < pixelSize &&
174
178
  requiredHeight - height < pixelSize)) &&
175
179
  Number.isFinite(cellWidth) &&
@@ -0,0 +1,65 @@
1
+ export type ViewSize = { width: number; height: number };
2
+
3
+ type MeasurableHost = {
4
+ measureLayout(
5
+ relativeTo: unknown,
6
+ onSuccess: (x: number, y: number, width: number, height: number) => void,
7
+ onFail?: () => void,
8
+ ): void;
9
+ getNativeScrollRef?(): unknown;
10
+ };
11
+
12
+ function hostOf(instance: unknown): MeasurableHost | null {
13
+ const candidate = instance as Partial<MeasurableHost> | null | undefined;
14
+ const host =
15
+ typeof candidate?.getNativeScrollRef === 'function'
16
+ ? (candidate.getNativeScrollRef() as Partial<MeasurableHost> | null)
17
+ : candidate;
18
+ return host && typeof host.measureLayout === 'function'
19
+ ? (host as MeasurableHost)
20
+ : null;
21
+ }
22
+
23
+ /**
24
+ * The size the latest React commit gave a host view, read from a layout
25
+ * effect. Fabric commits and lays out the shadow tree before layout effects
26
+ * run and answers a self-relative `measureLayout` synchronously, with the
27
+ * same untransformed frame that onLayout later reports. Props that change in
28
+ * the same commit as the view size can therefore be validated against that
29
+ * size instead of the previous onLayout event.
30
+ *
31
+ * Returns null when no answer arrives synchronously or the view has no
32
+ * layout (unmounted, `display: 'none'`, a renderer without the API); callers
33
+ * keep their onLayout size then.
34
+ */
35
+ export function readCommittedSize(instance: unknown): ViewSize | null {
36
+ const host = hostOf(instance);
37
+ if (!host) return null;
38
+ let size = null as ViewSize | null;
39
+ try {
40
+ host.measureLayout(
41
+ host,
42
+ (_x, _y, width, height) => {
43
+ size = { width, height };
44
+ },
45
+ () => {},
46
+ );
47
+ } catch {
48
+ return null;
49
+ }
50
+ // An answer that arrives after this returns is ignored.
51
+ const result = size;
52
+ return result &&
53
+ Number.isFinite(result.width) &&
54
+ Number.isFinite(result.height) &&
55
+ result.width >= 0 &&
56
+ result.height >= 0
57
+ ? result
58
+ : null;
59
+ }
60
+
61
+ export function sameViewSize(a: ViewSize | null, b: ViewSize | null): boolean {
62
+ return (
63
+ a === b || (!!a && !!b && a.width === b.width && a.height === b.height)
64
+ );
65
+ }