react-native-gesture-image-viewer 2.3.3 → 2.5.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.
Files changed (51) hide show
  1. package/README.md +19 -5
  2. package/lib/module/GestureViewer.js +39 -15
  3. package/lib/module/GestureViewer.js.map +1 -1
  4. package/lib/module/GestureViewerManager.js +43 -14
  5. package/lib/module/GestureViewerManager.js.map +1 -1
  6. package/lib/module/index.js.map +1 -1
  7. package/lib/module/itemDimensions.js +86 -0
  8. package/lib/module/itemDimensions.js.map +1 -0
  9. package/lib/module/useGestureViewer.js +246 -28
  10. package/lib/module/useGestureViewer.js.map +1 -1
  11. package/lib/module/useGestureViewerPaging.js +17 -5
  12. package/lib/module/useGestureViewerPaging.js.map +1 -1
  13. package/lib/module/useGestureViewerPaging.web.js +31 -7
  14. package/lib/module/useGestureViewerPaging.web.js.map +1 -1
  15. package/lib/module/utils/index.js +43 -10
  16. package/lib/module/utils/index.js.map +1 -1
  17. package/lib/module/utils/tapZoom.js +51 -13
  18. package/lib/module/utils/tapZoom.js.map +1 -1
  19. package/lib/typescript/src/GestureViewer.d.ts.map +1 -1
  20. package/lib/typescript/src/GestureViewerManager.d.ts +16 -3
  21. package/lib/typescript/src/GestureViewerManager.d.ts.map +1 -1
  22. package/lib/typescript/src/index.d.ts +1 -1
  23. package/lib/typescript/src/index.d.ts.map +1 -1
  24. package/lib/typescript/src/itemDimensions.d.ts +26 -0
  25. package/lib/typescript/src/itemDimensions.d.ts.map +1 -0
  26. package/lib/typescript/src/types.d.ts +40 -1
  27. package/lib/typescript/src/types.d.ts.map +1 -1
  28. package/lib/typescript/src/useGestureViewer.d.ts +4 -2
  29. package/lib/typescript/src/useGestureViewer.d.ts.map +1 -1
  30. package/lib/typescript/src/useGestureViewerPaging.d.ts +1 -1
  31. package/lib/typescript/src/useGestureViewerPaging.d.ts.map +1 -1
  32. package/lib/typescript/src/useGestureViewerPaging.types.d.ts +3 -0
  33. package/lib/typescript/src/useGestureViewerPaging.types.d.ts.map +1 -1
  34. package/lib/typescript/src/useGestureViewerPaging.web.d.ts +1 -1
  35. package/lib/typescript/src/useGestureViewerPaging.web.d.ts.map +1 -1
  36. package/lib/typescript/src/utils/index.d.ts +7 -3
  37. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  38. package/lib/typescript/src/utils/tapZoom.d.ts +17 -1
  39. package/lib/typescript/src/utils/tapZoom.d.ts.map +1 -1
  40. package/package.json +1 -1
  41. package/src/GestureViewer.tsx +64 -18
  42. package/src/GestureViewerManager.ts +58 -19
  43. package/src/index.tsx +5 -0
  44. package/src/itemDimensions.ts +162 -0
  45. package/src/types.ts +51 -1
  46. package/src/useGestureViewer.ts +381 -52
  47. package/src/useGestureViewerPaging.ts +21 -7
  48. package/src/useGestureViewerPaging.types.ts +3 -0
  49. package/src/useGestureViewerPaging.web.ts +46 -7
  50. package/src/utils/index.ts +86 -27
  51. package/src/utils/tapZoom.ts +66 -13
package/src/types.ts CHANGED
@@ -82,6 +82,42 @@ export type GestureViewerSingleTapEvent<ItemT> = {
82
82
  item: ItemT;
83
83
  };
84
84
 
85
+ export type GestureViewerItemDimensions = Readonly<{
86
+ /**
87
+ * Natural/source content width. Must be finite and greater than zero.
88
+ */
89
+ width: number;
90
+ /**
91
+ * Natural/source content height. Must be finite and greater than zero.
92
+ */
93
+ height: number;
94
+ }>;
95
+
96
+ export type GestureViewerItemDimensionsResolver<ItemT> = (
97
+ item: ItemT,
98
+ index: number,
99
+ ) => GestureViewerItemDimensions | undefined;
100
+
101
+ export type GestureViewerItemKey = string | number;
102
+
103
+ export type GestureViewerItemKeyResolver<ItemT> = (
104
+ item: ItemT,
105
+ index: number,
106
+ ) => GestureViewerItemKey;
107
+
108
+ export type GestureViewerRenderItemInfo = {
109
+ /**
110
+ * Whether the rendered item is currently active.
111
+ * @remarks The current item remains active during a page transition. When the transition finishes on another item, that item becomes active.
112
+ */
113
+ readonly isActive: boolean;
114
+ /**
115
+ * Registers natural/source dimensions for the rendered item after they become available.
116
+ * @remarks Call this from an image load/event callback or a passive effect after commit. Do not call it directly while rendering or from descendant layout effects.
117
+ */
118
+ readonly setItemDimensions: (dimensions: GestureViewerItemDimensions) => void;
119
+ };
120
+
85
121
  export type GestureViewerDismissDirection = 'down' | 'up' | 'both';
86
122
 
87
123
  export interface TriggerAnimationConfig extends WithTimingConfig {
@@ -114,6 +150,7 @@ export interface GestureViewerProps<ItemT, LC> {
114
150
  data: ItemT[];
115
151
  /**
116
152
  * The index of the item to display in the `GestureViewer` when the component is mounted.
153
+ * @remarks The value is normalized to the current data: non-finite or negative values use `0`, values above the available range use the last index, and empty data uses `0`. Updating this prop repositions a mounted viewer.
117
154
  * @defaultValue 0
118
155
  */
119
156
  initialIndex?: number;
@@ -128,8 +165,11 @@ export interface GestureViewerProps<ItemT, LC> {
128
165
  onDismissStart?: () => void;
129
166
  /**
130
167
  * A callback function that is called to render the item.
168
+ * @param item - The item to render.
169
+ * @param index - The list index of the rendered item.
170
+ * @param info - Render state for this list cell.
131
171
  */
132
- renderItem: (item: ItemT, index: number) => React.ReactElement;
172
+ renderItem: (item: ItemT, index: number, info: GestureViewerRenderItemInfo) => React.ReactElement;
133
173
  /**
134
174
  * A callback function that is called when a single tap is confirmed on the viewer content.
135
175
  * @remarks
@@ -138,6 +178,16 @@ export interface GestureViewerProps<ItemT, LC> {
138
178
  * - Prefer this callback over overlaying a pressable in `renderContainer` for fullscreen tap handling.
139
179
  */
140
180
  onSingleTap?: (event: GestureViewerSingleTapEvent<ItemT>) => void;
181
+ /**
182
+ * Returns natural/source dimensions for an item when they are already known.
183
+ * @remarks Return `undefined` while dimensions are unavailable. Invalid dimensions fall back to the viewer cell size.
184
+ */
185
+ getItemDimensions?: GestureViewerItemDimensionsResolver<ItemT>;
186
+ /**
187
+ * Returns a stable key used to retain loaded dimensions when equivalent item objects are recreated at the same index.
188
+ * @remarks Keys must be unique within `data` and change when the rendered content's natural dimensions can change. Do not use the index by itself. This is only needed when object items are recreated and dimensions are reported through `setItemDimensions`.
189
+ */
190
+ getItemKey?: GestureViewerItemKeyResolver<ItemT>;
141
191
  /**
142
192
  * A callback function that is called to render the container.
143
193
  * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer.
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
2
2
  import { Platform, type View, useWindowDimensions } from 'react-native';
3
3
  import { Gesture, type GestureType } from 'react-native-gesture-handler';
4
4
  import {
@@ -14,10 +14,23 @@ import { scheduleOnRN } from 'react-native-worklets';
14
14
 
15
15
  import type GestureViewerManager from './GestureViewerManager';
16
16
  import { registry } from './GestureViewerRegistry';
17
+ import {
18
+ type ItemDimensionsRegistry,
19
+ pruneItemDimensionsRegistry,
20
+ registerItemDimensions,
21
+ resolveItemDimensions,
22
+ } from './itemDimensions';
17
23
  import { scheduleInitialScroll } from './scheduleInitialScroll';
18
- import type { GestureViewerProps, TriggerRect } from './types';
24
+ import type { GestureViewerItemDimensions, GestureViewerProps, TriggerRect } from './types';
19
25
  import { useGestureViewerPaging } from './useGestureViewerPaging';
20
- import { createBoundsConstraint, createScrollAction } from './utils';
26
+ import {
27
+ clampIndex,
28
+ clampTranslationToBounds,
29
+ createScrollAction,
30
+ getLoopAdjustedIndex,
31
+ getLoopPhysicalIndex,
32
+ resolveGeometrySyncTranslationMode,
33
+ } from './utils';
21
34
  import { getDismissDistance, shouldDismissByDirection } from './utils/dismiss';
22
35
  import { applyTapZoomAtPoint } from './utils/tapZoom';
23
36
  import { calculateFocalPointTranslation, shouldAcceptFocalPoint } from './utils/zoom';
@@ -33,6 +46,43 @@ type UseGestureViewerProps<ItemT, LC> = Omit<
33
46
  | 'enableSnapMode'
34
47
  >;
35
48
 
49
+ type ViewerPositionSnapshot = Readonly<{
50
+ dataLength: number;
51
+ initialIndex: number;
52
+ pageStride: number;
53
+ usesLoopSentinels: boolean;
54
+ }>;
55
+
56
+ function getViewerPositionChanges(
57
+ previous: ViewerPositionSnapshot | null,
58
+ current: ViewerPositionSnapshot,
59
+ ) {
60
+ return {
61
+ isInitial: previous === null,
62
+ didDataLengthChange: previous !== null && previous.dataLength !== current.dataLength,
63
+ didInitialIndexChange: previous !== null && previous.initialIndex !== current.initialIndex,
64
+ didLoopLayoutChange:
65
+ previous !== null && previous.usesLoopSentinels !== current.usesLoopSentinels,
66
+ didPageStrideChange: previous !== null && previous.pageStride !== current.pageStride,
67
+ };
68
+ }
69
+
70
+ function fitItemDimensions(
71
+ dimensions: GestureViewerItemDimensions | undefined,
72
+ viewport: GestureViewerItemDimensions,
73
+ ): GestureViewerItemDimensions {
74
+ if (!dimensions) {
75
+ return viewport;
76
+ }
77
+
78
+ const fitScale = Math.min(viewport.width / dimensions.width, viewport.height / dimensions.height);
79
+
80
+ return {
81
+ width: dimensions.width * fitScale,
82
+ height: dimensions.height * fitScale,
83
+ };
84
+ }
85
+
36
86
  export const useGestureViewer = <ItemT, LC>({
37
87
  data,
38
88
  initialIndex = 0,
@@ -53,6 +103,8 @@ export const useGestureViewer = <ItemT, LC>({
53
103
  triggerAnimation,
54
104
  autoPlay = false,
55
105
  autoPlayInterval = 3000,
106
+ getItemDimensions,
107
+ getItemKey,
56
108
  }: UseGestureViewerProps<ItemT, LC>) => {
57
109
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
58
110
  const width = customWidth || screenWidth;
@@ -74,7 +126,11 @@ export const useGestureViewer = <ItemT, LC>({
74
126
  const onAnimationCompleteRef = useRef(triggerAnimation?.onAnimationComplete);
75
127
  const onSingleTapRef = useRef(onSingleTap);
76
128
  const dataRef = useRef(data);
129
+ const getItemDimensionsRef = useRef(getItemDimensions);
130
+ const getItemKeyRef = useRef(getItemKey);
77
131
  const managerRef = useRef(manager);
132
+ const configuredManagerRef = useRef<GestureViewerManager | null>(null);
133
+ const itemDimensionsRef = useRef<ItemDimensionsRegistry<ItemT>>(new Map());
78
134
 
79
135
  const isValidTriggerRect = useCallback((rect: TriggerRect | null): rect is TriggerRect => {
80
136
  return !!rect && rect.width > 0 && rect.height > 0;
@@ -89,6 +145,8 @@ export const useGestureViewer = <ItemT, LC>({
89
145
  const scale = useSharedValue(1);
90
146
  const backdropOpacity = useSharedValue(1);
91
147
  const rotation = useSharedValue(0);
148
+ const contentWidth = useSharedValue(width);
149
+ const contentHeight = useSharedValue(height);
92
150
 
93
151
  const triggerScale = useSharedValue(1);
94
152
  const triggerTranslateX = useSharedValue(0);
@@ -102,6 +160,122 @@ export const useGestureViewer = <ItemT, LC>({
102
160
  const hasActiveFocal = useSharedValue(false);
103
161
 
104
162
  const dataLength = data?.length || 0;
163
+ const usesLoopSentinels = enableLoop && dataLength > 1;
164
+ const pageStride = width + itemSpacing;
165
+ const adjustedInitialIndex = getLoopPhysicalIndex(initialIndex, dataLength, enableLoop);
166
+ // Manager setup can rerender before a deferred list scroll runs, so each consumer tracks
167
+ // the last committed position inputs independently.
168
+ const managerPositionSnapshotRef = useRef<ViewerPositionSnapshot | null>(null);
169
+ const listPositionSnapshotRef = useRef<ViewerPositionSnapshot | null>(null);
170
+ const viewportRef = useRef({ height, width });
171
+ const loopConfigRef = useRef({ dataLength, enableLoop });
172
+ const activeGeometryRef = useRef<{
173
+ contentHeight: number;
174
+ contentWidth: number;
175
+ height: number;
176
+ width: number;
177
+ } | null>(null);
178
+ const activeGeometryIndexRef = useRef<number | null>(null);
179
+
180
+ const syncActiveContentDimensions = useCallback(
181
+ (logicalIndex = pendingIndexRef.current) => {
182
+ const viewport = viewportRef.current;
183
+ const fitted = fitItemDimensions(
184
+ resolveItemDimensions({
185
+ data: dataRef.current,
186
+ getItemDimensions: getItemDimensionsRef.current,
187
+ getItemKey: getItemKeyRef.current,
188
+ index: logicalIndex,
189
+ registry: itemDimensionsRef.current,
190
+ }),
191
+ viewport,
192
+ );
193
+ const nextGeometry = {
194
+ contentHeight: fitted.height,
195
+ contentWidth: fitted.width,
196
+ height: viewport.height,
197
+ width: viewport.width,
198
+ };
199
+ const previousGeometry = activeGeometryRef.current;
200
+ const previousGeometryIndex = activeGeometryIndexRef.current;
201
+
202
+ activeGeometryIndexRef.current = logicalIndex;
203
+
204
+ const currentScale = scale.get();
205
+ const translationMode = resolveGeometrySyncTranslationMode(
206
+ previousGeometryIndex,
207
+ logicalIndex,
208
+ currentScale,
209
+ );
210
+
211
+ if (translationMode === 'reset') {
212
+ // Complete the page-owned reset before the next item's geometry can reuse the old offset.
213
+ translateX.set(0);
214
+ translateY.set(0);
215
+ }
216
+
217
+ if (
218
+ previousGeometry?.contentHeight === nextGeometry.contentHeight &&
219
+ previousGeometry.contentWidth === nextGeometry.contentWidth &&
220
+ previousGeometry.height === nextGeometry.height &&
221
+ previousGeometry.width === nextGeometry.width
222
+ ) {
223
+ return;
224
+ }
225
+
226
+ activeGeometryRef.current = nextGeometry;
227
+
228
+ if (contentWidth.get() !== fitted.width) {
229
+ contentWidth.set(fitted.width);
230
+ }
231
+ if (contentHeight.get() !== fitted.height) {
232
+ contentHeight.set(fitted.height);
233
+ }
234
+
235
+ if (translationMode !== 'constrain') {
236
+ return;
237
+ }
238
+
239
+ const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } =
240
+ clampTranslationToBounds({
241
+ contentHeight: fitted.height,
242
+ contentWidth: fitted.width,
243
+ height: viewport.height,
244
+ scale: currentScale,
245
+ translateX: translateX.get(),
246
+ translateY: translateY.get(),
247
+ width: viewport.width,
248
+ });
249
+
250
+ translateX.set(withTiming(constrainedTranslateX));
251
+ translateY.set(withTiming(constrainedTranslateY));
252
+ },
253
+ [contentHeight, contentWidth, scale, translateX, translateY],
254
+ );
255
+
256
+ const setItemDimensions = useCallback(
257
+ (listIndex: number, item: ItemT, dimensions: GestureViewerItemDimensions) => {
258
+ const { dataLength: currentDataLength, enableLoop: currentEnableLoop } =
259
+ loopConfigRef.current;
260
+ const logicalIndex =
261
+ currentDataLength <= 0
262
+ ? listIndex
263
+ : getLoopAdjustedIndex(listIndex, currentDataLength, currentEnableLoop).realIndex;
264
+ const didUpdateDimensions = registerItemDimensions({
265
+ data: dataRef.current,
266
+ dimensions,
267
+ getItemKey: getItemKeyRef.current,
268
+ index: logicalIndex,
269
+ item,
270
+ registry: itemDimensionsRef.current,
271
+ });
272
+
273
+ if (didUpdateDimensions && logicalIndex === pendingIndexRef.current) {
274
+ syncActiveContentDimensions(logicalIndex);
275
+ }
276
+ },
277
+ [syncActiveContentDimensions],
278
+ );
105
279
 
106
280
  const animationConfig = useMemo(
107
281
  () => ({
@@ -129,26 +303,38 @@ export const useGestureViewer = <ItemT, LC>({
129
303
  ],
130
304
  );
131
305
 
132
- const adjustedInitialIndex = useMemo(() => {
133
- if (enableLoop && dataLength > 1) {
134
- return initialIndex + 1;
135
- }
136
-
137
- return initialIndex;
138
- }, [enableLoop, dataLength, initialIndex]);
139
-
140
- const constrainTranslation = useMemo(
141
- () => createBoundsConstraint({ height, width }),
142
- [width, height],
306
+ const constrainTranslation = useCallback(
307
+ ({
308
+ scale: targetScale,
309
+ translateX: targetTranslateX,
310
+ translateY: targetTranslateY,
311
+ }: {
312
+ translateX: number;
313
+ translateY: number;
314
+ scale: number;
315
+ }) => {
316
+ 'worklet';
317
+
318
+ return clampTranslationToBounds({
319
+ contentHeight: contentHeight.get(),
320
+ contentWidth: contentWidth.get(),
321
+ height,
322
+ scale: targetScale,
323
+ translateX: targetTranslateX,
324
+ translateY: targetTranslateY,
325
+ width,
326
+ });
327
+ },
328
+ [contentHeight, contentWidth, height, width],
143
329
  );
144
330
 
145
331
  const scrollTo = useCallback(
146
332
  (index: number, animated: boolean) => {
147
- const scrollAction = createScrollAction(listRef.current, width + itemSpacing);
333
+ const scrollAction = createScrollAction(listRef.current, pageStride);
148
334
 
149
335
  return scrollAction.scrollTo(index, animated);
150
336
  },
151
- [width, itemSpacing],
337
+ [pageStride],
152
338
  );
153
339
 
154
340
  const resetTransformState = useCallback(() => {
@@ -167,9 +353,14 @@ export const useGestureViewer = <ItemT, LC>({
167
353
  return;
168
354
  }
169
355
 
356
+ if (nextIndex === pendingIndexRef.current) {
357
+ return;
358
+ }
359
+
170
360
  pendingIndexRef.current = nextIndex;
361
+ syncActiveContentDimensions(nextIndex);
171
362
  },
172
- [dataLength],
363
+ [dataLength, syncActiveContentDimensions],
173
364
  );
174
365
 
175
366
  const syncCurrentIndex = useCallback(
@@ -179,6 +370,7 @@ export const useGestureViewer = <ItemT, LC>({
179
370
  }
180
371
 
181
372
  pendingIndexRef.current = nextIndex;
373
+ syncActiveContentDimensions(nextIndex);
182
374
 
183
375
  const managerCurrentIndex = manager.getState().currentIndex;
184
376
 
@@ -190,7 +382,7 @@ export const useGestureViewer = <ItemT, LC>({
190
382
  manager.notifyStateChange();
191
383
  resetTransformState();
192
384
  },
193
- [dataLength, manager, resetTransformState],
385
+ [dataLength, manager, resetTransformState, syncActiveContentDimensions],
194
386
  );
195
387
 
196
388
  const emitZoomChange = useCallback((currentScale: number, prevScale: number | null) => {
@@ -249,37 +441,78 @@ export const useGestureViewer = <ItemT, LC>({
249
441
  }, [id]);
250
442
 
251
443
  useEffect(() => {
252
- pendingIndexRef.current = initialIndex;
444
+ const currentPositionSnapshot = {
445
+ dataLength,
446
+ initialIndex,
447
+ pageStride,
448
+ usesLoopSentinels,
449
+ };
450
+ const { didDataLengthChange, didInitialIndexChange, didLoopLayoutChange } =
451
+ getViewerPositionChanges(managerPositionSnapshotRef.current, currentPositionSnapshot);
452
+
453
+ managerPositionSnapshotRef.current = currentPositionSnapshot;
253
454
 
254
455
  if (!manager) {
456
+ configuredManagerRef.current = null;
255
457
  return;
256
458
  }
257
459
 
460
+ const isNewManager = configuredManagerRef.current !== manager;
461
+ const shouldApplyInitialIndex =
462
+ isNewManager || didInitialIndexChange || didDataLengthChange || didLoopLayoutChange;
463
+ const shouldSyncInitialIndex =
464
+ didInitialIndexChange ||
465
+ didDataLengthChange ||
466
+ didLoopLayoutChange ||
467
+ activeGeometryIndexRef.current !== initialIndex;
468
+
258
469
  manager.setDataLength(dataLength);
259
470
  manager.setEnableHorizontalSwipe(enableHorizontalSwipe);
260
- manager.setCurrentIndex(initialIndex);
261
- manager.setWidth(width + itemSpacing);
471
+ manager.setPagingStride(pageStride);
472
+ manager.setViewportWidth(width);
262
473
  manager.setHeight(height);
263
- manager.setZoomSharedValues(scale, translateX, translateY, maxZoomScale);
474
+ manager.setZoomSharedValues({
475
+ contentHeight,
476
+ contentWidth,
477
+ maxZoomScale,
478
+ scale,
479
+ translateX,
480
+ translateY,
481
+ });
264
482
  manager.setResetTransformCallback(resetTransformState);
265
483
  manager.setRotation(rotation);
266
484
  manager.setEnableLoop(enableLoop);
485
+
486
+ if (shouldApplyInitialIndex) {
487
+ pendingIndexRef.current = initialIndex;
488
+ manager.setCurrentIndex(initialIndex);
489
+
490
+ if (shouldSyncInitialIndex) {
491
+ syncActiveContentDimensions(initialIndex);
492
+ }
493
+ }
494
+
495
+ configuredManagerRef.current = manager;
267
496
  manager.notifyStateChange();
268
497
  }, [
269
498
  dataLength,
270
499
  enableHorizontalSwipe,
271
500
  initialIndex,
272
501
  manager,
502
+ pageStride,
273
503
  width,
274
- itemSpacing,
275
504
  maxZoomScale,
276
505
  enableLoop,
277
506
  scale,
278
507
  height,
508
+ contentWidth,
509
+ contentHeight,
279
510
  resetTransformState,
511
+ syncActiveContentDimensions,
280
512
  translateX,
281
513
  translateY,
282
514
  rotation,
515
+ usesLoopSentinels,
283
516
  ]);
284
517
 
285
518
  useEffect(() => {
@@ -291,6 +524,35 @@ export const useGestureViewer = <ItemT, LC>({
291
524
  }, [manager]);
292
525
 
293
526
  useEffect(() => {
527
+ const currentPositionSnapshot = {
528
+ dataLength,
529
+ initialIndex,
530
+ pageStride,
531
+ usesLoopSentinels,
532
+ };
533
+ const {
534
+ didDataLengthChange,
535
+ didInitialIndexChange,
536
+ didLoopLayoutChange,
537
+ didPageStrideChange,
538
+ isInitial: isInitialPositionRender,
539
+ } = getViewerPositionChanges(listPositionSnapshotRef.current, currentPositionSnapshot);
540
+ const commitPositionSnapshot = () => {
541
+ listPositionSnapshotRef.current = currentPositionSnapshot;
542
+ };
543
+
544
+ const shouldResetToInitialIndex =
545
+ isInitialPositionRender ||
546
+ didInitialIndexChange ||
547
+ didDataLengthChange ||
548
+ didLoopLayoutChange;
549
+ const shouldRealignCurrentIndex = didPageStrideChange && !shouldResetToInitialIndex;
550
+
551
+ if (!shouldResetToInitialIndex && !shouldRealignCurrentIndex) {
552
+ commitPositionSnapshot();
553
+ return;
554
+ }
555
+
294
556
  translateY.set(0);
295
557
  translateX.set(0);
296
558
  scale.set(1);
@@ -298,15 +560,44 @@ export const useGestureViewer = <ItemT, LC>({
298
560
  startScale.set(1);
299
561
  rotation.set(0);
300
562
 
301
- if (adjustedInitialIndex <= 0 || !listRef.current) {
563
+ if (dataLength === 0 || !listRef.current) {
564
+ commitPositionSnapshot();
565
+ return;
566
+ }
567
+
568
+ if (isInitialPositionRender && adjustedInitialIndex === 0) {
569
+ commitPositionSnapshot();
570
+ return;
571
+ }
572
+
573
+ const logicalIndex = shouldResetToInitialIndex
574
+ ? initialIndex
575
+ : clampIndex(
576
+ managerRef.current?.getState().currentIndex ?? pendingIndexRef.current,
577
+ dataLength,
578
+ );
579
+ const physicalIndex = getLoopPhysicalIndex(logicalIndex, dataLength, enableLoop);
580
+
581
+ if (shouldRealignCurrentIndex) {
582
+ pendingIndexRef.current = logicalIndex;
583
+ syncActiveContentDimensions(logicalIndex);
584
+ }
585
+
586
+ if (shouldRealignCurrentIndex && physicalIndex === 0) {
587
+ commitPositionSnapshot();
302
588
  return;
303
589
  }
304
590
 
305
591
  return scheduleInitialScroll(() => {
306
- scrollTo(adjustedInitialIndex, false);
592
+ scrollTo(physicalIndex, false);
593
+ commitPositionSnapshot();
307
594
  });
308
595
  }, [
309
596
  adjustedInitialIndex,
597
+ dataLength,
598
+ enableLoop,
599
+ initialIndex,
600
+ pageStride,
310
601
  translateY,
311
602
  backdropOpacity,
312
603
  translateX,
@@ -314,15 +605,36 @@ export const useGestureViewer = <ItemT, LC>({
314
605
  startScale,
315
606
  rotation,
316
607
  scrollTo,
608
+ syncActiveContentDimensions,
609
+ usesLoopSentinels,
317
610
  ]);
318
611
 
319
612
  useEffect(() => {
320
613
  onAnimationCompleteRef.current = triggerAnimation?.onAnimationComplete;
321
614
  }, [triggerAnimation?.onAnimationComplete]);
322
615
 
323
- useEffect(() => {
616
+ useLayoutEffect(() => {
617
+ // Retained virtualized-cell callbacks read only the most recently committed props.
324
618
  dataRef.current = data;
325
- }, [data]);
619
+ getItemDimensionsRef.current = getItemDimensions;
620
+ getItemKeyRef.current = getItemKey;
621
+ viewportRef.current = { height, width };
622
+ loopConfigRef.current = { dataLength, enableLoop };
623
+ syncActiveContentDimensions();
624
+ }, [
625
+ data,
626
+ dataLength,
627
+ enableLoop,
628
+ getItemDimensions,
629
+ getItemKey,
630
+ height,
631
+ syncActiveContentDimensions,
632
+ width,
633
+ ]);
634
+
635
+ useEffect(() => {
636
+ pruneItemDimensionsRegistry(itemDimensionsRef.current, dataLength);
637
+ }, [dataLength]);
326
638
 
327
639
  useEffect(() => {
328
640
  managerRef.current = manager;
@@ -757,6 +1069,8 @@ export const useGestureViewer = <ItemT, LC>({
757
1069
  .numberOfTaps(2)
758
1070
  .onEnd((event) => {
759
1071
  applyTapZoomAtPoint({
1072
+ contentHeight: contentHeight.get(),
1073
+ contentWidth: contentWidth.get(),
760
1074
  x: event.x,
761
1075
  y: event.y,
762
1076
  width,
@@ -767,7 +1081,17 @@ export const useGestureViewer = <ItemT, LC>({
767
1081
  translateY,
768
1082
  });
769
1083
  }),
770
- [enableDoubleTapZoom, height, maxZoomScale, scale, translateX, translateY, width],
1084
+ [
1085
+ contentHeight,
1086
+ contentWidth,
1087
+ enableDoubleTapZoom,
1088
+ height,
1089
+ maxZoomScale,
1090
+ scale,
1091
+ translateX,
1092
+ translateY,
1093
+ width,
1094
+ ],
771
1095
  );
772
1096
 
773
1097
  const tapGesture = useMemo(
@@ -811,32 +1135,36 @@ export const useGestureViewer = <ItemT, LC>({
811
1135
  return Gesture.Native().requireExternalGestureToFail(dismissGestureRef);
812
1136
  }, []);
813
1137
 
814
- const { onMomentumScrollEnd, onScroll, onScrollBeginDrag, onWebClick } = useGestureViewerPaging({
815
- adjustedInitialIndex,
816
- autoPlay,
817
- autoPlayInterval,
818
- currentIndex,
819
- dataLength,
820
- enableDoubleTapZoom,
821
- enableHorizontalSwipe,
822
- enableLoop,
823
- height,
824
- isRotated,
825
- isZoomed,
826
- itemSpacing,
827
- manager,
828
- maxZoomScale,
829
- onSingleTap: emitSingleTap,
830
- scale,
831
- scrollTo,
832
- syncCurrentIndex,
833
- syncPendingIndex,
834
- translateX,
835
- translateY,
836
- width,
837
- });
1138
+ const { activeListIndex, onMomentumScrollEnd, onScroll, onScrollBeginDrag, onWebClick } =
1139
+ useGestureViewerPaging({
1140
+ adjustedInitialIndex,
1141
+ autoPlay,
1142
+ autoPlayInterval,
1143
+ contentHeight,
1144
+ contentWidth,
1145
+ currentIndex,
1146
+ dataLength,
1147
+ enableDoubleTapZoom,
1148
+ enableHorizontalSwipe,
1149
+ enableLoop,
1150
+ height,
1151
+ isRotated,
1152
+ isZoomed,
1153
+ itemSpacing,
1154
+ manager,
1155
+ maxZoomScale,
1156
+ onSingleTap: emitSingleTap,
1157
+ scale,
1158
+ scrollTo,
1159
+ syncCurrentIndex,
1160
+ syncPendingIndex,
1161
+ translateX,
1162
+ translateY,
1163
+ width,
1164
+ });
838
1165
 
839
1166
  return {
1167
+ activeListIndex,
840
1168
  animatedStyle,
841
1169
  backdropStyle,
842
1170
  dataLength,
@@ -853,6 +1181,7 @@ export const useGestureViewer = <ItemT, LC>({
853
1181
  onScroll,
854
1182
 
855
1183
  onScrollBeginDrag,
1184
+ setItemDimensions,
856
1185
  zoomGesture,
857
1186
  };
858
1187
  };