react-native-gesture-image-viewer 1.6.6 → 1.7.1

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.
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type React from 'react';
2
2
  import type { FlatList as RNFlatList, ScrollView as RNScrollView, StyleProp, ViewStyle } from 'react-native';
3
3
  import type { FlatList as GHFlatList, ScrollView as GHScrollView } from 'react-native-gesture-handler';
4
+ import type { WithTimingConfig } from 'react-native-reanimated';
4
5
 
5
6
  export type FlatListComponent = typeof RNFlatList | typeof GHFlatList;
6
7
  export type ScrollViewComponent = typeof RNScrollView | typeof GHScrollView;
@@ -13,10 +14,34 @@ type ConditionalListProps<LC> = LC extends FlatListComponent
13
14
  ? React.ComponentProps<LC>
14
15
  : GetComponentProps<LC>;
15
16
 
17
+ export type TriggerRect = {
18
+ x: number;
19
+ y: number;
20
+ width: number;
21
+ height: number;
22
+ };
23
+
24
+ export interface TriggerAnimationConfig extends WithTimingConfig {
25
+ /**
26
+ * Animation duration in milliseconds
27
+ * @defaultValue 300
28
+ */
29
+ duration?: WithTimingConfig['duration'];
30
+ /**
31
+ * Animation easing function
32
+ * @defaultValue Easing.bezier(0.25, 0.1, 0.25, 1.0)
33
+ */
34
+ easing?: WithTimingConfig['easing'];
35
+ /**
36
+ * Callback function called after animation completion
37
+ */
38
+ onAnimationComplete?: () => void;
39
+ }
40
+
16
41
  export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
17
42
  /**
18
43
  * When you want to efficiently manage multiple `GestureViewer` instances, you can use the `id` prop to use multiple `GestureViewer` components.
19
- * @remark `GestureViewer` automatically removes instances from memory when components are unmounted, so no manual memory management is required.
44
+ * @remarks `GestureViewer` automatically removes instances from memory when components are unmounted, so no manual memory management is required.
20
45
  * @defaultValue 'default'
21
46
  */
22
47
  id?: string;
@@ -37,28 +62,39 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
37
62
  * A callback function that is called when the `GestureViewer` is dismissed.
38
63
  */
39
64
  onDismiss?: () => void;
65
+ /**
66
+ * A callback function that is called when the dismiss interaction starts.
67
+ * @remarks Useful to hide external UI (e.g., headers, buttons) while the dismiss gesture/animation is in progress.
68
+ */
69
+ onDismissStart?: () => void;
40
70
  /**
41
71
  * A callback function that is called to render the item.
42
72
  */
43
73
  renderItem: (item: T, index: number) => React.ReactElement;
44
74
  /**
45
75
  * A callback function that is called to render the container.
76
+ * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer.
77
+ * The second argument provides control helpers such as `dismiss()` to close the viewer.
78
+ *
79
+ * @param children - The viewer content to be rendered inside your container.
80
+ * @param helpers - Control helpers for the viewer. Currently includes `dismiss()`.
81
+ * @returns A React element that wraps and renders the provided `children`.
46
82
  */
47
- renderContainer?: (children: React.ReactElement) => React.ReactElement;
83
+ renderContainer?: (children: React.ReactElement, helpers: { dismiss: () => void }) => React.ReactElement;
48
84
  /**
49
85
  * Support for any list component like `ScrollView`, `FlatList`, `FlashList` through the `ListComponent` prop.
50
86
  */
51
87
  ListComponent: LC;
52
88
  /**
53
89
  * The width of the `GestureViewer`.
54
- * @remark If you don't set this prop, the width of the `GestureViewer` will be the same as the width of the screen.
90
+ * @remarks If you don't set this prop, the width of the `GestureViewer` will be the same as the width of the screen.
55
91
  * @defaultValue screen width
56
92
  */
57
93
  width?: number;
58
94
  /**
59
95
  * Enables snap scrolling mode.
60
96
  *
61
- * @remark
97
+ * @remarks
62
98
  * **`false` (default)**: Paging mode (`pagingEnabled: true`)
63
99
  * - Scrolls by full screen size increments
64
100
  *
@@ -74,17 +110,15 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
74
110
  * @defaultValue 80
75
111
  */
76
112
  dismissThreshold?: number;
77
- // swipeThreshold?: number;
78
- // velocityThreshold?: number;
79
113
  /**
80
114
  * Calls `onDismiss` function when swiping down.
81
- * @remark Useful for closing modals with downward swipe gestures.
115
+ * @remarks Useful for closing modals with downward swipe gestures.
82
116
  * @defaultValue true
83
117
  */
84
118
  enableDismissGesture?: boolean;
85
119
  /**
86
120
  * Controls left/right swipe gestures.
87
- * @remark When `false`, horizontal gestures are disabled.
121
+ * @remarks When `false`, horizontal gestures are disabled.
88
122
  * @defaultValue true
89
123
  */
90
124
  enableSwipeGesture?: boolean;
@@ -95,7 +129,7 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
95
129
  resistance?: number;
96
130
  /**
97
131
  * The props to pass to the list component.
98
- * @remark The `listProps` provides **type inference based on the selected list component**, ensuring accurate autocompletion and type safety in your IDE.
132
+ * @remarks The `listProps` provides **type inference based on the selected list component**, ensuring accurate autocompletion and type safety in your IDE.
99
133
  */
100
134
  listProps?: Partial<ConditionalListProps<LC>>;
101
135
  /**
@@ -108,25 +142,25 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
108
142
  containerStyle?: StyleProp<ViewStyle>;
109
143
  /**
110
144
  * By default, the background `opacity` gradually decreases from 1 to 0 during downward swipe gestures.
111
- * @remark When `false`, this animation is disabled.
145
+ * @remarks When `false`, this animation is disabled.
112
146
  * @defaultValue true
113
147
  */
114
148
  animateBackdrop?: boolean;
115
149
  /**
116
150
  * Only works when zoom is active, allows moving item position when zoomed.
117
- * @remark When `false`, gesture movement is disabled during zoom.
151
+ * @remarks When `false`, gesture movement is disabled during zoom.
118
152
  * @defaultValue true
119
153
  */
120
154
  enableZoomPanGesture?: boolean;
121
155
  /**
122
156
  * Controls two-finger pinch gestures.
123
- * @remark When `false`, two-finger zoom gestures are disabled.
157
+ * @remarks When `false`, two-finger zoom gestures are disabled.
124
158
  * @defaultValue true
125
159
  */
126
160
  enableZoomGesture?: boolean;
127
161
  /**
128
162
  * Controls double-tap zoom gestures.
129
- * @remark When `false`, double-tap zoom gestures are disabled.
163
+ * @remarks When `false`, double-tap zoom gestures are disabled.
130
164
  * @defaultValue true
131
165
  */
132
166
  enableDoubleTapGesture?: boolean;
@@ -142,10 +176,29 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
142
176
  maxZoomScale?: number;
143
177
  /**
144
178
  * The spacing between items in pixels.
145
- * @remark Only applied when `useSnap` is `true`.
179
+ * @remarks Only applied when `useSnap` is `true`.
146
180
  * @defaultValue 0
147
181
  */
148
182
  itemSpacing?: number;
183
+ /**
184
+ * Trigger-based animation settings
185
+ * @remarks You can customize animation duration, easing, and system reduce-motion behavior.
186
+ *
187
+ * @example
188
+ * ```tsx
189
+ * <GestureViewer
190
+ * triggerAnimation={{
191
+ * duration: 250,
192
+ * easing: Easing.out(Easing.cubic),
193
+ * reduceMotion: 'system',
194
+ * onAnimationComplete: () => {
195
+ * console.log('Animation complete');
196
+ * },
197
+ * }}
198
+ * />
199
+ * ```
200
+ */
201
+ triggerAnimation?: TriggerAnimationConfig;
149
202
  }
150
203
 
151
204
  /**
@@ -18,7 +18,7 @@ import {
18
18
  } from 'react-native-reanimated';
19
19
  import type GestureViewerManager from './GestureViewerManager';
20
20
  import { registry } from './GestureViewerRegistry';
21
- import type { GestureViewerProps } from './types';
21
+ import type { GestureViewerProps, TriggerRect } from './types';
22
22
  import { createBoundsConstraint, createScrollAction, getLoopAdjustedIndex } from './utils';
23
23
 
24
24
  type UseGestureViewerProps<T = any> = Omit<
@@ -34,8 +34,6 @@ export const useGestureViewer = <T = any>({
34
34
  width: customWidth,
35
35
  dismissThreshold = 80,
36
36
  resistance = 2,
37
- // swipeThreshold = 0.5,
38
- // velocityThreshold = 200,
39
37
  animateBackdrop = true,
40
38
  enableDismissGesture = true,
41
39
  enableSwipeGesture = true,
@@ -47,6 +45,8 @@ export const useGestureViewer = <T = any>({
47
45
  itemSpacing = 0,
48
46
  useSnap = false,
49
47
  id = 'default',
48
+ onDismissStart,
49
+ triggerAnimation,
50
50
  }: UseGestureViewerProps<T>) => {
51
51
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
52
52
  const width = useSnap ? customWidth || screenWidth : screenWidth;
@@ -54,11 +54,14 @@ export const useGestureViewer = <T = any>({
54
54
  const [isZoomed, setIsZoomed] = useState(false);
55
55
  const [isRotated, setIsRotated] = useState(false);
56
56
  const [manager, setManager] = useState<GestureViewerManager | null>(null);
57
+ const [shouldStartTriggerAnimation, setShouldStartTriggerAnimation] = useState(false);
57
58
 
58
59
  const listRef = useRef<any>(null);
59
60
  const unsubscribeRef = useRef<(() => void) | null>(null);
60
61
  const onIndexChangeRef = useRef<((index: number) => void) | null>(null);
61
62
  const lastEmittedIndexRef = useRef<number>(null);
63
+ const triggerRectRef = useRef<TriggerRect | null>(null);
64
+ const onAnimationCompleteRef = useRef(triggerAnimation?.onAnimationComplete);
62
65
 
63
66
  const initialTranslateY = useSharedValue(0);
64
67
  const initialTranslateX = useSharedValue(0);
@@ -70,8 +73,22 @@ export const useGestureViewer = <T = any>({
70
73
  const backdropOpacity = useSharedValue(1);
71
74
  const rotation = useSharedValue(0);
72
75
 
76
+ const triggerScale = useSharedValue(1);
77
+ const triggerTranslateX = useSharedValue(0);
78
+ const triggerTranslateY = useSharedValue(0);
79
+ const triggerOpacity = useSharedValue(1);
80
+
73
81
  const dataLength = data?.length || 0;
74
82
 
83
+ const animationConfig = useMemo(
84
+ () => ({
85
+ duration: triggerAnimation?.duration ?? 300,
86
+ easing: triggerAnimation?.easing ?? Easing.bezier(0.25, 0.1, 0.25, 1.0),
87
+ reduceMotion: triggerAnimation?.reduceMotion,
88
+ }),
89
+ [triggerAnimation?.duration, triggerAnimation?.easing, triggerAnimation?.reduceMotion],
90
+ );
91
+
75
92
  const adjustedInitialIndex = useMemo(() => {
76
93
  if (enableLoop && data.length > 1) {
77
94
  return initialIndex + 1;
@@ -85,6 +102,10 @@ export const useGestureViewer = <T = any>({
85
102
  [width, screenHeight],
86
103
  );
87
104
 
105
+ const onAnimationComplete = useCallback(() => {
106
+ onAnimationCompleteRef.current?.();
107
+ }, []);
108
+
88
109
  const scrollTo = useCallback(
89
110
  (index: number, animated: boolean) => {
90
111
  const scrollAction = createScrollAction(listRef.current, width + itemSpacing);
@@ -140,6 +161,68 @@ export const useGestureViewer = <T = any>({
140
161
  };
141
162
  }, []);
142
163
 
164
+ useEffect(() => {
165
+ onAnimationCompleteRef.current = triggerAnimation?.onAnimationComplete;
166
+ }, [triggerAnimation?.onAnimationComplete]);
167
+
168
+ useEffect(() => {
169
+ if (shouldStartTriggerAnimation && triggerRectRef.current) {
170
+ const startX = triggerRectRef.current.x + triggerRectRef.current.width / 2 - screenWidth / 2;
171
+ const startY = triggerRectRef.current.y + triggerRectRef.current.height / 2 - screenHeight / 2;
172
+ const initialScaleFromTrigger = Math.min(
173
+ triggerRectRef.current.width / screenWidth,
174
+ triggerRectRef.current.height / screenHeight,
175
+ );
176
+
177
+ triggerScale.value = initialScaleFromTrigger;
178
+ triggerTranslateX.value = startX;
179
+ triggerTranslateY.value = startY;
180
+ triggerOpacity.value = 0;
181
+
182
+ triggerScale.value = withTiming(1, animationConfig, (finished) => {
183
+ if (finished) {
184
+ runOnJS(onAnimationComplete)();
185
+ }
186
+ });
187
+ triggerTranslateX.value = withTiming(0, animationConfig);
188
+ triggerTranslateY.value = withTiming(0, animationConfig);
189
+ triggerOpacity.value = withTiming(1, {
190
+ duration: animationConfig.duration / 2,
191
+ easing: animationConfig.easing,
192
+ reduceMotion: animationConfig.reduceMotion,
193
+ });
194
+
195
+ setShouldStartTriggerAnimation(false);
196
+ }
197
+ }, [
198
+ shouldStartTriggerAnimation,
199
+ animationConfig,
200
+ screenWidth,
201
+ screenHeight,
202
+ triggerOpacity,
203
+ triggerScale,
204
+ triggerTranslateX,
205
+ triggerTranslateY,
206
+ onAnimationComplete,
207
+ ]);
208
+
209
+ useEffect(() => {
210
+ const node = registry.getTriggerNode(id);
211
+
212
+ if (node && typeof node.measure === 'function') {
213
+ node.measure((_x, _y, width, height, pageX, pageY) => {
214
+ triggerRectRef.current = { x: pageX, y: pageY, width, height };
215
+ triggerOpacity.value = 0;
216
+ setShouldStartTriggerAnimation(true);
217
+ registry.clearTriggerNode(id);
218
+ });
219
+ }
220
+
221
+ return () => {
222
+ triggerRectRef.current = null;
223
+ };
224
+ }, [id, triggerOpacity]);
225
+
143
226
  useEffect(() => {
144
227
  const handleManagerChange = (manager: GestureViewerManager | null) => {
145
228
  unsubscribeRef.current?.();
@@ -227,6 +310,43 @@ export const useGestureViewer = <T = any>({
227
310
  };
228
311
  }, [adjustedInitialIndex, translateY, backdropOpacity, translateX, scale, startScale, rotation, scrollTo]);
229
312
 
313
+ const handleDismiss = useCallback(() => {
314
+ onDismissStart?.();
315
+
316
+ if (triggerRectRef.current) {
317
+ const endX = triggerRectRef.current.x + triggerRectRef.current.width / 2 - screenWidth / 2;
318
+ const endY = triggerRectRef.current.y + triggerRectRef.current.height / 2 - screenHeight / 2;
319
+ const endScale = Math.min(
320
+ triggerRectRef.current.width / screenWidth,
321
+ triggerRectRef.current.height / screenHeight,
322
+ );
323
+
324
+ triggerScale.value = withTiming(endScale, animationConfig);
325
+ triggerTranslateX.value = withTiming(endX, animationConfig);
326
+ triggerTranslateY.value = withTiming(endY, animationConfig);
327
+ triggerOpacity.value = withTiming(0, animationConfig, (finished) => {
328
+ if (finished && onDismiss) {
329
+ runOnJS(onDismiss)();
330
+ }
331
+ });
332
+ return;
333
+ }
334
+
335
+ if (onDismiss) {
336
+ runOnJS(onDismiss)();
337
+ }
338
+ }, [
339
+ animationConfig,
340
+ onDismiss,
341
+ onDismissStart,
342
+ screenWidth,
343
+ screenHeight,
344
+ triggerTranslateX,
345
+ triggerScale,
346
+ triggerTranslateY,
347
+ triggerOpacity,
348
+ ]);
349
+
230
350
  const onMomentumScrollEnd = useCallback(
231
351
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
232
352
  if (!enableSwipeGesture) {
@@ -248,7 +368,7 @@ export const useGestureViewer = <T = any>({
248
368
  scrollTo(jumpToIndex, false);
249
369
  }
250
370
 
251
- const currentIndex = manager?.getState() ?? 0;
371
+ const currentIndex = manager?.getState().currentIndex;
252
372
 
253
373
  if (realIndex !== currentIndex && realIndex >= 0 && realIndex < dataLength) {
254
374
  if (manager) {
@@ -297,8 +417,8 @@ export const useGestureViewer = <T = any>({
297
417
  translateY.value = event.translationY / resistance;
298
418
  })
299
419
  .onEnd((event) => {
300
- if (canDismiss && event.translationY > dismissThreshold && onDismiss) {
301
- runOnJS(onDismiss)();
420
+ if (canDismiss && event.translationY > dismissThreshold && handleDismiss) {
421
+ runOnJS(handleDismiss)();
302
422
  return;
303
423
  }
304
424
 
@@ -307,7 +427,7 @@ export const useGestureViewer = <T = any>({
307
427
  stiffness: 150,
308
428
  });
309
429
  });
310
- }, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance, isZoomed]);
430
+ }, [translateY, dismissThreshold, enableDismissGesture, handleDismiss, resistance, isZoomed]);
311
431
 
312
432
  const zoomPinchGesture = useMemo(() => {
313
433
  return Gesture.Pinch()
@@ -478,7 +598,12 @@ export const useGestureViewer = <T = any>({
478
598
 
479
599
  const animatedStyle = useAnimatedStyle(() => {
480
600
  return {
601
+ opacity: triggerOpacity.value,
481
602
  transform: [
603
+ { translateX: triggerTranslateX.value },
604
+ { translateY: triggerTranslateY.value },
605
+ { scale: triggerScale.value },
606
+
482
607
  { translateY: translateY.value },
483
608
  { translateX: translateX.value },
484
609
  { scale: scale.value },
@@ -488,13 +613,15 @@ export const useGestureViewer = <T = any>({
488
613
  });
489
614
 
490
615
  const backdropStyle = useAnimatedStyle(() => {
616
+ const baseOpacity = triggerOpacity.value;
617
+
491
618
  if (!animateBackdrop || scale.value !== 1) {
492
- return { opacity: 1 };
619
+ return { opacity: baseOpacity };
493
620
  }
494
621
 
495
- const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
622
+ const dismissOpacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
496
623
 
497
- return { opacity };
624
+ return { opacity: dismissOpacity * baseOpacity };
498
625
  }, [animateBackdrop]);
499
626
 
500
627
  const onScrollBeginDrag = useCallback(() => {
@@ -503,7 +630,6 @@ export const useGestureViewer = <T = any>({
503
630
 
504
631
  return {
505
632
  dataLength,
506
- translateY,
507
633
  listRef,
508
634
  isZoomed,
509
635
  isRotated,
@@ -512,6 +638,7 @@ export const useGestureViewer = <T = any>({
512
638
 
513
639
  onMomentumScrollEnd,
514
640
  onScrollBeginDrag,
641
+ handleDismiss,
515
642
 
516
643
  animatedStyle,
517
644
  backdropStyle,