react-native-gesture-image-viewer 1.2.2 → 1.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.
@@ -0,0 +1,441 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ InteractionManager,
4
+ type NativeScrollEvent,
5
+ type NativeSyntheticEvent,
6
+ useWindowDimensions,
7
+ } from 'react-native';
8
+ import { Gesture } from 'react-native-gesture-handler';
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 GestureViewerManager from './GestureViewerManager';
20
+ import { registry } from './GestureViewerRegistry';
21
+ import type { GestureViewerProps } from './types';
22
+ import { createBoundsConstraint } from './utils';
23
+
24
+ type UseGestureViewerProps<T = any> = Omit<
25
+ GestureViewerProps<T>,
26
+ 'renderItem' | 'renderContainer' | 'ListComponent' | 'listProps' | 'containerStyle' | 'backdropStyle'
27
+ >;
28
+
29
+ export const useGestureViewer = <T = any>({
30
+ data,
31
+ initialIndex = 0,
32
+ onIndexChange,
33
+ onDismiss,
34
+ width: customWidth,
35
+ dismissThreshold = 80,
36
+ resistance = 2,
37
+ // swipeThreshold = 0.5,
38
+ // velocityThreshold = 200,
39
+ animateBackdrop = true,
40
+ enableDismissGesture = true,
41
+ enableSwipeGesture = true,
42
+ enableZoomGesture = true,
43
+ enableDoubleTapGesture = true,
44
+ enableZoomPanGesture = true,
45
+ maxZoomScale = 2,
46
+ itemSpacing = 0,
47
+ useSnap = false,
48
+ id = 'default',
49
+ }: UseGestureViewerProps<T>) => {
50
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
51
+ const width = useSnap ? customWidth || screenWidth : screenWidth;
52
+
53
+ const [isZoomed, setIsZoomed] = useState(false);
54
+
55
+ const [currentIndex, setCurrentIndex] = useState(initialIndex);
56
+ const [manager, setManager] = useState<GestureViewerManager | null>(null);
57
+
58
+ const unsubscribeRef = useRef<(() => void) | null>(null);
59
+
60
+ const initialTranslateY = useSharedValue(0);
61
+ const initialTranslateX = useSharedValue(0);
62
+ const startScale = useSharedValue(1);
63
+
64
+ const translateY = useSharedValue(0);
65
+ const translateX = useSharedValue(0);
66
+ const scale = useSharedValue(1);
67
+ const backdropOpacity = useSharedValue(1);
68
+
69
+ const listRef = useRef<any>(null);
70
+
71
+ const dataLength = data?.length || 0;
72
+
73
+ const constrainTranslation = useMemo(
74
+ () => createBoundsConstraint({ width, height: screenHeight }),
75
+ [width, screenHeight],
76
+ );
77
+
78
+ useAnimatedReaction(
79
+ () => scale.value,
80
+ (currentScale) => {
81
+ runOnJS(setIsZoomed)(currentScale > 1);
82
+ },
83
+ );
84
+
85
+ useEffect(() => {
86
+ const handleManagerChange = (manager: GestureViewerManager | null) => {
87
+ unsubscribeRef.current?.();
88
+ unsubscribeRef.current = null;
89
+
90
+ setManager(manager);
91
+
92
+ if (manager) {
93
+ setCurrentIndex(manager.getState().currentIndex);
94
+ unsubscribeRef.current = manager.subscribe((state) => {
95
+ setCurrentIndex(state.currentIndex);
96
+ });
97
+ return;
98
+ }
99
+
100
+ setCurrentIndex(0);
101
+ };
102
+
103
+ const unsubscribeFromRegistry = registry.subscribeToManager(id, handleManagerChange);
104
+
105
+ return () => {
106
+ unsubscribeFromRegistry();
107
+ unsubscribeRef.current?.();
108
+ };
109
+ }, [id]);
110
+
111
+ useEffect(() => {
112
+ if (!manager) {
113
+ return;
114
+ }
115
+
116
+ manager.setDataLength(dataLength);
117
+ manager.setEnableSwipeGesture(enableSwipeGesture);
118
+ manager.setCurrentIndex(initialIndex);
119
+ manager.setWidth(width + itemSpacing);
120
+ manager.setHeight(screenHeight);
121
+ manager.setZoomSharedValues(scale, translateX, translateY, maxZoomScale);
122
+ manager.notifyStateChange();
123
+ }, [
124
+ dataLength,
125
+ enableSwipeGesture,
126
+ initialIndex,
127
+ manager,
128
+ width,
129
+ itemSpacing,
130
+ maxZoomScale,
131
+ scale,
132
+ screenHeight,
133
+ translateX,
134
+ translateY,
135
+ ]);
136
+
137
+ useEffect(() => {
138
+ if (!manager || !listRef.current) {
139
+ return;
140
+ }
141
+
142
+ manager.setListRef(listRef.current);
143
+ }, [manager]);
144
+
145
+ useEffect(() => {
146
+ onIndexChange?.(currentIndex);
147
+ }, [currentIndex, onIndexChange]);
148
+
149
+ useEffect(() => {
150
+ translateY.value = 0;
151
+ translateX.value = 0;
152
+ scale.value = 1;
153
+ backdropOpacity.value = 1;
154
+ startScale.value = 1;
155
+
156
+ if (initialIndex <= 0 || !listRef.current) {
157
+ return;
158
+ }
159
+
160
+ const runAfterInteractions = InteractionManager.runAfterInteractions(() => {
161
+ if (listRef.current.scrollToIndex) {
162
+ listRef.current.scrollToIndex({
163
+ index: initialIndex,
164
+ animated: false,
165
+ });
166
+ } else if (listRef.current.scrollTo) {
167
+ listRef.current.scrollTo({
168
+ x: initialIndex * (width + itemSpacing),
169
+ animated: false,
170
+ });
171
+ }
172
+ });
173
+
174
+ return () => {
175
+ runAfterInteractions?.cancel();
176
+ };
177
+ }, [initialIndex, translateY, backdropOpacity, translateX, scale, startScale, width, itemSpacing]);
178
+
179
+ const onMomentumScrollEnd = useCallback(
180
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
181
+ if (!enableSwipeGesture) {
182
+ return;
183
+ }
184
+
185
+ const contentOffset = event.nativeEvent.contentOffset;
186
+ const newIndex = Math.round(contentOffset.x / (width + itemSpacing));
187
+
188
+ if (newIndex !== currentIndex && newIndex >= 0 && newIndex < dataLength) {
189
+ if (manager) {
190
+ manager.setCurrentIndex(newIndex);
191
+ setCurrentIndex(newIndex);
192
+ manager.notifyStateChange();
193
+ }
194
+
195
+ translateX.value = withTiming(0);
196
+ translateY.value = withTiming(0);
197
+ initialTranslateX.value = withTiming(0);
198
+ initialTranslateY.value = withTiming(0);
199
+ startScale.value = withTiming(1);
200
+ scale.value = withTiming(1);
201
+ }
202
+ },
203
+ [
204
+ manager,
205
+ currentIndex,
206
+ dataLength,
207
+ width,
208
+ itemSpacing,
209
+ enableSwipeGesture,
210
+ translateX,
211
+ translateY,
212
+ scale,
213
+ initialTranslateX,
214
+ initialTranslateY,
215
+ startScale,
216
+ ],
217
+ );
218
+
219
+ const dismissGesture = useMemo(() => {
220
+ return Gesture.Pan()
221
+ .minDistance(10)
222
+ .averageTouches(true)
223
+ .activeCursor('grabbing')
224
+ .activeOffsetY([-10, 10])
225
+ .failOffsetX([-10, 10])
226
+ .enabled(!isZoomed)
227
+ .onUpdate((event) => {
228
+ translateY.value = event.translationY / resistance;
229
+ })
230
+ .onEnd((event) => {
231
+ if (event.translationY > dismissThreshold && enableDismissGesture && onDismiss) {
232
+ runOnJS(onDismiss)();
233
+ return;
234
+ }
235
+
236
+ translateY.value = withSpring(0, {
237
+ damping: 15,
238
+ stiffness: 150,
239
+ });
240
+ });
241
+ }, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance, isZoomed]);
242
+
243
+ const zoomPinchGesture = useMemo(() => {
244
+ return Gesture.Pinch()
245
+ .enabled(enableZoomGesture)
246
+ .onBegin(() => {
247
+ startScale.value = scale.value;
248
+ initialTranslateX.value = translateX.value;
249
+ initialTranslateY.value = translateY.value;
250
+ })
251
+ .onUpdate((event) => {
252
+ const newScale = startScale.value * event.scale;
253
+
254
+ scale.value = newScale;
255
+
256
+ if (newScale <= 1) {
257
+ translateX.value = withTiming(0);
258
+ translateY.value = withTiming(0);
259
+ return;
260
+ }
261
+
262
+ const deltaScale = newScale - startScale.value;
263
+ const centerX = event.focalX - width / 2;
264
+ const centerY = event.focalY - screenHeight / 2;
265
+
266
+ // NOTE 새로운 이동값 = 기존 이동값 - (중심점 거리 × 스케일 변화량) / 원래 스케일 (중심점이 화면 중심에서 멀수록, 확대 배율이 클수록 더 많이 이동)
267
+ const newTranslateX = initialTranslateX.value - (centerX * deltaScale) / startScale.value;
268
+ const newTranslateY = initialTranslateY.value - (centerY * deltaScale) / startScale.value;
269
+
270
+ const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({
271
+ translateX: newTranslateX,
272
+ translateY: newTranslateY,
273
+ scale: newScale,
274
+ });
275
+
276
+ translateX.value = constrainedTranslateX;
277
+ translateY.value = constrainedTranslateY;
278
+ })
279
+ .onEnd(() => {
280
+ if (scale.value > maxZoomScale) {
281
+ scale.value = withTiming(maxZoomScale, {
282
+ duration: 300,
283
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
284
+ });
285
+
286
+ const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({
287
+ translateX: translateX.value,
288
+ translateY: translateY.value,
289
+ scale: maxZoomScale,
290
+ });
291
+
292
+ translateX.value = withTiming(constrainedTranslateX);
293
+ translateY.value = withTiming(constrainedTranslateY);
294
+
295
+ return;
296
+ }
297
+
298
+ if (scale.value < 1) {
299
+ scale.value = withTiming(1, {
300
+ duration: 300,
301
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
302
+ });
303
+ translateX.value = withTiming(0);
304
+ translateY.value = withTiming(0);
305
+ initialTranslateX.value = withTiming(0);
306
+ initialTranslateY.value = withTiming(0);
307
+ return;
308
+ }
309
+
310
+ const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({
311
+ translateX: translateX.value,
312
+ translateY: translateY.value,
313
+ scale: scale.value,
314
+ });
315
+
316
+ translateX.value = withTiming(constrainedTranslateX);
317
+ translateY.value = withTiming(constrainedTranslateY);
318
+ });
319
+ }, [
320
+ scale,
321
+ enableZoomGesture,
322
+ maxZoomScale,
323
+ translateX,
324
+ translateY,
325
+ startScale,
326
+ initialTranslateX,
327
+ initialTranslateY,
328
+ width,
329
+ screenHeight,
330
+ constrainTranslation,
331
+ ]);
332
+
333
+ const zoomPanGesture = useMemo(() => {
334
+ return Gesture.Pan()
335
+ .enabled(enableZoomPanGesture && isZoomed)
336
+ .activeCursor('grabbing')
337
+ .averageTouches(true)
338
+ .onBegin(() => {
339
+ initialTranslateX.value = translateX.value;
340
+ initialTranslateY.value = translateY.value;
341
+ })
342
+ .onUpdate((event) => {
343
+ if (scale.value > 1) {
344
+ const newTranslateX = initialTranslateX.value + event.translationX;
345
+ const newTranslateY = initialTranslateY.value + event.translationY;
346
+
347
+ const { translateX: constrainedTranslateX, translateY: constrainedTranslateY } = constrainTranslation({
348
+ translateX: newTranslateX,
349
+ translateY: newTranslateY,
350
+ scale: scale.value,
351
+ });
352
+
353
+ translateX.value = constrainedTranslateX;
354
+ translateY.value = constrainedTranslateY;
355
+ }
356
+ });
357
+ }, [
358
+ translateX,
359
+ translateY,
360
+ enableZoomPanGesture,
361
+ isZoomed,
362
+ scale,
363
+ initialTranslateX,
364
+ initialTranslateY,
365
+ constrainTranslation,
366
+ ]);
367
+
368
+ const doubleTapGesture = useMemo(() => {
369
+ return Gesture.Tap()
370
+ .enabled(enableDoubleTapGesture)
371
+ .numberOfTaps(2)
372
+ .onEnd((event) => {
373
+ const nextScale = scale.value > 1 ? 1 : maxZoomScale;
374
+
375
+ if (nextScale > 1) {
376
+ const centerX = event.x - width / 2;
377
+ const centerY = event.y - screenHeight / 2;
378
+
379
+ // NOTE 확대로 밀려난 거리만큼 반대로 이동해서 탭 지점을 제자리에 유지
380
+ translateX.value = withTiming(-centerX * (nextScale - 1), {
381
+ duration: 300,
382
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
383
+ });
384
+ translateY.value = withTiming(-centerY * (nextScale - 1), {
385
+ duration: 300,
386
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
387
+ });
388
+ } else {
389
+ translateX.value = withTiming(0, {
390
+ duration: 300,
391
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
392
+ });
393
+ translateY.value = withTiming(0, {
394
+ duration: 300,
395
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
396
+ });
397
+ }
398
+
399
+ scale.value = withTiming(nextScale, {
400
+ duration: 300,
401
+ easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
402
+ });
403
+ });
404
+ }, [scale, enableDoubleTapGesture, maxZoomScale, translateX, translateY, width, screenHeight]);
405
+
406
+ const zoomGesture = useMemo(() => {
407
+ return Gesture.Race(zoomPinchGesture, Gesture.Exclusive(zoomPanGesture, doubleTapGesture));
408
+ }, [zoomPinchGesture, zoomPanGesture, doubleTapGesture]);
409
+
410
+ const animatedStyle = useAnimatedStyle(() => {
411
+ return {
412
+ transform: [{ translateY: translateY.value }, { translateX: translateX.value }, { scale: scale.value }],
413
+ };
414
+ });
415
+
416
+ const backdropStyle = useAnimatedStyle(() => {
417
+ if (!animateBackdrop || scale.value !== 1) {
418
+ return { opacity: 1 };
419
+ }
420
+
421
+ const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
422
+
423
+ return { opacity };
424
+ }, [animateBackdrop]);
425
+
426
+ return {
427
+ currentIndex,
428
+ dataLength,
429
+ translateY,
430
+ listRef,
431
+ isZoomed,
432
+
433
+ dismissGesture,
434
+ zoomGesture,
435
+
436
+ onMomentumScrollEnd,
437
+
438
+ animatedStyle,
439
+ backdropStyle,
440
+ };
441
+ };
@@ -0,0 +1,51 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react';
2
+ import type GestureViewerManager from './GestureViewerManager';
3
+ import type { GestureViewerManagerState } from './GestureViewerManager';
4
+ import { registry } from './GestureViewerRegistry';
5
+
6
+ export const useGestureViewerController = (id = 'default') => {
7
+ const [state, setState] = useState<GestureViewerManagerState>({
8
+ currentIndex: 0,
9
+ dataLength: 0,
10
+ });
11
+
12
+ const [manager, setManager] = useState<GestureViewerManager | null>(null);
13
+ const unsubscribeRef = useRef<(() => void) | null>(null);
14
+
15
+ useEffect(() => {
16
+ const handleManagerChange = (newManager: GestureViewerManager | null) => {
17
+ unsubscribeRef.current?.();
18
+ unsubscribeRef.current = null;
19
+
20
+ setManager(newManager);
21
+
22
+ if (newManager) {
23
+ setState(newManager.getState());
24
+ unsubscribeRef.current = newManager.subscribe(setState);
25
+ return;
26
+ }
27
+
28
+ setState({ currentIndex: 0, dataLength: 0 });
29
+ };
30
+
31
+ const unsubscribeFromRegistry = registry.subscribeToManager(id, handleManagerChange);
32
+
33
+ return () => {
34
+ unsubscribeFromRegistry();
35
+ unsubscribeRef.current?.();
36
+ };
37
+ }, [id]);
38
+
39
+ const noopFunction = useMemo(() => () => {}, []);
40
+
41
+ return {
42
+ goToIndex: manager?.goToIndex || noopFunction,
43
+ goToPrevious: manager?.goToPrevious || noopFunction,
44
+ goToNext: manager?.goToNext || noopFunction,
45
+ zoomIn: manager?.zoomIn || noopFunction,
46
+ zoomOut: manager?.zoomOut || noopFunction,
47
+ resetZoom: manager?.resetZoom || noopFunction,
48
+ currentIndex: state.currentIndex,
49
+ totalCount: state.dataLength,
50
+ };
51
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { FlatList as RNFlatList, ScrollView as RNScrollView } from 'react-native';
2
+ import { FlatList as GestureFlatList, ScrollView as GestureScrollView } from 'react-native-gesture-handler';
3
+ import type { FlatListComponent, ScrollViewComponent } from './types';
4
+
5
+ export const isScrollViewLike = (component: any): component is ScrollViewComponent => {
6
+ return component === RNScrollView || component === GestureScrollView;
7
+ };
8
+
9
+ export const isFlatListLike = (component: any): component is FlatListComponent => {
10
+ if (component === RNFlatList || component === GestureFlatList || isFlashListLike(component)) {
11
+ return true;
12
+ }
13
+
14
+ return false;
15
+ };
16
+
17
+ export const isFlashListLike = (component: any): component is any => {
18
+ try {
19
+ const FlashList = require('@shopify/flash-list')?.FlashList;
20
+
21
+ if (FlashList && component === FlashList) {
22
+ return true;
23
+ }
24
+ } catch {
25
+ // do nothing
26
+ }
27
+
28
+ return component?.name === 'FlashList';
29
+ };
30
+
31
+ export const createBoundsConstraint =
32
+ ({ width, height }: { width: number; height: number }) =>
33
+ ({ scale, translateX, translateY }: { translateX: number; translateY: number; scale: number }) => {
34
+ 'worklet';
35
+
36
+ if (scale <= 1) {
37
+ return {
38
+ translateX,
39
+ translateY,
40
+ };
41
+ }
42
+
43
+ const maxTranslateX = (width * scale - width) / 2;
44
+ const maxTranslateY = (height * scale - height) / 2;
45
+
46
+ return {
47
+ translateX: Math.max(-maxTranslateX, Math.min(maxTranslateX, translateX)),
48
+ translateY: Math.max(-maxTranslateY, Math.min(maxTranslateY, translateY)),
49
+ };
50
+ };