react-native-gesture-image-viewer 0.1.0 → 0.3.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 (34) hide show
  1. package/lib/module/GestureImageViewer.js +25 -10
  2. package/lib/module/GestureImageViewer.js.map +1 -1
  3. package/lib/module/ImageViewerManager.js +78 -0
  4. package/lib/module/ImageViewerManager.js.map +1 -0
  5. package/lib/module/ImageViewerRegistry.js +26 -0
  6. package/lib/module/ImageViewerRegistry.js.map +1 -0
  7. package/lib/module/index.js +1 -0
  8. package/lib/module/index.js.map +1 -1
  9. package/lib/module/useGestureImageViewer.js +138 -59
  10. package/lib/module/useGestureImageViewer.js.map +1 -1
  11. package/lib/module/useImageViewerController.js +28 -0
  12. package/lib/module/useImageViewerController.js.map +1 -0
  13. package/lib/typescript/src/GestureImageViewer.d.ts +1 -1
  14. package/lib/typescript/src/GestureImageViewer.d.ts.map +1 -1
  15. package/lib/typescript/src/ImageViewerManager.d.ts +28 -0
  16. package/lib/typescript/src/ImageViewerManager.d.ts.map +1 -0
  17. package/lib/typescript/src/ImageViewerRegistry.d.ts +10 -0
  18. package/lib/typescript/src/ImageViewerRegistry.d.ts.map +1 -0
  19. package/lib/typescript/src/index.d.ts +1 -0
  20. package/lib/typescript/src/index.d.ts.map +1 -1
  21. package/lib/typescript/src/types.d.ts +5 -0
  22. package/lib/typescript/src/types.d.ts.map +1 -1
  23. package/lib/typescript/src/useGestureImageViewer.d.ts +16 -7
  24. package/lib/typescript/src/useGestureImageViewer.d.ts.map +1 -1
  25. package/lib/typescript/src/useImageViewerController.d.ts +8 -0
  26. package/lib/typescript/src/useImageViewerController.d.ts.map +1 -0
  27. package/package.json +1 -1
  28. package/src/GestureImageViewer.tsx +42 -14
  29. package/src/ImageViewerManager.ts +92 -0
  30. package/src/ImageViewerRegistry.ts +30 -0
  31. package/src/index.tsx +1 -0
  32. package/src/types.ts +5 -0
  33. package/src/useGestureImageViewer.ts +210 -62
  34. package/src/useImageViewerController.ts +34 -0
@@ -6,7 +6,18 @@ import {
6
6
  useWindowDimensions,
7
7
  } from 'react-native';
8
8
  import { Gesture } from 'react-native-gesture-handler';
9
- import { interpolate, runOnJS, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
9
+ import {
10
+ Easing,
11
+ interpolate,
12
+ runOnJS,
13
+ useAnimatedReaction,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ withSpring,
17
+ withTiming,
18
+ } from 'react-native-reanimated';
19
+ import type { ImageViewerManagerState } from './ImageViewerManager';
20
+ import { registry } from './ImageViewerRegistry';
10
21
  import type { GestureImageViewerProps } from './types';
11
22
 
12
23
  type UseGestureImageViewerProps<T = any> = Omit<
@@ -27,30 +38,92 @@ export const useGestureImageViewer = <T = any>({
27
38
  animateBackdrop = true,
28
39
  enableDismissGesture = true,
29
40
  enableSwipeGesture = true,
41
+ enableZoomGesture = true,
42
+ enableDoubleTapGesture = true,
43
+ enableZoomPanGesture = true,
44
+ maxZoomScale = 2,
45
+ id = 'default',
30
46
  }: UseGestureImageViewerProps<T>) => {
31
- const { width: screenWidth } = useWindowDimensions();
47
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
32
48
  const width = customWidth || screenWidth;
33
49
 
34
- const [currentIndex, setCurrentIndex] = useState(initialIndex);
50
+ const [isZoomed, setIsZoomed] = useState(false);
51
+
52
+ const [managerState, setManagerState] = useState<ImageViewerManagerState>({
53
+ currentIndex: initialIndex,
54
+ dataLength: data?.length || 0,
55
+ });
56
+
57
+ const initialTranslateY = useSharedValue(0);
58
+ const initialTranslateX = useSharedValue(0);
59
+ const startScale = useSharedValue(1);
35
60
 
36
61
  const translateY = useSharedValue(0);
62
+ const translateX = useSharedValue(0);
63
+ const scale = useSharedValue(1);
37
64
  const backdropOpacity = useSharedValue(1);
38
65
 
39
66
  const flatListRef = useRef<any>(null);
40
67
 
41
68
  const dataLength = data?.length || 0;
42
69
 
43
- // 초기 위치 설정
70
+ useAnimatedReaction(
71
+ () => scale.value,
72
+ (currentScale) => {
73
+ runOnJS(setIsZoomed)(currentScale > 1);
74
+ },
75
+ );
76
+
77
+ useEffect(() => {
78
+ const manager = registry.getManager(id);
79
+
80
+ if (!manager) {
81
+ return;
82
+ }
83
+
84
+ setManagerState(manager.getState());
85
+
86
+ return manager.subscribe(setManagerState);
87
+ }, [id]);
88
+
89
+ useEffect(() => {
90
+ const manager = registry.getManager(id);
91
+
92
+ if (!manager) {
93
+ return;
94
+ }
95
+
96
+ manager.setDataLength(dataLength);
97
+ manager.setEnableSwipeGesture(enableSwipeGesture);
98
+ manager.setCurrentIndex(initialIndex);
99
+ manager.notifyStateChange();
100
+ }, [dataLength, enableSwipeGesture, initialIndex, id]);
101
+
102
+ useEffect(() => {
103
+ const manager = registry.getManager(id);
104
+
105
+ if (!manager || !flatListRef.current) {
106
+ return;
107
+ }
108
+
109
+ manager.setFlatListRef(flatListRef.current);
110
+ }, [id]);
111
+
112
+ useEffect(() => {
113
+ onIndexChange?.(managerState.currentIndex);
114
+ }, [managerState.currentIndex, onIndexChange]);
115
+
44
116
  useEffect(() => {
45
- setCurrentIndex(initialIndex);
46
117
  translateY.value = 0;
118
+ translateX.value = 0;
119
+ scale.value = 1;
47
120
  backdropOpacity.value = 1;
121
+ startScale.value = 1;
48
122
 
49
123
  if (initialIndex <= 0 || !flatListRef.current) {
50
124
  return;
51
125
  }
52
126
 
53
- // FlatList 초기 위치 설정
54
127
  const runAfterInteractions = InteractionManager.runAfterInteractions(() => {
55
128
  flatListRef.current?.scrollToIndex({
56
129
  index: initialIndex,
@@ -61,14 +134,8 @@ export const useGestureImageViewer = <T = any>({
61
134
  return () => {
62
135
  runAfterInteractions?.cancel();
63
136
  };
64
- }, [initialIndex, translateY, backdropOpacity]);
65
-
66
- // 인덱스 변경 알림
67
- useEffect(() => {
68
- onIndexChange?.(currentIndex);
69
- }, [currentIndex, onIndexChange]);
137
+ }, [initialIndex, translateY, backdropOpacity, translateX, scale, startScale]);
70
138
 
71
- // FlatList 스크롤 핸들러
72
139
  const onMomentumScrollEnd = useCallback(
73
140
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
74
141
  if (!enableSwipeGesture) {
@@ -78,52 +145,49 @@ export const useGestureImageViewer = <T = any>({
78
145
  const contentOffset = event.nativeEvent.contentOffset;
79
146
  const newIndex = Math.round(contentOffset.x / width);
80
147
 
81
- if (newIndex !== currentIndex && newIndex >= 0 && newIndex < dataLength) {
82
- setCurrentIndex(newIndex);
83
- }
84
- },
85
- [currentIndex, dataLength, width, enableSwipeGesture],
86
- );
148
+ if (newIndex !== managerState.currentIndex && newIndex >= 0 && newIndex < dataLength) {
149
+ const manager = registry.getManager(id);
87
150
 
88
- const goToIndex = useCallback(
89
- (index: number) => {
90
- if (index < 0 || index >= dataLength || !enableSwipeGesture) {
91
- return;
92
- }
151
+ if (manager) {
152
+ manager.setCurrentIndex(newIndex);
153
+ }
93
154
 
94
- setCurrentIndex(index);
95
- flatListRef.current?.scrollToIndex({
96
- index,
97
- animated: true,
98
- });
155
+ translateX.value = withTiming(0);
156
+ translateY.value = withTiming(0);
157
+ initialTranslateX.value = withTiming(0);
158
+ initialTranslateY.value = withTiming(0);
159
+ startScale.value = withTiming(1);
160
+ scale.value = withTiming(1);
161
+ }
99
162
  },
100
- [dataLength, enableSwipeGesture],
163
+ [
164
+ id,
165
+ managerState.currentIndex,
166
+ dataLength,
167
+ width,
168
+ enableSwipeGesture,
169
+ translateX,
170
+ translateY,
171
+ scale,
172
+ initialTranslateX,
173
+ initialTranslateY,
174
+ startScale,
175
+ ],
101
176
  );
102
177
 
103
- const goToPrevious = useCallback(() => {
104
- if (currentIndex > 0) {
105
- goToIndex(currentIndex - 1);
106
- }
107
- }, [currentIndex, goToIndex]);
108
-
109
- const goToNext = useCallback(() => {
110
- if (currentIndex < dataLength - 1) {
111
- goToIndex(currentIndex + 1);
112
- }
113
- }, [currentIndex, dataLength, goToIndex]);
114
-
115
- const panGesture = useMemo(() => {
178
+ const dismissGesture = useMemo(() => {
116
179
  return Gesture.Pan()
117
180
  .minDistance(10)
181
+ .averageTouches(true)
118
182
  .activeOffsetY([-10, 10])
119
183
  .failOffsetX([-10, 10])
184
+ .enabled(!isZoomed)
120
185
  .onUpdate((event) => {
121
- 'worklet';
122
-
123
186
  translateY.value = event.translationY / resistance;
124
187
  })
125
188
  .onEnd((event) => {
126
189
  'worklet';
190
+
127
191
  if (event.translationY > dismissThreshold && enableDismissGesture && onDismiss) {
128
192
  runOnJS(onDismiss)();
129
193
  return;
@@ -134,44 +198,128 @@ export const useGestureImageViewer = <T = any>({
134
198
  stiffness: 150,
135
199
  });
136
200
  });
137
- }, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance]);
201
+ }, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance, isZoomed]);
202
+
203
+ const zoomPinchGesture = useMemo(() => {
204
+ return Gesture.Pinch()
205
+ .enabled(enableZoomGesture)
206
+ .onBegin(() => {
207
+ startScale.value = scale.value;
208
+ })
209
+ .onUpdate((event) => {
210
+ scale.value = startScale.value * event.scale;
211
+ })
212
+ .onEnd(() => {
213
+ if (scale.value > maxZoomScale) {
214
+ scale.value = withTiming(maxZoomScale, {
215
+ duration: 300,
216
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
217
+ });
218
+ return;
219
+ }
220
+
221
+ if (scale.value < 1) {
222
+ scale.value = withTiming(1, {
223
+ duration: 300,
224
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
225
+ });
226
+ translateX.value = withTiming(0);
227
+ translateY.value = withTiming(0);
228
+ }
229
+ });
230
+ }, [scale, enableZoomGesture, maxZoomScale, translateX, translateY, startScale]);
231
+
232
+ const zoomPanGesture = useMemo(() => {
233
+ return Gesture.Pan()
234
+ .enabled(enableZoomPanGesture && isZoomed)
235
+ .onBegin(() => {
236
+ initialTranslateX.value = translateX.value;
237
+ initialTranslateY.value = translateY.value;
238
+ })
239
+ .onUpdate((event) => {
240
+ 'worklet';
241
+
242
+ if (scale.value > 1) {
243
+ const maxTranslateX = (width * scale.value - width) / 2;
244
+ const maxTranslateY = (screenHeight * scale.value - screenHeight) / 2;
245
+
246
+ translateX.value = Math.max(
247
+ -maxTranslateX,
248
+ Math.min(maxTranslateX, initialTranslateX.value + event.translationX),
249
+ );
250
+ translateY.value = Math.max(
251
+ -maxTranslateY,
252
+ Math.min(maxTranslateY, initialTranslateY.value + event.translationY),
253
+ );
254
+ }
255
+ });
256
+ }, [
257
+ translateX,
258
+ translateY,
259
+ enableZoomPanGesture,
260
+ isZoomed,
261
+ scale,
262
+ initialTranslateX,
263
+ initialTranslateY,
264
+ width,
265
+ screenHeight,
266
+ ]);
267
+
268
+ const doubleTapGesture = useMemo(() => {
269
+ return Gesture.Tap()
270
+ .enabled(enableDoubleTapGesture)
271
+ .numberOfTaps(2)
272
+ .onEnd(() => {
273
+ const nextScale = scale.value > 1 ? 1 : maxZoomScale;
274
+
275
+ translateX.value = withTiming(0, {
276
+ duration: 300,
277
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
278
+ });
279
+ translateY.value = withTiming(0, {
280
+ duration: 300,
281
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
282
+ });
283
+
284
+ scale.value = withTiming(nextScale, {
285
+ duration: 300,
286
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
287
+ });
288
+ });
289
+ }, [scale, enableDoubleTapGesture, maxZoomScale, translateX, translateY]);
290
+
291
+ const zoomGesture = useMemo(() => {
292
+ return Gesture.Simultaneous(zoomPinchGesture, zoomPanGesture, doubleTapGesture);
293
+ }, [zoomPinchGesture, zoomPanGesture, doubleTapGesture]);
138
294
 
139
- // 애니메이션 스타일
140
295
  const animatedStyle = useAnimatedStyle(() => {
141
296
  return {
142
- transform: [{ translateY: translateY.value }],
297
+ transform: [{ translateY: translateY.value }, { translateX: translateX.value }, { scale: scale.value }],
143
298
  };
144
- }, [translateY]);
299
+ });
145
300
 
146
301
  const backdropStyle = useAnimatedStyle(() => {
147
- if (!animateBackdrop) {
302
+ if (!animateBackdrop || scale.value > 1) {
148
303
  return { opacity: 1 };
149
304
  }
150
305
 
151
306
  const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
152
307
 
153
308
  return { opacity };
154
- }, [animateBackdrop, translateY]);
309
+ }, [animateBackdrop]);
155
310
 
156
311
  return {
157
- // State
158
- currentIndex,
312
+ currentIndex: managerState.currentIndex,
159
313
  dataLength,
160
314
  translateY,
161
315
  flatListRef,
316
+ isZoomed,
162
317
 
163
- // Actions
164
- goToPrevious,
165
- goToNext,
166
- goToIndex,
167
-
168
- // Gesture
169
- panGesture,
318
+ dismissGesture,
319
+ zoomGesture,
170
320
 
171
- // FlatList handlers
172
321
  onMomentumScrollEnd,
173
322
 
174
- // Animated styles
175
323
  animatedStyle,
176
324
  backdropStyle,
177
325
  };
@@ -0,0 +1,34 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import type { ImageViewerManagerState } from './ImageViewerManager';
3
+ import { registry } from './ImageViewerRegistry';
4
+
5
+ export const useImageViewerController = (id = 'default') => {
6
+ const [state, setState] = useState<ImageViewerManagerState>({
7
+ currentIndex: 0,
8
+ dataLength: 0,
9
+ });
10
+
11
+ useEffect(() => {
12
+ const manager = registry.createManager(id);
13
+
14
+ if (!manager) {
15
+ return;
16
+ }
17
+
18
+ setState(manager.getState());
19
+
20
+ return manager.subscribe(setState);
21
+ }, [id]);
22
+
23
+ const manager = registry.getManager(id);
24
+
25
+ const noopFunction = useMemo(() => () => {}, []);
26
+
27
+ return {
28
+ goToIndex: manager?.goToIndex || noopFunction,
29
+ goToPrevious: manager?.goToPrevious || noopFunction,
30
+ goToNext: manager?.goToNext || noopFunction,
31
+ currentIndex: state.currentIndex,
32
+ totalCount: state.dataLength,
33
+ };
34
+ };