react-native-gesture-image-viewer 3.0.0-beta.2 → 3.0.0-beta.4

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/lib/module/GestureViewer.js +8 -1
  2. package/lib/module/GestureViewer.js.map +1 -1
  3. package/lib/module/GestureViewerManager.js +32 -10
  4. package/lib/module/GestureViewerManager.js.map +1 -1
  5. package/lib/module/index.js.map +1 -1
  6. package/lib/module/itemDimensions.js +86 -0
  7. package/lib/module/itemDimensions.js.map +1 -0
  8. package/lib/module/useGestureViewer.js +153 -18
  9. package/lib/module/useGestureViewer.js.map +1 -1
  10. package/lib/module/useGestureViewerManagerBridge.js +11 -2
  11. package/lib/module/useGestureViewerManagerBridge.js.map +1 -1
  12. package/lib/module/useWebClickHandler.js.map +1 -1
  13. package/lib/module/useWebClickHandler.web.js +9 -1
  14. package/lib/module/useWebClickHandler.web.js.map +1 -1
  15. package/lib/module/utils/index.js +27 -7
  16. package/lib/module/utils/index.js.map +1 -1
  17. package/lib/module/utils/tapZoom.js +47 -14
  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 +12 -1
  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 +28 -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/useGestureViewerManagerBridge.d.ts +3 -1
  31. package/lib/typescript/src/useGestureViewerManagerBridge.d.ts.map +1 -1
  32. package/lib/typescript/src/useWebClickHandler.d.ts +2 -0
  33. package/lib/typescript/src/useWebClickHandler.d.ts.map +1 -1
  34. package/lib/typescript/src/useWebClickHandler.web.d.ts +4 -2
  35. package/lib/typescript/src/useWebClickHandler.web.d.ts.map +1 -1
  36. package/lib/typescript/src/utils/index.d.ts +4 -2
  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 +16 -2
  42. package/src/GestureViewerManager.ts +47 -15
  43. package/src/index.tsx +4 -0
  44. package/src/itemDimensions.ts +162 -0
  45. package/src/types.ts +38 -0
  46. package/src/useGestureViewer.ts +208 -24
  47. package/src/useGestureViewerManagerBridge.ts +24 -2
  48. package/src/useWebClickHandler.ts +2 -0
  49. package/src/useWebClickHandler.web.ts +12 -0
  50. package/src/utils/index.ts +55 -22
  51. package/src/utils/tapZoom.ts +55 -15
@@ -22,6 +22,12 @@ import type GestureViewerManager from './GestureViewerManager';
22
22
  import { normalizeHorizontalSwipeThreshold } from './gestureViewerPaging';
23
23
  import { registry } from './GestureViewerRegistry';
24
24
  import { resolveGestureViewerRenderWindow } from './gestureViewerRenderWindow';
25
+ import {
26
+ type ItemDimensionsRegistry,
27
+ pruneItemDimensionsRegistry,
28
+ registerItemDimensions,
29
+ resolveItemDimensions,
30
+ } from './itemDimensions';
25
31
  import { enableNativeTapGestures } from './platformTapGestures';
26
32
  import {
27
33
  type NavigationOptions,
@@ -33,12 +39,12 @@ import {
33
39
  resolveNavigation,
34
40
  shouldRunNavigationDuringTransition,
35
41
  } from './renderWindow';
36
- import type { GestureViewerProps, TriggerRect } from './types';
42
+ import type { GestureViewerItemDimensions, GestureViewerProps, TriggerRect } from './types';
37
43
  import { useGestureViewerManagerBridge } from './useGestureViewerManagerBridge';
38
44
  import { useGestureViewerPaging } from './useGestureViewerPaging';
39
45
  import { type EmitSingleTap, useWebClickHandler } from './useWebClickHandler';
40
46
  import { useWebSingleTapTimer } from './useWebSingleTapTimer';
41
- import { createBoundsConstraint } from './utils';
47
+ import { clampTranslationToBounds, resolveGeometrySyncTranslationMode } from './utils';
42
48
  import { getDismissDistance, shouldDismissByDirection } from './utils/dismiss';
43
49
  import { applyTapZoomAtPoint } from './utils/tapZoom';
44
50
  import { calculateFocalPointTranslation, shouldAcceptFocalPoint } from './utils/zoom';
@@ -54,6 +60,23 @@ type UseGestureViewerProps<ItemT> = Omit<
54
60
  'renderItem' | 'renderContainer' | 'containerStyle' | 'backdropStyle'
55
61
  >;
56
62
 
63
+ type ContentViewport = Readonly<{
64
+ height: number;
65
+ width: number;
66
+ }>;
67
+
68
+ function fitItemDimensions(
69
+ dimensions: GestureViewerItemDimensions | undefined,
70
+ viewport: ContentViewport,
71
+ ): GestureViewerItemDimensions {
72
+ if (!dimensions) {
73
+ return viewport;
74
+ }
75
+
76
+ const fit = Math.min(viewport.width / dimensions.width, viewport.height / dimensions.height);
77
+ return { height: dimensions.height * fit, width: dimensions.width * fit };
78
+ }
79
+
57
80
  export const useGestureViewer = <ItemT>({
58
81
  data,
59
82
  initialIndex = 0,
@@ -75,6 +98,8 @@ export const useGestureViewer = <ItemT>({
75
98
  triggerAnimation,
76
99
  autoPlay = false,
77
100
  autoPlayInterval = 3000,
101
+ getItemDimensions,
102
+ getItemKey,
78
103
  }: UseGestureViewerProps<ItemT>) => {
79
104
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
80
105
  const width = customWidth || screenWidth;
@@ -117,12 +142,23 @@ export const useGestureViewer = <ItemT>({
117
142
  const onAnimationCompleteRef = useRef(triggerAnimation?.onAnimationComplete);
118
143
  const onSingleTapRef = useRef(onSingleTap);
119
144
  const dataRef = useRef(data);
145
+ const getItemDimensionsRef = useRef(getItemDimensions);
146
+ const getItemKeyRef = useRef(getItemKey);
120
147
  const dataLengthRef = useRef(dataLength);
121
148
  const enableLoopRef = useRef(enableLoop);
122
149
  const managerRef = useRef<GestureViewerManager | null>(null);
123
150
  const isZoomedRef = useRef(isZoomed);
124
151
  const isRotatedRef = useRef(isRotated);
125
152
  const isPinchingRef = useRef(isPinching);
153
+ const itemDimensionsRef = useRef<ItemDimensionsRegistry<ItemT>>(new Map());
154
+ const viewportRef = useRef({ height, width });
155
+ const activeGeometryRef = useRef<{
156
+ contentHeight: number;
157
+ contentWidth: number;
158
+ height: number;
159
+ width: number;
160
+ } | null>(null);
161
+ const activeGeometryIndexRef = useRef<number | null>(null);
126
162
 
127
163
  const initialTranslateY = useSharedValue(0);
128
164
  const initialTranslateX = useSharedValue(0);
@@ -132,6 +168,8 @@ export const useGestureViewer = <ItemT>({
132
168
  const translateX = useSharedValue(0);
133
169
  const scale = useSharedValue(1);
134
170
  const rotation = useSharedValue(0);
171
+ const contentWidth = useSharedValue(width);
172
+ const contentHeight = useSharedValue(height);
135
173
 
136
174
  const triggerScale = useSharedValue(1);
137
175
  const triggerTranslateX = useSharedValue(0);
@@ -189,9 +227,121 @@ export const useGestureViewer = <ItemT>({
189
227
  windowSize,
190
228
  });
191
229
 
192
- const constrainTranslation = useMemo(
193
- () => createBoundsConstraint({ height, width }),
194
- [width, height],
230
+ const syncActiveContentDimensions = useCallback(
231
+ (index = pendingIndexRef.current) => {
232
+ const viewport = viewportRef.current;
233
+ const fitted = fitItemDimensions(
234
+ resolveItemDimensions({
235
+ data: dataRef.current,
236
+ getItemKey: getItemKeyRef.current,
237
+ getItemDimensions: getItemDimensionsRef.current,
238
+ index,
239
+ registry: itemDimensionsRef.current,
240
+ }),
241
+ viewport,
242
+ );
243
+ const nextGeometry = {
244
+ contentHeight: fitted.height,
245
+ contentWidth: fitted.width,
246
+ height: viewport.height,
247
+ width: viewport.width,
248
+ };
249
+ const previousGeometry = activeGeometryRef.current;
250
+ const previousGeometryIndex = activeGeometryIndexRef.current;
251
+
252
+ activeGeometryIndexRef.current = index;
253
+
254
+ const currentScale = scale.get();
255
+ const translationMode = resolveGeometrySyncTranslationMode(
256
+ previousGeometryIndex,
257
+ index,
258
+ currentScale,
259
+ );
260
+
261
+ if (translationMode === 'reset') {
262
+ // Complete the page-owned reset before the next item's geometry can reuse the old offset.
263
+ translateX.set(0);
264
+ translateY.set(0);
265
+ }
266
+
267
+ if (
268
+ previousGeometry?.contentHeight === nextGeometry.contentHeight &&
269
+ previousGeometry.contentWidth === nextGeometry.contentWidth &&
270
+ previousGeometry.height === nextGeometry.height &&
271
+ previousGeometry.width === nextGeometry.width
272
+ ) {
273
+ return;
274
+ }
275
+
276
+ activeGeometryRef.current = nextGeometry;
277
+
278
+ if (contentWidth.get() !== fitted.width) {
279
+ contentWidth.set(fitted.width);
280
+ }
281
+ if (contentHeight.get() !== fitted.height) {
282
+ contentHeight.set(fitted.height);
283
+ }
284
+
285
+ if (translationMode !== 'constrain') {
286
+ return;
287
+ }
288
+
289
+ const constrained = clampTranslationToBounds({
290
+ contentHeight: fitted.height,
291
+ contentWidth: fitted.width,
292
+ height: viewport.height,
293
+ width: viewport.width,
294
+ scale: currentScale,
295
+ translateX: translateX.get(),
296
+ translateY: translateY.get(),
297
+ });
298
+ translateX.set(withTiming(constrained.translateX));
299
+ translateY.set(withTiming(constrained.translateY));
300
+ },
301
+ [contentHeight, contentWidth, scale, translateX, translateY],
302
+ );
303
+
304
+ const setItemDimensions = useCallback(
305
+ (index: number, item: ItemT, dimensions: GestureViewerItemDimensions) => {
306
+ if (
307
+ registerItemDimensions({
308
+ data: dataRef.current,
309
+ dimensions,
310
+ getItemKey: getItemKeyRef.current,
311
+ index,
312
+ item,
313
+ registry: itemDimensionsRef.current,
314
+ }) === 'updated' &&
315
+ index === pendingIndexRef.current
316
+ ) {
317
+ syncActiveContentDimensions(index);
318
+ }
319
+ },
320
+ [syncActiveContentDimensions],
321
+ );
322
+
323
+ const constrainTranslation = useCallback(
324
+ ({
325
+ scale: targetScale,
326
+ translateX: targetX,
327
+ translateY: targetY,
328
+ }: {
329
+ scale: number;
330
+ translateX: number;
331
+ translateY: number;
332
+ }) => {
333
+ 'worklet';
334
+ return clampTranslationToBounds({
335
+ contentHeight: contentHeight.get(),
336
+ contentWidth: contentWidth.get(),
337
+ height,
338
+ scale: targetScale,
339
+ translateX: targetX,
340
+ translateY: targetY,
341
+ width,
342
+ });
343
+ },
344
+ [contentHeight, contentWidth, height, width],
195
345
  );
196
346
 
197
347
  const resetTransformState = useCallback(() => {
@@ -221,12 +371,16 @@ export const useGestureViewer = <ItemT>({
221
371
  rotation.set(0);
222
372
  }, [initialTranslateX, initialTranslateY, rotation, scale, startScale, translateX, translateY]);
223
373
 
224
- const commitCurrentIndex = useCallback((nextIndex: number) => {
225
- pendingIndexRef.current = nextIndex;
226
- currentIndexRef.current = nextIndex;
227
- setCurrentIndex(nextIndex);
228
- managerRef.current?.notifyStateChange();
229
- }, []);
374
+ const commitCurrentIndex = useCallback(
375
+ (nextIndex: number) => {
376
+ pendingIndexRef.current = nextIndex;
377
+ currentIndexRef.current = nextIndex;
378
+ syncActiveContentDimensions(nextIndex);
379
+ setCurrentIndex(nextIndex);
380
+ managerRef.current?.notifyStateChange();
381
+ },
382
+ [syncActiveContentDimensions],
383
+ );
230
384
 
231
385
  const commitVirtualIndexOnly = useCallback(
232
386
  (nextVirtualIndex: number) => {
@@ -360,6 +514,8 @@ export const useGestureViewer = <ItemT>({
360
514
  }, [navigateByDirection]);
361
515
 
362
516
  useGestureViewerManagerBridge({
517
+ contentHeight,
518
+ contentWidth,
363
519
  currentIndexRef,
364
520
  dataLengthRef,
365
521
  goToIndex: navigateToIndex,
@@ -433,9 +589,17 @@ export const useGestureViewer = <ItemT>({
433
589
  });
434
590
  }, [id]);
435
591
 
436
- useEffect(() => {
592
+ useLayoutEffect(() => {
437
593
  dataRef.current = data;
438
- }, [data]);
594
+ getItemDimensionsRef.current = getItemDimensions;
595
+ getItemKeyRef.current = getItemKey;
596
+ viewportRef.current = { height, width };
597
+ syncActiveContentDimensions();
598
+ }, [data, dataLength, getItemDimensions, getItemKey, height, syncActiveContentDimensions, width]);
599
+
600
+ useEffect(() => {
601
+ pruneItemDimensionsRegistry(itemDimensionsRef.current, dataLength);
602
+ }, [dataLength]);
439
603
 
440
604
  useEffect(() => {
441
605
  isZoomedRef.current = isZoomed;
@@ -910,6 +1074,8 @@ export const useGestureViewer = <ItemT>({
910
1074
  }
911
1075
 
912
1076
  applyTapZoomAtPoint({
1077
+ contentHeight: contentHeight.get(),
1078
+ contentWidth: contentWidth.get(),
913
1079
  x: event.x,
914
1080
  y: event.y,
915
1081
  width,
@@ -921,6 +1087,8 @@ export const useGestureViewer = <ItemT>({
921
1087
  });
922
1088
  }),
923
1089
  [
1090
+ contentHeight,
1091
+ contentWidth,
924
1092
  enableDoubleTapZoom,
925
1093
  height,
926
1094
  maxZoomScale,
@@ -1049,22 +1217,35 @@ export const useGestureViewer = <ItemT>({
1049
1217
  const currentScale = scale.get();
1050
1218
 
1051
1219
  if (currentScale > maxZoomScale) {
1052
- scale.set(
1053
- withTiming(maxZoomScale, {
1054
- duration: 300,
1055
- easing: Easing.bezier(0.25, 0.1, 0.25, 1),
1056
- }),
1057
- );
1058
-
1220
+ const focalX = hasActiveFocal.get() ? lastFocalX.get() : width / 2;
1221
+ const focalY = hasActiveFocal.get() ? lastFocalY.get() : height / 2;
1222
+ const { translateX: targetTranslateX, translateY: targetTranslateY } =
1223
+ calculateFocalPointTranslation({
1224
+ currentFocalX: focalX,
1225
+ currentFocalY: focalY,
1226
+ height,
1227
+ initialScale: currentScale,
1228
+ initialTranslateX: translateX.get(),
1229
+ initialTranslateY: translateY.get(),
1230
+ nextScale: maxZoomScale,
1231
+ startFocalX: focalX,
1232
+ startFocalY: focalY,
1233
+ width,
1234
+ });
1059
1235
  const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } =
1060
1236
  constrainTranslation({
1061
1237
  scale: maxZoomScale,
1062
- translateX: translateX.get(),
1063
- translateY: translateY.get(),
1238
+ translateX: maxZoomScale <= 1 ? 0 : targetTranslateX,
1239
+ translateY: maxZoomScale <= 1 ? 0 : targetTranslateY,
1064
1240
  });
1241
+ const settleConfig = {
1242
+ duration: 300,
1243
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1),
1244
+ };
1065
1245
 
1066
- translateX.set(withTiming(constrainedTranslateX));
1067
- translateY.set(withTiming(constrainedTranslateY));
1246
+ scale.set(withTiming(maxZoomScale, settleConfig));
1247
+ translateX.set(withTiming(constrainedTranslateX, settleConfig));
1248
+ translateY.set(withTiming(constrainedTranslateY, settleConfig));
1068
1249
 
1069
1250
  return;
1070
1251
  }
@@ -1199,6 +1380,8 @@ export const useGestureViewer = <ItemT>({
1199
1380
 
1200
1381
  const onWebClick = useWebClickHandler({
1201
1382
  clearPendingWebSingleTap,
1383
+ contentHeight,
1384
+ contentWidth,
1202
1385
  emitSingleTap,
1203
1386
  enableDoubleTapZoom,
1204
1387
  getCurrentTapTarget,
@@ -1255,6 +1438,7 @@ export const useGestureViewer = <ItemT>({
1255
1438
  pageStride,
1256
1439
  renderWindowSlots,
1257
1440
  visualPage,
1441
+ setItemDimensions,
1258
1442
  zoomGesture,
1259
1443
  zoomPinchGesture,
1260
1444
  };
@@ -7,6 +7,8 @@ import { registry } from './GestureViewerRegistry';
7
7
 
8
8
  type UseGestureViewerManagerBridgeOptions = {
9
9
  currentIndexRef: RefObject<number>;
10
+ contentHeight: SharedValue<number>;
11
+ contentWidth: SharedValue<number>;
10
12
  dataLengthRef: RefObject<number>;
11
13
  goToIndex: (index: number, options?: GestureViewerNavigationOptions) => void;
12
14
  goToNext: () => void;
@@ -29,6 +31,8 @@ type UseGestureViewerManagerBridgeOptions = {
29
31
  */
30
32
  export function useGestureViewerManagerBridge({
31
33
  currentIndexRef,
34
+ contentHeight,
35
+ contentWidth,
32
36
  dataLengthRef,
33
37
  goToIndex,
34
38
  goToNext,
@@ -61,9 +65,27 @@ export function useGestureViewerManagerBridge({
61
65
 
62
66
  manager.setWidth(width);
63
67
  manager.setHeight(height);
64
- manager.setZoomSharedValues(scale, translateX, translateY, maxZoomScale);
68
+ manager.setZoomSharedValues({
69
+ scale,
70
+ translateX,
71
+ translateY,
72
+ maxZoomScale,
73
+ contentWidth,
74
+ contentHeight,
75
+ });
65
76
  manager.setRotation(rotation);
66
- }, [height, manager, maxZoomScale, rotation, scale, translateX, translateY, width]);
77
+ }, [
78
+ contentHeight,
79
+ contentWidth,
80
+ height,
81
+ manager,
82
+ maxZoomScale,
83
+ rotation,
84
+ scale,
85
+ translateX,
86
+ translateY,
87
+ width,
88
+ ]);
67
89
 
68
90
  useEffect(() => {
69
91
  if (!manager) {
@@ -26,6 +26,8 @@ export type EmitSingleTap<ItemT> = (x: number, y: number, tapTarget?: WebTapTarg
26
26
 
27
27
  export type WebClickHandlerConfig<ItemT> = {
28
28
  clearPendingWebSingleTap: () => void;
29
+ contentHeight: SharedValue<number>;
30
+ contentWidth: SharedValue<number>;
29
31
  emitSingleTap: EmitSingleTap<ItemT>;
30
32
  enableDoubleTapZoom: boolean;
31
33
  getCurrentTapTarget: () => WebTapTarget<ItemT> | null;
@@ -27,6 +27,8 @@ export type EmitSingleTap<ItemT> = (x: number, y: number, tapTarget?: WebTapTarg
27
27
 
28
28
  export type WebClickHandlerConfig<ItemT> = {
29
29
  clearPendingWebSingleTap: () => void;
30
+ contentHeight: SharedValue<number>;
31
+ contentWidth: SharedValue<number>;
30
32
  emitSingleTap: EmitSingleTap<ItemT>;
31
33
  enableDoubleTapZoom: boolean;
32
34
  getCurrentTapTarget: () => WebTapTarget<ItemT> | null;
@@ -42,6 +44,8 @@ export type WebClickHandlerConfig<ItemT> = {
42
44
 
43
45
  export function createWebClickHandler<ItemT>({
44
46
  clearPendingWebSingleTap,
47
+ contentHeight,
48
+ contentWidth,
45
49
  emitSingleTap,
46
50
  enableDoubleTapZoom,
47
51
  getCurrentTapTarget,
@@ -78,6 +82,8 @@ export function createWebClickHandler<ItemT>({
78
82
  if (event.detail === 2) {
79
83
  clearPendingWebSingleTap();
80
84
  applyTapZoomAtPoint({
85
+ contentHeight: contentHeight.get(),
86
+ contentWidth: contentWidth.get(),
81
87
  x,
82
88
  y,
83
89
  width,
@@ -100,6 +106,8 @@ export function createWebClickHandler<ItemT>({
100
106
 
101
107
  export function useWebClickHandler<ItemT>({
102
108
  clearPendingWebSingleTap,
109
+ contentHeight,
110
+ contentWidth,
103
111
  emitSingleTap,
104
112
  enableDoubleTapZoom,
105
113
  getCurrentTapTarget,
@@ -116,6 +124,8 @@ export function useWebClickHandler<ItemT>({
116
124
  () =>
117
125
  createWebClickHandler({
118
126
  clearPendingWebSingleTap,
127
+ contentHeight,
128
+ contentWidth,
119
129
  emitSingleTap,
120
130
  enableDoubleTapZoom,
121
131
  getCurrentTapTarget,
@@ -130,6 +140,8 @@ export function useWebClickHandler<ItemT>({
130
140
  }),
131
141
  [
132
142
  clearPendingWebSingleTap,
143
+ contentHeight,
144
+ contentWidth,
133
145
  emitSingleTap,
134
146
  enableDoubleTapZoom,
135
147
  getCurrentTapTarget,
@@ -1,28 +1,61 @@
1
- export const createBoundsConstraint =
2
- ({ width, height }: { width: number; height: number }) =>
3
- ({
4
- scale,
5
- translateX,
6
- translateY,
7
- }: {
8
- translateX: number;
9
- translateY: number;
10
- scale: number;
11
- }) => {
12
- 'worklet';
1
+ const isValidDimension = (value: number): boolean => {
2
+ 'worklet';
3
+ return Number.isFinite(value) && value > 0;
4
+ };
13
5
 
14
- if (scale <= 1) {
15
- return {
16
- translateX,
17
- translateY,
18
- };
19
- }
6
+ export const resolveGeometrySyncTranslationMode = (
7
+ previousIndex: number | null,
8
+ nextIndex: number,
9
+ scale: number,
10
+ ): 'constrain' | 'none' | 'reset' => {
11
+ if (previousIndex !== null && previousIndex !== nextIndex) {
12
+ return 'reset';
13
+ }
20
14
 
21
- const maxTranslateX = (width * scale - width) / 2;
22
- const maxTranslateY = (height * scale - height) / 2;
15
+ return scale > 1 ? 'constrain' : 'none';
16
+ };
23
17
 
18
+ const clampTranslation = (value: number, max: number): number => {
19
+ 'worklet';
20
+ const result = Math.max(-max, Math.min(max, value));
21
+ return result === 0 ? 0 : result;
22
+ };
23
+
24
+ export const clampTranslationToBounds = ({
25
+ width,
26
+ height,
27
+ contentWidth,
28
+ contentHeight,
29
+ scale,
30
+ translateX,
31
+ translateY,
32
+ }: {
33
+ width: number;
34
+ height: number;
35
+ contentWidth?: number;
36
+ contentHeight?: number;
37
+ translateX: number;
38
+ translateY: number;
39
+ scale: number;
40
+ }) => {
41
+ 'worklet';
42
+
43
+ if (scale <= 1) {
24
44
  return {
25
- translateX: Math.max(-maxTranslateX, Math.min(maxTranslateX, translateX)),
26
- translateY: Math.max(-maxTranslateY, Math.min(maxTranslateY, translateY)),
45
+ translateX,
46
+ translateY,
27
47
  };
48
+ }
49
+
50
+ const baseWidth =
51
+ contentWidth !== undefined && isValidDimension(contentWidth) ? contentWidth : width;
52
+ const baseHeight =
53
+ contentHeight !== undefined && isValidDimension(contentHeight) ? contentHeight : height;
54
+ const maxTranslateX = Math.max(0, (baseWidth * scale - width) / 2);
55
+ const maxTranslateY = Math.max(0, (baseHeight * scale - height) / 2);
56
+
57
+ return {
58
+ translateX: clampTranslation(translateX, maxTranslateX),
59
+ translateY: clampTranslation(translateY, maxTranslateY),
28
60
  };
61
+ };
@@ -1,11 +1,51 @@
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
+ const nextScale = scale > 1 ? 1 : maxZoomScale;
27
+ if (nextScale <= 1) return { scale: nextScale, translateX: 0, translateY: 0 };
28
+ return {
29
+ scale: nextScale,
30
+ ...clampTranslationToBounds({
31
+ contentHeight,
32
+ contentWidth,
33
+ height,
34
+ scale: nextScale,
35
+ translateX: -(x - width / 2) * (nextScale - 1),
36
+ translateY: -(y - height / 2) * (nextScale - 1),
37
+ width,
38
+ }),
39
+ };
40
+ };
41
+
4
42
  export const applyTapZoomAtPoint = ({
5
43
  x,
6
44
  y,
7
45
  width,
8
46
  height,
47
+ contentWidth,
48
+ contentHeight,
9
49
  maxZoomScale,
10
50
  scale,
11
51
  translateX,
@@ -15,6 +55,8 @@ export const applyTapZoomAtPoint = ({
15
55
  y: number;
16
56
  width: number;
17
57
  height: number;
58
+ contentWidth?: number;
59
+ contentHeight?: number;
18
60
  maxZoomScale: number;
19
61
  scale: SharedValue<number>;
20
62
  translateX: SharedValue<number>;
@@ -22,24 +64,22 @@ export const applyTapZoomAtPoint = ({
22
64
  }) => {
23
65
  'worklet';
24
66
 
25
- const nextScale = scale.get() > 1 ? 1 : maxZoomScale;
67
+ const target = getTapZoomTarget({
68
+ contentHeight,
69
+ contentWidth,
70
+ height,
71
+ maxZoomScale,
72
+ scale: scale.get(),
73
+ width,
74
+ x,
75
+ y,
76
+ });
26
77
  const timingConfig = {
27
78
  duration: 300,
28
79
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
29
80
  };
30
81
 
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
- scale.set(withTiming(nextScale, timingConfig));
39
- return;
40
- }
41
-
42
- translateX.set(withTiming(0, timingConfig));
43
- translateY.set(withTiming(0, timingConfig));
44
- scale.set(withTiming(nextScale, timingConfig));
82
+ translateX.set(withTiming(target.translateX, timingConfig));
83
+ translateY.set(withTiming(target.translateY, timingConfig));
84
+ scale.set(withTiming(target.scale, timingConfig));
45
85
  };