react-native-gesture-image-viewer 2.4.0 → 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 (50) hide show
  1. package/README.md +19 -5
  2. package/lib/module/GestureViewer.js +31 -13
  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 +244 -32
  10. package/lib/module/useGestureViewer.js.map +1 -1
  11. package/lib/module/useGestureViewerPaging.js +1 -3
  12. package/lib/module/useGestureViewerPaging.js.map +1 -1
  13. package/lib/module/useGestureViewerPaging.web.js +26 -18
  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 +29 -0
  27. package/lib/typescript/src/types.d.ts.map +1 -1
  28. package/lib/typescript/src/useGestureViewer.d.ts +3 -2
  29. package/lib/typescript/src/useGestureViewer.d.ts.map +1 -1
  30. package/lib/typescript/src/useGestureViewerPaging.d.ts.map +1 -1
  31. package/lib/typescript/src/useGestureViewerPaging.types.d.ts +2 -0
  32. package/lib/typescript/src/useGestureViewerPaging.types.d.ts.map +1 -1
  33. package/lib/typescript/src/useGestureViewerPaging.web.d.ts +1 -1
  34. package/lib/typescript/src/useGestureViewerPaging.web.d.ts.map +1 -1
  35. package/lib/typescript/src/utils/index.d.ts +7 -3
  36. package/lib/typescript/src/utils/index.d.ts.map +1 -1
  37. package/lib/typescript/src/utils/tapZoom.d.ts +17 -1
  38. package/lib/typescript/src/utils/tapZoom.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/GestureViewer.tsx +47 -16
  41. package/src/GestureViewerManager.ts +58 -19
  42. package/src/index.tsx +4 -0
  43. package/src/itemDimensions.ts +162 -0
  44. package/src/types.ts +39 -0
  45. package/src/useGestureViewer.ts +352 -36
  46. package/src/useGestureViewerPaging.ts +1 -3
  47. package/src/useGestureViewerPaging.types.ts +2 -0
  48. package/src/useGestureViewerPaging.web.ts +41 -21
  49. package/src/utils/index.ts +86 -27
  50. package/src/utils/tapZoom.ts +66 -13
@@ -1,3 +1,3 @@
1
1
  import type { UseGestureViewerPagingArgs, UseGestureViewerPagingResult } from './useGestureViewerPaging.types';
2
- export declare function useGestureViewerPaging({ adjustedInitialIndex, autoPlay, autoPlayInterval, currentIndex, dataLength, enableDoubleTapZoom, enableHorizontalSwipe, enableLoop, height, onSingleTap, isRotated, isZoomed, itemSpacing, manager, maxZoomScale, scale, scrollTo, syncCurrentIndex, syncPendingIndex, translateX, translateY, width, }: UseGestureViewerPagingArgs): UseGestureViewerPagingResult;
2
+ export declare function useGestureViewerPaging({ adjustedInitialIndex, autoPlay, autoPlayInterval, contentHeight, contentWidth, currentIndex, dataLength, enableDoubleTapZoom, enableHorizontalSwipe, enableLoop, height, onSingleTap, isRotated, isZoomed, itemSpacing, manager, maxZoomScale, scale, scrollTo, syncCurrentIndex, syncPendingIndex, translateX, translateY, width, }: UseGestureViewerPagingArgs): UseGestureViewerPagingResult;
3
3
  //# sourceMappingURL=useGestureViewerPaging.web.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useGestureViewerPaging.web.d.ts","sourceRoot":"","sources":["../../../src/useGestureViewerPaging.web.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,0BAA0B,EAC1B,4BAA4B,EAE7B,MAAM,gCAAgC,CAAC;AAqBxC,wBAAgB,sBAAsB,CAAC,EACrC,oBAAoB,EACpB,QAAQ,EACR,gBAAgB,EAChB,YAAY,EACZ,UAAU,EACV,mBAAmB,EACnB,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,WAAW,EACX,SAAS,EACT,QAAQ,EACR,WAAW,EACX,OAAO,EACP,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,KAAK,GACN,EAAE,0BAA0B,GAAG,4BAA4B,CAiZ3D"}
1
+ {"version":3,"file":"useGestureViewerPaging.web.d.ts","sourceRoot":"","sources":["../../../src/useGestureViewerPaging.web.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,0BAA0B,EAC1B,4BAA4B,EAE7B,MAAM,gCAAgC,CAAC;AAqBxC,wBAAgB,sBAAsB,CAAC,EACrC,oBAAoB,EACpB,QAAQ,EACR,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,mBAAmB,EACnB,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,WAAW,EACX,SAAS,EACT,QAAQ,EACR,WAAW,EACX,OAAO,EACP,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,gBAAgB,EAChB,UAAU,EACV,UAAU,EACV,KAAK,GACN,EAAE,0BAA0B,GAAG,4BAA4B,CAma3D"}
@@ -9,10 +9,13 @@ export declare const isFlashListLike: (component: React.ComponentType<any>) => b
9
9
  * and we also avoid wrapping RNGH-provided scrollables to prevent double-applying Gesture.Native() to components that already participate in RNGH.
10
10
  */
11
11
  export declare const shouldUseNativeScrollGesture: (platformOS: PlatformOSType, component: React.ComponentType<any>) => boolean;
12
- export declare const createBoundsConstraint: ({ width, height }: {
12
+ export declare const clampIndex: (index: number | undefined, dataLength: number) => number;
13
+ export declare const resolveGeometrySyncTranslationMode: (previousIndex: number | null, nextIndex: number, scale: number) => "constrain" | "none" | "reset";
14
+ export declare const clampTranslationToBounds: ({ width, height, contentWidth, contentHeight, scale, translateX, translateY, }: {
13
15
  width: number;
14
16
  height: number;
15
- }) => ({ scale, translateX, translateY, }: {
17
+ contentWidth?: number;
18
+ contentHeight?: number;
16
19
  translateX: number;
17
20
  translateY: number;
18
21
  scale: number;
@@ -20,7 +23,8 @@ export declare const createBoundsConstraint: ({ width, height }: {
20
23
  translateX: number;
21
24
  translateY: number;
22
25
  };
23
- export declare const createLoopData: <T>(dataRef: React.RefObject<T[]>, enableLoop: boolean) => T[];
26
+ export declare const createLoopData: <T>(data: T[], enableLoop: boolean) => T[];
27
+ export declare const getLoopPhysicalIndex: (logicalIndex: number, dataLength: number, enableLoop: boolean) => number;
24
28
  export declare const getLoopAdjustedIndex: (scrollIndex: number, originalDataLength: number, enableLoop: boolean) => {
25
29
  realIndex: number;
26
30
  needsJump: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,cAAc,CAAC;AAMtB,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAIvE,eAAO,MAAM,gBAAgB,GAC3B,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,SAAS,IAAI,mBAC+C,CAAC;AAEhE,eAAO,MAAM,cAAc,GACzB,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,SAAS,IAAI,iBAAiB,CAAC,GAAG,CAMpC,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAAG,OAMrE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,GACvC,YAAY,cAAc,EAC1B,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,OAEF,CAAC;AAEF,eAAO,MAAM,sBAAsB,GAChC,mBAAmB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,MACpD,oCAIE;IACD,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;;;CAiBA,CAAC;AAEJ,eAAO,MAAM,cAAc,GAAI,CAAC,EAAE,SAAS,KAAK,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,EAAE,YAAY,OAAO,KAAG,CAAC,EAevF,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,aAAa,MAAM,EACnB,oBAAoB,MAAM,EAC1B,YAAY,OAAO,KAClB;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAoB/D,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,SAAS,GAAG,EAAE,OAAO,MAAM;sBAC1C,MAAM,YAAY,OAAO;CAO3C,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/utils/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,cAAc,EACpB,MAAM,cAAc,CAAC;AAMtB,OAAO,KAAK,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAIvE,eAAO,MAAM,gBAAgB,GAC3B,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,SAAS,IAAI,mBAC+C,CAAC;AAEhE,eAAO,MAAM,cAAc,GACzB,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,SAAS,IAAI,iBAAiB,CAAC,GAAG,CAMpC,CAAC;AAEF,eAAO,MAAM,eAAe,GAAI,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAAG,OAMrE,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,4BAA4B,GACvC,YAAY,cAAc,EAC1B,WAAW,KAAK,CAAC,aAAa,CAAC,GAAG,CAAC,KAClC,OAEF,CAAC;AAEF,eAAO,MAAM,UAAU,GAAI,OAAO,MAAM,GAAG,SAAS,EAAE,YAAY,MAAM,KAAG,MAY1E,CAAC;AAEF,eAAO,MAAM,kCAAkC,GAC7C,eAAe,MAAM,GAAG,IAAI,EAC5B,WAAW,MAAM,EACjB,OAAO,MAAM,KACZ,WAAW,GAAG,MAAM,GAAG,OAMzB,CAAC;AAoBF,eAAO,MAAM,wBAAwB,GAAI,gFAQtC;IACD,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;CACf;;;CAsBA,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,CAAC,EAAE,MAAM,CAAC,EAAE,EAAE,YAAY,OAAO,KAAG,CAAC,EAanE,CAAC;AAEF,eAAO,MAAM,oBAAoB,GAC/B,cAAc,MAAM,EACpB,YAAY,MAAM,EAClB,YAAY,OAAO,KAClB,MAA0E,CAAC;AAE9E,eAAO,MAAM,oBAAoB,GAC/B,aAAa,MAAM,EACnB,oBAAoB,MAAM,EAC1B,YAAY,OAAO,KAClB;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAoB/D,CAAC;AAEF,eAAO,MAAM,kBAAkB,GAAI,SAAS,GAAG,EAAE,OAAO,MAAM;sBAC1C,MAAM,YAAY,OAAO;CAO3C,CAAC"}
@@ -1,9 +1,25 @@
1
1
  import type { SharedValue } from 'react-native-reanimated';
2
- export declare const applyTapZoomAtPoint: ({ x, y, width, height, maxZoomScale, scale, translateX, translateY, }: {
2
+ export declare const getTapZoomTarget: ({ x, y, width, height, contentWidth, contentHeight, maxZoomScale, scale, }: {
3
3
  x: number;
4
4
  y: number;
5
5
  width: number;
6
6
  height: number;
7
+ contentWidth?: number;
8
+ contentHeight?: number;
9
+ maxZoomScale: number;
10
+ scale: number;
11
+ }) => {
12
+ scale: number;
13
+ translateX: number;
14
+ translateY: number;
15
+ };
16
+ export declare const applyTapZoomAtPoint: ({ x, y, width, height, contentWidth, contentHeight, maxZoomScale, scale, translateX, translateY, }: {
17
+ x: number;
18
+ y: number;
19
+ width: number;
20
+ height: number;
21
+ contentWidth?: number;
22
+ contentHeight?: number;
7
23
  maxZoomScale: number;
8
24
  scale: SharedValue<number>;
9
25
  translateX: SharedValue<number>;
@@ -1 +1 @@
1
- {"version":3,"file":"tapZoom.d.ts","sourceRoot":"","sources":["../../../../src/utils/tapZoom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAG3D,eAAO,MAAM,mBAAmB,GAAI,uEASjC;IACD,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC3B,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAChC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACjC,SAsBA,CAAC"}
1
+ {"version":3,"file":"tapZoom.d.ts","sourceRoot":"","sources":["../../../../src/utils/tapZoom.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAK3D,eAAO,MAAM,gBAAgB,GAAI,4EAS9B;IACD,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;CACf;;;;CA2BA,CAAC;AAEF,eAAO,MAAM,mBAAmB,GAAI,oGAWjC;IACD,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAC3B,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAChC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;CACjC,SAsBA,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-gesture-image-viewer",
3
- "version": "2.4.0",
3
+ "version": "2.5.0",
4
4
  "description": "🖼️ React Native Image Viewer - Reanimated-powered image gestures with full control",
5
5
  "keywords": [
6
6
  "android",
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef } from 'react';
1
+ import { useCallback, useEffect, useMemo, type ReactElement } from 'react';
2
2
  import {
3
3
  Platform,
4
4
  type ScrollViewProps,
@@ -10,10 +10,16 @@ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-g
10
10
  import Animated from 'react-native-reanimated';
11
11
 
12
12
  import { registry } from './GestureViewerRegistry';
13
- import type { GestureViewerProps } from './types';
13
+ import type {
14
+ GestureViewerItemDimensions,
15
+ GestureViewerProps,
16
+ GestureViewerRenderItemInfo,
17
+ } from './types';
14
18
  import { useGestureViewer } from './useGestureViewer';
15
19
  import {
20
+ clampIndex,
16
21
  createLoopData,
22
+ getLoopPhysicalIndex,
17
23
  isFlashListLike,
18
24
  isFlatListLike,
19
25
  isScrollViewLike,
@@ -21,6 +27,31 @@ import {
21
27
  } from './utils';
22
28
  import WebPagingFixStyle from './WebPagingFixStyle';
23
29
 
30
+ type GestureViewerItemCellProps<ItemT> = {
31
+ item: ItemT;
32
+ index: number;
33
+ isActive: boolean;
34
+ renderItem: (item: ItemT, index: number, info: GestureViewerRenderItemInfo) => ReactElement;
35
+ setItemDimensions: (index: number, item: ItemT, dimensions: GestureViewerItemDimensions) => void;
36
+ };
37
+
38
+ function GestureViewerItemCell<ItemT>({
39
+ item,
40
+ index,
41
+ isActive,
42
+ renderItem,
43
+ setItemDimensions,
44
+ }: GestureViewerItemCellProps<ItemT>) {
45
+ const registerItemDimensions = (dimensions: GestureViewerItemDimensions) => {
46
+ setItemDimensions(index, item, dimensions);
47
+ };
48
+
49
+ return renderItem(item, index, {
50
+ isActive,
51
+ setItemDimensions: registerItemDimensions,
52
+ });
53
+ }
54
+
24
55
  export function GestureViewer<ItemT, LC>({
25
56
  id = 'default',
26
57
  data,
@@ -40,14 +71,14 @@ export function GestureViewer<ItemT, LC>({
40
71
  }: GestureViewerProps<ItemT, LC>) {
41
72
  const Component = ListComponent as React.ComponentType<any>;
42
73
 
43
- const dataRef = useRef(data);
44
-
45
74
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
46
75
 
47
76
  const width = customWidth || screenWidth;
48
77
  const height = customHeight || screenHeight;
78
+ const normalizedInitialIndex = clampIndex(initialIndex, data.length);
79
+ const initialListIndex = getLoopPhysicalIndex(normalizedInitialIndex, data.length, enableLoop);
49
80
 
50
- const loopData = useMemo(() => createLoopData(dataRef, enableLoop), [enableLoop]);
81
+ const loopData = useMemo(() => createLoopData(data, enableLoop), [data, enableLoop]);
51
82
 
52
83
  const isScrollView = isScrollViewLike(Component);
53
84
  const isFlashList = isFlashListLike(Component);
@@ -68,12 +99,13 @@ export function GestureViewer<ItemT, LC>({
68
99
  animatedStyle,
69
100
  backdropStyle,
70
101
  handleDismiss,
102
+ setItemDimensions,
71
103
  } = useGestureViewer({
72
104
  id,
73
105
  data,
74
106
  width,
75
107
  height,
76
- initialIndex,
108
+ initialIndex: normalizedInitialIndex,
77
109
  itemSpacing,
78
110
  enableLoop,
79
111
  ...props,
@@ -106,9 +138,13 @@ export function GestureViewer<ItemT, LC>({
106
138
  styles.item,
107
139
  ]}
108
140
  >
109
- {renderItemProp(item, index, {
110
- isActive: index === activeListIndex && isFlashListCell,
111
- })}
141
+ <GestureViewerItemCell
142
+ index={index}
143
+ isActive={index === activeListIndex && isFlashListCell}
144
+ item={item}
145
+ renderItem={renderItemProp}
146
+ setItemDimensions={setItemDimensions}
147
+ />
112
148
  </View>
113
149
  );
114
150
  },
@@ -121,6 +157,7 @@ export function GestureViewer<ItemT, LC>({
121
157
  isScrollView,
122
158
  isFlashList,
123
159
  height,
160
+ setItemDimensions,
124
161
  ],
125
162
  );
126
163
 
@@ -137,10 +174,6 @@ export function GestureViewer<ItemT, LC>({
137
174
  return Gesture.Race(dismissGesture, zoomGesture);
138
175
  }, [zoomGesture, dismissGesture]);
139
176
 
140
- useEffect(() => {
141
- dataRef.current = data;
142
- }, [data]);
143
-
144
177
  useEffect(() => {
145
178
  registry.createManager(id);
146
179
 
@@ -225,9 +258,7 @@ export function GestureViewer<ItemT, LC>({
225
258
  {...commonProps}
226
259
  data={loopData}
227
260
  renderItem={renderItem}
228
- initialScrollIndex={
229
- enableLoop && data.length > 1 ? initialIndex + 1 : initialIndex
230
- }
261
+ initialScrollIndex={initialListIndex}
231
262
  keyExtractor={keyExtractor}
232
263
  {...(isFlashList
233
264
  ? // NOTE - Deprecated estimatedItemSize for FlashList V2 (https://shopify.github.io/flash-list/docs/v2-changes#deprecated)
@@ -6,12 +6,22 @@ import type {
6
6
  GestureViewerEventType,
7
7
  GestureViewerState,
8
8
  } from './types';
9
- import { createBoundsConstraint, createScrollAction } from './utils';
9
+ import { clampTranslationToBounds, createScrollAction } from './utils';
10
+
11
+ type ZoomSharedValuesOptions = {
12
+ scale: SharedValue<number>;
13
+ translateX: SharedValue<number>;
14
+ translateY: SharedValue<number>;
15
+ maxZoomScale: number;
16
+ contentWidth?: SharedValue<number>;
17
+ contentHeight?: SharedValue<number>;
18
+ };
10
19
 
11
20
  class GestureViewerManager {
12
21
  private currentIndex = 0;
13
22
  private dataLength = 0;
14
- private width = 0;
23
+ private pagingStride = 0;
24
+ private viewportWidth = 0;
15
25
  private height = 0;
16
26
  private maxZoomScale = 2;
17
27
  private enableHorizontalSwipe = true;
@@ -22,6 +32,8 @@ class GestureViewerManager {
22
32
  private rotation: SharedValue<number> | null = null;
23
33
  private translateX: SharedValue<number> | null = null;
24
34
  private translateY: SharedValue<number> | null = null;
35
+ private contentWidth: SharedValue<number> | null = null;
36
+ private contentHeight: SharedValue<number> | null = null;
25
37
  private resetTransformCallback: (() => void) | null = null;
26
38
 
27
39
  private loopCallback: (() => void) | null = null;
@@ -105,8 +117,12 @@ class GestureViewerManager {
105
117
  this.enableLoop = enabled;
106
118
  }
107
119
 
108
- setWidth(width: number) {
109
- this.width = width;
120
+ setPagingStride(pagingStride: number) {
121
+ this.pagingStride = pagingStride;
122
+ }
123
+
124
+ setViewportWidth(width: number) {
125
+ this.viewportWidth = width;
110
126
  }
111
127
 
112
128
  setHeight(height: number) {
@@ -131,16 +147,20 @@ class GestureViewerManager {
131
147
  }
132
148
  }
133
149
 
134
- setZoomSharedValues(
135
- scale: SharedValue<number>,
136
- translateX: SharedValue<number>,
137
- translateY: SharedValue<number>,
138
- maxZoomScale: number,
139
- ) {
150
+ setZoomSharedValues({
151
+ scale,
152
+ translateX,
153
+ translateY,
154
+ maxZoomScale,
155
+ contentWidth,
156
+ contentHeight,
157
+ }: ZoomSharedValuesOptions) {
140
158
  this.scale = scale;
141
159
  this.translateX = translateX;
142
160
  this.translateY = translateY;
143
161
  this.maxZoomScale = maxZoomScale;
162
+ this.contentWidth = contentWidth ?? null;
163
+ this.contentHeight = contentHeight ?? null;
144
164
  }
145
165
 
146
166
  setResetTransformCallback(callback: (() => void) | null) {
@@ -203,10 +223,7 @@ class GestureViewerManager {
203
223
 
204
224
  this.scale.set(withTiming(nextScale));
205
225
 
206
- const { translateX, translateY } = createBoundsConstraint({
207
- width: this.width,
208
- height: this.height,
209
- })({
226
+ const { translateX, translateY } = this.constrainZoomTranslation({
210
227
  translateX: this.translateX.get(),
211
228
  translateY: this.translateY.get(),
212
229
  scale: nextScale,
@@ -237,10 +254,7 @@ class GestureViewerManager {
237
254
  return;
238
255
  }
239
256
 
240
- const { translateX, translateY } = createBoundsConstraint({
241
- width: this.width,
242
- height: this.height,
243
- })({
257
+ const { translateX, translateY } = this.constrainZoomTranslation({
244
258
  translateX: this.translateX.get(),
245
259
  translateY: this.translateY.get(),
246
260
  scale: nextScale,
@@ -273,7 +287,7 @@ class GestureViewerManager {
273
287
 
274
288
  this.cancelPendingLoopTransition();
275
289
 
276
- const { scrollTo } = createScrollAction(this.listRef, this.width);
290
+ const { scrollTo } = createScrollAction(this.listRef, this.pagingStride || this.viewportWidth);
277
291
 
278
292
  if (this.enableLoop && this.dataLength > 1) {
279
293
  if (index < 0) {
@@ -358,9 +372,14 @@ class GestureViewerManager {
358
372
  this.currentIndex = 0;
359
373
  this.dataLength = 0;
360
374
  this.maxZoomScale = 2;
375
+ this.pagingStride = 0;
376
+ this.viewportWidth = 0;
377
+ this.height = 0;
361
378
  this.scale = null;
362
379
  this.translateX = null;
363
380
  this.translateY = null;
381
+ this.contentWidth = null;
382
+ this.contentHeight = null;
364
383
  this.rotation = null;
365
384
  this.resetTransformCallback = null;
366
385
  this.eventListeners.clear();
@@ -370,6 +389,26 @@ class GestureViewerManager {
370
389
  this.currentIndex = targetIndex;
371
390
  this.notifyListeners();
372
391
  };
392
+
393
+ private constrainZoomTranslation = ({
394
+ scale,
395
+ translateX,
396
+ translateY,
397
+ }: {
398
+ scale: number;
399
+ translateX: number;
400
+ translateY: number;
401
+ }) => {
402
+ return clampTranslationToBounds({
403
+ contentHeight: this.contentHeight?.get(),
404
+ contentWidth: this.contentWidth?.get(),
405
+ height: this.height,
406
+ scale,
407
+ translateX,
408
+ translateY,
409
+ width: this.viewportWidth || this.pagingStride,
410
+ });
411
+ };
373
412
  }
374
413
 
375
414
  export default GestureViewerManager;
package/src/index.tsx CHANGED
@@ -6,6 +6,10 @@ export type {
6
6
  GestureViewerEventCallback,
7
7
  GestureViewerEventData,
8
8
  GestureViewerEventType,
9
+ GestureViewerItemDimensions,
10
+ GestureViewerItemDimensionsResolver,
11
+ GestureViewerItemKey,
12
+ GestureViewerItemKeyResolver,
9
13
  GestureViewerProps,
10
14
  GestureViewerRenderItemInfo,
11
15
  GestureViewerState,
@@ -0,0 +1,162 @@
1
+ import type {
2
+ GestureViewerItemDimensions,
3
+ GestureViewerItemDimensionsResolver,
4
+ GestureViewerItemKey,
5
+ GestureViewerItemKeyResolver,
6
+ } from './types';
7
+
8
+ type ItemDimensionsRegistryEntry<ItemT> = Readonly<{
9
+ item: ItemT;
10
+ itemKey?: GestureViewerItemKey;
11
+ dimensions: GestureViewerItemDimensions;
12
+ }>;
13
+
14
+ export type ItemDimensionsRegistry<ItemT> = Map<number, ItemDimensionsRegistryEntry<ItemT>>;
15
+
16
+ export const isValidItemDimensions = (
17
+ dimensions: GestureViewerItemDimensions | null | undefined,
18
+ ): dimensions is GestureViewerItemDimensions => {
19
+ return (
20
+ dimensions !== null &&
21
+ dimensions !== undefined &&
22
+ Number.isFinite(dimensions.width) &&
23
+ Number.isFinite(dimensions.height) &&
24
+ dimensions.width > 0 &&
25
+ dimensions.height > 0
26
+ );
27
+ };
28
+
29
+ const resolveItemKey = <ItemT>(
30
+ getItemKey: GestureViewerItemKeyResolver<ItemT> | undefined,
31
+ item: ItemT,
32
+ index: number,
33
+ ): GestureViewerItemKey | undefined => {
34
+ const itemKey = getItemKey?.(item, index);
35
+
36
+ if (typeof itemKey === 'string') {
37
+ return itemKey;
38
+ }
39
+
40
+ return typeof itemKey === 'number' && Number.isFinite(itemKey) ? itemKey : undefined;
41
+ };
42
+
43
+ const doesEntryMatchItem = <ItemT>(
44
+ entry: ItemDimensionsRegistryEntry<ItemT>,
45
+ item: ItemT,
46
+ itemKey: GestureViewerItemKey | undefined,
47
+ ): boolean => {
48
+ if (itemKey !== undefined && entry.itemKey !== undefined) {
49
+ return entry.itemKey === itemKey;
50
+ }
51
+
52
+ return Object.is(entry.item, item);
53
+ };
54
+
55
+ export const pruneItemDimensionsRegistry = <ItemT>(
56
+ registry: ItemDimensionsRegistry<ItemT>,
57
+ dataLength: number,
58
+ ): void => {
59
+ for (const index of registry.keys()) {
60
+ if (index < 0 || index >= dataLength) {
61
+ registry.delete(index);
62
+ }
63
+ }
64
+ };
65
+
66
+ export const resolveItemDimensions = <ItemT>({
67
+ registry,
68
+ data,
69
+ index,
70
+ getItemDimensions,
71
+ getItemKey,
72
+ }: {
73
+ registry: ItemDimensionsRegistry<ItemT>;
74
+ data: readonly ItemT[];
75
+ index: number;
76
+ getItemDimensions?: GestureViewerItemDimensionsResolver<ItemT>;
77
+ getItemKey?: GestureViewerItemKeyResolver<ItemT>;
78
+ }): GestureViewerItemDimensions | undefined => {
79
+ if (index < 0 || index >= data.length) {
80
+ return undefined;
81
+ }
82
+
83
+ // `as ItemT` restores the viewer's dense-list invariant, which TypeScript cannot infer from
84
+ // generic indexed access when `noUncheckedIndexedAccess` is enabled.
85
+ const item = data[index] as ItemT;
86
+ const registered = registry.get(index);
87
+ const itemKey = registered ? resolveItemKey(getItemKey, item, index) : undefined;
88
+
89
+ if (
90
+ registered &&
91
+ doesEntryMatchItem(registered, item, itemKey) &&
92
+ isValidItemDimensions(registered.dimensions)
93
+ ) {
94
+ return registered.dimensions;
95
+ }
96
+
97
+ const resolved = getItemDimensions?.(item, index);
98
+
99
+ if (isValidItemDimensions(resolved)) {
100
+ return resolved;
101
+ }
102
+
103
+ return undefined;
104
+ };
105
+
106
+ export const registerItemDimensions = <ItemT>({
107
+ registry,
108
+ data,
109
+ index,
110
+ item,
111
+ dimensions,
112
+ getItemKey,
113
+ }: {
114
+ registry: ItemDimensionsRegistry<ItemT>;
115
+ data: readonly ItemT[];
116
+ index: number;
117
+ item: ItemT;
118
+ dimensions: GestureViewerItemDimensions;
119
+ getItemKey?: GestureViewerItemKeyResolver<ItemT>;
120
+ }): boolean => {
121
+ if (index < 0 || index >= data.length || !isValidItemDimensions(dimensions)) {
122
+ return false;
123
+ }
124
+
125
+ // `as ItemT` restores the viewer's dense-list invariant, which TypeScript cannot infer from
126
+ // generic indexed access when `noUncheckedIndexedAccess` is enabled.
127
+ const currentItem = data[index] as ItemT;
128
+ const currentItemKey = resolveItemKey(getItemKey, currentItem, index);
129
+ const reportedItemKey = Object.is(currentItem, item)
130
+ ? currentItemKey
131
+ : resolveItemKey(getItemKey, item, index);
132
+ const hasMatchingKey =
133
+ currentItemKey !== undefined &&
134
+ reportedItemKey !== undefined &&
135
+ currentItemKey === reportedItemKey;
136
+
137
+ if (!Object.is(currentItem, item) && !hasMatchingKey) {
138
+ return false;
139
+ }
140
+
141
+ const registered = registry.get(index);
142
+
143
+ if (
144
+ registered &&
145
+ doesEntryMatchItem(registered, currentItem, currentItemKey) &&
146
+ registered.dimensions.width === dimensions.width &&
147
+ registered.dimensions.height === dimensions.height
148
+ ) {
149
+ return false;
150
+ }
151
+
152
+ registry.set(index, {
153
+ item: currentItem,
154
+ itemKey: currentItemKey,
155
+ dimensions: {
156
+ height: dimensions.height,
157
+ width: dimensions.width,
158
+ },
159
+ });
160
+
161
+ return true;
162
+ };
package/src/types.ts CHANGED
@@ -82,12 +82,40 @@ 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
+
85
108
  export type GestureViewerRenderItemInfo = {
86
109
  /**
87
110
  * Whether the rendered item is currently active.
88
111
  * @remarks The current item remains active during a page transition. When the transition finishes on another item, that item becomes active.
89
112
  */
90
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;
91
119
  };
92
120
 
93
121
  export type GestureViewerDismissDirection = 'down' | 'up' | 'both';
@@ -122,6 +150,7 @@ export interface GestureViewerProps<ItemT, LC> {
122
150
  data: ItemT[];
123
151
  /**
124
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.
125
154
  * @defaultValue 0
126
155
  */
127
156
  initialIndex?: number;
@@ -149,6 +178,16 @@ export interface GestureViewerProps<ItemT, LC> {
149
178
  * - Prefer this callback over overlaying a pressable in `renderContainer` for fullscreen tap handling.
150
179
  */
151
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>;
152
191
  /**
153
192
  * A callback function that is called to render the container.
154
193
  * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer.