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
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect } from 'react';
1
+ import { useCallback, useEffect, useState } from 'react';
2
2
  import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
3
3
 
4
4
  import type {
@@ -8,6 +8,7 @@ import type {
8
8
  import { getLoopAdjustedIndex } from './utils';
9
9
 
10
10
  export function useGestureViewerPaging({
11
+ adjustedInitialIndex,
11
12
  autoPlay,
12
13
  autoPlayInterval,
13
14
  currentIndex,
@@ -23,6 +24,12 @@ export function useGestureViewerPaging({
23
24
  syncPendingIndex,
24
25
  width,
25
26
  }: UseGestureViewerPagingArgs): UseGestureViewerPagingResult {
27
+ const [activeListIndex, setActiveListIndex] = useState(adjustedInitialIndex);
28
+
29
+ useEffect(() => {
30
+ setActiveListIndex(adjustedInitialIndex);
31
+ }, [adjustedInitialIndex, dataLength]);
32
+
26
33
  useEffect(() => {
27
34
  if (
28
35
  !autoPlay ||
@@ -69,24 +76,30 @@ export function useGestureViewerPaging({
69
76
 
70
77
  const { contentOffset } = event.nativeEvent;
71
78
  const scrollIndex = Math.round(contentOffset.x / (width + itemSpacing));
79
+ const { realIndex, needsJump, jumpToIndex } = getLoopAdjustedIndex(
80
+ scrollIndex,
81
+ dataLength,
82
+ enableLoop,
83
+ );
72
84
 
73
85
  const isLoopHandled = manager?.handleMomentumScrollEnd(scrollIndex);
74
86
 
75
- if (isLoopHandled) {
87
+ if (realIndex < 0 || realIndex >= dataLength) {
76
88
  return;
77
89
  }
78
90
 
79
- const { realIndex, needsJump, jumpToIndex } = getLoopAdjustedIndex(
80
- scrollIndex,
81
- dataLength,
82
- enableLoop,
83
- );
91
+ if (isLoopHandled) {
92
+ syncCurrentIndex(realIndex);
93
+ setActiveListIndex(jumpToIndex ?? scrollIndex);
94
+ return;
95
+ }
84
96
 
85
97
  if (needsJump && jumpToIndex !== undefined) {
86
98
  scrollTo(jumpToIndex, false);
87
99
  }
88
100
 
89
101
  syncCurrentIndex(realIndex);
102
+ setActiveListIndex(jumpToIndex ?? scrollIndex);
90
103
  },
91
104
  [
92
105
  dataLength,
@@ -120,6 +133,7 @@ export function useGestureViewerPaging({
120
133
  }, [manager]);
121
134
 
122
135
  return {
136
+ activeListIndex,
123
137
  onMomentumScrollEnd,
124
138
  onScroll,
125
139
  onScrollBeginDrag,
@@ -8,6 +8,8 @@ export type UseGestureViewerPagingArgs = {
8
8
  adjustedInitialIndex: number;
9
9
  autoPlay: boolean;
10
10
  autoPlayInterval: number;
11
+ contentHeight: SharedValue<number>;
12
+ contentWidth: SharedValue<number>;
11
13
  currentIndex: number;
12
14
  dataLength: number;
13
15
  enableDoubleTapZoom: boolean;
@@ -34,6 +36,7 @@ export type WebClickTarget = {
34
36
  };
35
37
 
36
38
  export type UseGestureViewerPagingResult = {
39
+ activeListIndex: number;
37
40
  onMomentumScrollEnd?: ScrollViewProps['onMomentumScrollEnd'];
38
41
  onScroll?: ScrollViewProps['onScroll'];
39
42
  onScrollBeginDrag?: ScrollViewProps['onScrollBeginDrag'];
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, type MouseEvent } from 'react';
1
+ import { useCallback, useEffect, useRef, useState, type MouseEvent } from 'react';
2
2
  import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
3
3
 
4
4
  import type {
@@ -30,6 +30,8 @@ export function useGestureViewerPaging({
30
30
  adjustedInitialIndex,
31
31
  autoPlay,
32
32
  autoPlayInterval,
33
+ contentHeight,
34
+ contentWidth,
33
35
  currentIndex,
34
36
  dataLength,
35
37
  enableDoubleTapZoom,
@@ -50,18 +52,26 @@ export function useGestureViewerPaging({
50
52
  translateY,
51
53
  width,
52
54
  }: UseGestureViewerPagingArgs): UseGestureViewerPagingResult {
55
+ const [activeListIndex, setActiveListIndex] = useState(adjustedInitialIndex);
56
+ const pageStride = width + itemSpacing;
53
57
  const webSingleTapTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
54
58
 
55
59
  const webScrollRuntimeRef = useRef<WebScrollRuntime>({
56
60
  actor: 'idle',
57
61
  isAutoplayPausedByUser: false,
58
62
  lastSettledPhysicalIndex: adjustedInitialIndex,
59
- latestOffsetX: adjustedInitialIndex * (width + itemSpacing),
63
+ latestOffsetX: adjustedInitialIndex * pageStride,
60
64
  latestRawPhysicalIndex: adjustedInitialIndex,
61
65
  lastProgrammaticScrollVersion: 0,
62
66
  settleTimer: null,
63
67
  resumeAutoplayTimer: null,
64
68
  });
69
+ const previousPagingLayoutRef = useRef<{
70
+ adjustedInitialIndex: number;
71
+ dataLength: number;
72
+ manager: typeof manager;
73
+ pageStride: number;
74
+ } | null>(null);
65
75
 
66
76
  const clearWebSingleTapTimer = useCallback(() => {
67
77
  if (webSingleTapTimerRef.current) {
@@ -187,6 +197,7 @@ export function useGestureViewerPaging({
187
197
  runtime.latestOffsetX = settledPhysicalIndex * (width + itemSpacing);
188
198
  runtime.actor = 'idle';
189
199
  manager?.cancelPendingLoopTransition();
200
+ setActiveListIndex(settledPhysicalIndex);
190
201
  syncCurrentIndex(logicalIndex);
191
202
 
192
203
  if (settledByActor === 'user') {
@@ -223,12 +234,35 @@ export function useGestureViewerPaging({
223
234
 
224
235
  useEffect(() => {
225
236
  const runtime = webScrollRuntimeRef.current;
237
+ const previousLayout = previousPagingLayoutRef.current;
238
+ const shouldResetToInitialIndex =
239
+ previousLayout === null ||
240
+ previousLayout.adjustedInitialIndex !== adjustedInitialIndex ||
241
+ previousLayout.dataLength !== dataLength ||
242
+ previousLayout.manager !== manager;
243
+ const didPageStrideChange = previousLayout !== null && previousLayout.pageStride !== pageStride;
244
+
245
+ previousPagingLayoutRef.current = {
246
+ adjustedInitialIndex,
247
+ dataLength,
248
+ manager,
249
+ pageStride,
250
+ };
251
+
252
+ if (!shouldResetToInitialIndex && !didPageStrideChange) {
253
+ return;
254
+ }
226
255
 
227
256
  runtime.actor = 'idle';
228
257
  runtime.isAutoplayPausedByUser = false;
229
- runtime.lastSettledPhysicalIndex = adjustedInitialIndex;
230
- runtime.latestOffsetX = adjustedInitialIndex * (width + itemSpacing);
231
- runtime.latestRawPhysicalIndex = adjustedInitialIndex;
258
+
259
+ if (shouldResetToInitialIndex) {
260
+ runtime.lastSettledPhysicalIndex = adjustedInitialIndex;
261
+ setActiveListIndex(adjustedInitialIndex);
262
+ }
263
+
264
+ runtime.latestOffsetX = runtime.lastSettledPhysicalIndex * pageStride;
265
+ runtime.latestRawPhysicalIndex = runtime.lastSettledPhysicalIndex;
232
266
  runtime.lastProgrammaticScrollVersion = manager?.getProgrammaticScrollVersion() ?? 0;
233
267
  clearWebSettleTimer();
234
268
  clearWebAutoplayResumeTimer();
@@ -236,9 +270,9 @@ export function useGestureViewerPaging({
236
270
  adjustedInitialIndex,
237
271
  clearWebAutoplayResumeTimer,
238
272
  clearWebSettleTimer,
239
- itemSpacing,
273
+ dataLength,
240
274
  manager,
241
- width,
275
+ pageStride,
242
276
  ]);
243
277
 
244
278
  useEffect(() => {
@@ -387,6 +421,8 @@ export function useGestureViewerPaging({
387
421
  scheduleWebAutoplayResume();
388
422
 
389
423
  applyTapZoomAtPoint({
424
+ contentHeight: contentHeight.get(),
425
+ contentWidth: contentWidth.get(),
390
426
  x: resolvedX,
391
427
  y: resolvedY,
392
428
  width,
@@ -412,6 +448,8 @@ export function useGestureViewerPaging({
412
448
  },
413
449
  [
414
450
  clearWebSingleTapTimer,
451
+ contentHeight,
452
+ contentWidth,
415
453
  enableDoubleTapZoom,
416
454
  height,
417
455
  maxZoomScale,
@@ -426,6 +464,7 @@ export function useGestureViewerPaging({
426
464
  );
427
465
 
428
466
  return {
467
+ activeListIndex,
429
468
  onMomentumScrollEnd,
430
469
  onScroll,
431
470
  onScrollBeginDrag,
@@ -47,39 +47,92 @@ export const shouldUseNativeScrollGesture = (
47
47
  return platformOS === 'ios' && component !== GestureScrollView && component !== GestureFlatList;
48
48
  };
49
49
 
50
- export const createBoundsConstraint =
51
- ({ width, height }: { width: number; height: number }) =>
52
- ({
53
- scale,
54
- translateX,
55
- translateY,
56
- }: {
57
- translateX: number;
58
- translateY: number;
59
- scale: number;
60
- }) => {
61
- 'worklet';
62
-
63
- if (scale <= 1) {
64
- return {
65
- translateX,
66
- translateY,
67
- };
68
- }
50
+ export const clampIndex = (index: number | undefined, dataLength: number): number => {
51
+ if (dataLength <= 0) {
52
+ return 0;
53
+ }
54
+
55
+ const candidateIndex = index ?? 0;
56
+
57
+ if (!Number.isFinite(candidateIndex)) {
58
+ return 0;
59
+ }
60
+
61
+ return Math.min(Math.max(Math.trunc(candidateIndex), 0), dataLength - 1);
62
+ };
63
+
64
+ export const resolveGeometrySyncTranslationMode = (
65
+ previousIndex: number | null,
66
+ nextIndex: number,
67
+ scale: number,
68
+ ): 'constrain' | 'none' | 'reset' => {
69
+ if (previousIndex !== null && previousIndex !== nextIndex) {
70
+ return 'reset';
71
+ }
72
+
73
+ return scale > 1 ? 'constrain' : 'none';
74
+ };
75
+
76
+ const isValidDimension = (value: number): boolean => {
77
+ 'worklet';
78
+
79
+ return Number.isFinite(value) && value > 0;
80
+ };
69
81
 
70
- const maxTranslateX = (width * scale - width) / 2;
71
- const maxTranslateY = (height * scale - height) / 2;
82
+ const clampTranslation = (value: number, max: number): number => {
83
+ 'worklet';
72
84
 
85
+ if (max <= 0) {
86
+ return 0;
87
+ }
88
+
89
+ const clamped = Math.max(-max, Math.min(max, value));
90
+
91
+ return clamped === 0 ? 0 : clamped;
92
+ };
93
+
94
+ export const clampTranslationToBounds = ({
95
+ width,
96
+ height,
97
+ contentWidth,
98
+ contentHeight,
99
+ scale,
100
+ translateX,
101
+ translateY,
102
+ }: {
103
+ width: number;
104
+ height: number;
105
+ contentWidth?: number;
106
+ contentHeight?: number;
107
+ translateX: number;
108
+ translateY: number;
109
+ scale: number;
110
+ }) => {
111
+ 'worklet';
112
+
113
+ if (scale <= 1) {
73
114
  return {
74
- translateX: Math.max(-maxTranslateX, Math.min(maxTranslateX, translateX)),
75
- translateY: Math.max(-maxTranslateY, Math.min(maxTranslateY, translateY)),
115
+ translateX,
116
+ translateY,
76
117
  };
77
- };
118
+ }
78
119
 
79
- export const createLoopData = <T>(dataRef: React.RefObject<T[]>, enableLoop: boolean): T[] => {
80
- const data = dataRef.current;
120
+ const baseContentWidth =
121
+ contentWidth !== undefined && isValidDimension(contentWidth) ? contentWidth : width;
122
+ const baseContentHeight =
123
+ contentHeight !== undefined && isValidDimension(contentHeight) ? contentHeight : height;
81
124
 
82
- if (!enableLoop || !data?.length || data.length <= 1) {
125
+ const maxTranslateX = Math.max(0, (baseContentWidth * scale - width) / 2);
126
+ const maxTranslateY = Math.max(0, (baseContentHeight * scale - height) / 2);
127
+
128
+ return {
129
+ translateX: clampTranslation(translateX, maxTranslateX),
130
+ translateY: clampTranslation(translateY, maxTranslateY),
131
+ };
132
+ };
133
+
134
+ export const createLoopData = <T>(data: T[], enableLoop: boolean): T[] => {
135
+ if (!enableLoop || data.length <= 1) {
83
136
  return data;
84
137
  }
85
138
 
@@ -93,6 +146,12 @@ export const createLoopData = <T>(dataRef: React.RefObject<T[]>, enableLoop: boo
93
146
  return [lastItem, ...data, firstItem];
94
147
  };
95
148
 
149
+ export const getLoopPhysicalIndex = (
150
+ logicalIndex: number,
151
+ dataLength: number,
152
+ enableLoop: boolean,
153
+ ): number => (enableLoop && dataLength > 1 ? logicalIndex + 1 : logicalIndex);
154
+
96
155
  export const getLoopAdjustedIndex = (
97
156
  scrollIndex: number,
98
157
  originalDataLength: number,
@@ -1,11 +1,62 @@
1
1
  import type { SharedValue } from 'react-native-reanimated';
2
2
  import { Easing, withTiming } from 'react-native-reanimated';
3
3
 
4
+ import { clampTranslationToBounds } from '.';
5
+
6
+ export const getTapZoomTarget = ({
7
+ x,
8
+ y,
9
+ width,
10
+ height,
11
+ contentWidth,
12
+ contentHeight,
13
+ maxZoomScale,
14
+ scale,
15
+ }: {
16
+ x: number;
17
+ y: number;
18
+ width: number;
19
+ height: number;
20
+ contentWidth?: number;
21
+ contentHeight?: number;
22
+ maxZoomScale: number;
23
+ scale: number;
24
+ }) => {
25
+ 'worklet';
26
+
27
+ const nextScale = scale > 1 ? 1 : maxZoomScale;
28
+
29
+ if (nextScale <= 1) {
30
+ return {
31
+ scale: nextScale,
32
+ translateX: 0,
33
+ translateY: 0,
34
+ };
35
+ }
36
+
37
+ const centerX = x - width / 2;
38
+ const centerY = y - height / 2;
39
+ return {
40
+ scale: nextScale,
41
+ ...clampTranslationToBounds({
42
+ contentHeight,
43
+ contentWidth,
44
+ height,
45
+ scale: nextScale,
46
+ translateX: -centerX * (nextScale - 1),
47
+ translateY: -centerY * (nextScale - 1),
48
+ width,
49
+ }),
50
+ };
51
+ };
52
+
4
53
  export const applyTapZoomAtPoint = ({
5
54
  x,
6
55
  y,
7
56
  width,
8
57
  height,
58
+ contentWidth,
59
+ contentHeight,
9
60
  maxZoomScale,
10
61
  scale,
11
62
  translateX,
@@ -15,6 +66,8 @@ export const applyTapZoomAtPoint = ({
15
66
  y: number;
16
67
  width: number;
17
68
  height: number;
69
+ contentWidth?: number;
70
+ contentHeight?: number;
18
71
  maxZoomScale: number;
19
72
  scale: SharedValue<number>;
20
73
  translateX: SharedValue<number>;
@@ -22,23 +75,23 @@ export const applyTapZoomAtPoint = ({
22
75
  }) => {
23
76
  'worklet';
24
77
 
25
- const nextScale = scale.get() > 1 ? 1 : maxZoomScale;
78
+ const target = getTapZoomTarget({
79
+ contentHeight,
80
+ contentWidth,
81
+ height,
82
+ maxZoomScale,
83
+ scale: scale.get(),
84
+ width,
85
+ x,
86
+ y,
87
+ });
26
88
  const timingConfig = {
27
89
  duration: 300,
28
90
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
29
91
  };
30
92
 
31
- if (nextScale > 1) {
32
- const centerX = x - width / 2;
33
- const centerY = y - height / 2;
34
-
35
- // NOTE 확대로 밀려난 거리만큼 반대로 이동해서 탭 지점을 제자리에 유지
36
- translateX.set(withTiming(-centerX * (nextScale - 1), timingConfig));
37
- translateY.set(withTiming(-centerY * (nextScale - 1), timingConfig));
38
- } else {
39
- translateX.set(withTiming(0, timingConfig));
40
- translateY.set(withTiming(0, timingConfig));
41
- }
93
+ translateX.set(withTiming(target.translateX, timingConfig));
94
+ translateY.set(withTiming(target.translateY, timingConfig));
42
95
 
43
- scale.set(withTiming(nextScale, timingConfig));
96
+ scale.set(withTiming(target.scale, timingConfig));
44
97
  };