react-native-gesture-image-viewer 2.0.0-beta.4 → 2.0.0-beta.6

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 RNScrollView> {
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;
@@ -33,27 +58,38 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
33
58
  * A callback function that is called when the `GestureViewer` is dismissed.
34
59
  */
35
60
  onDismiss?: () => void;
61
+ /**
62
+ * A callback function that is called when the dismiss interaction starts.
63
+ * @remarks Useful to hide external UI (e.g., headers, buttons) while the dismiss gesture/animation is in progress.
64
+ */
65
+ onDismissStart?: () => void;
36
66
  /**
37
67
  * A callback function that is called to render the item.
38
68
  */
39
69
  renderItem: (item: T, index: number) => React.ReactElement;
40
70
  /**
41
71
  * A callback function that is called to render the container.
72
+ * @remarks Useful for composing additional UI (e.g., close button, toolbars) around the viewer.
73
+ * The second argument provides control helpers such as `dismiss()` to close the viewer.
74
+ *
75
+ * @param children - The viewer content to be rendered inside your container.
76
+ * @param helpers - Control helpers for the viewer. Currently includes `dismiss()`.
77
+ * @returns A React element that wraps and renders the provided `children`.
42
78
  */
43
- renderContainer?: (children: React.ReactElement) => React.ReactElement;
79
+ renderContainer?: (children: React.ReactElement, helpers: { dismiss: () => void }) => React.ReactElement;
44
80
  /**
45
81
  * Support for any list component like `ScrollView`, `FlatList`, `FlashList` through the `ListComponent` prop.
46
82
  */
47
83
  ListComponent: LC;
48
84
  /**
49
85
  * The width of the `GestureViewer`.
50
- * @remark If you don't set this prop, the width of the `GestureViewer` will be the same as the width of the screen.
86
+ * @remarks If you don't set this prop, the width of the `GestureViewer` will be the same as the width of the screen.
51
87
  * @defaultValue screen width
52
88
  */
53
89
  width?: number;
54
90
  /**
55
91
  * The props to pass to the list component.
56
- * @remark The `listProps` provides **type inference based on the selected list component**, ensuring accurate autocompletion and type safety in your IDE.
92
+ * @remarks The `listProps` provides **type inference based on the selected list component**, ensuring accurate autocompletion and type safety in your IDE.
57
93
  */
58
94
  listProps?: Partial<ConditionalListProps<LC>>;
59
95
  /**
@@ -66,7 +102,7 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
66
102
  containerStyle?: StyleProp<ViewStyle>;
67
103
  /**
68
104
  * Dismiss gesture options. Calls `onDismiss` function when swiping down.
69
- * @remark Useful for closing modals with downward swipe gestures.
105
+ * @remarks Useful for closing modals with downward swipe gestures.
70
106
  */
71
107
  dismiss?: {
72
108
  /**
@@ -86,32 +122,32 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
86
122
  resistance?: number;
87
123
  /**
88
124
  * By default, the background `opacity` gradually decreases from 1 to 0 during downward swipe gestures.
89
- * @remark When `false`, this animation is disabled.
125
+ * @remarks When `false`, this animation is disabled.
90
126
  * @defaultValue true
91
127
  */
92
128
  fadeBackdrop?: boolean;
93
129
  };
94
130
  /**
95
131
  * Controls left/right swipe gestures.
96
- * @remark When `false`, horizontal gestures are disabled.
132
+ * @remarks When `false`, horizontal gestures are disabled.
97
133
  * @defaultValue true
98
134
  */
99
135
  enableHorizontalSwipe?: boolean;
100
136
  /**
101
137
  * Only works when zoom is active, allows moving item position when zoomed.
102
- * @remark When `false`, gesture movement is disabled during zoom.
138
+ * @remarks When `false`, gesture movement is disabled during zoom.
103
139
  * @defaultValue true
104
140
  */
105
141
  enablePanWhenZoomed?: boolean;
106
142
  /**
107
143
  * Controls two-finger pinch gestures.
108
- * @remark When `false`, two-finger zoom gestures are disabled.
144
+ * @remarks When `false`, two-finger zoom gestures are disabled.
109
145
  * @defaultValue true
110
146
  */
111
147
  enablePinchZoom?: boolean;
112
148
  /**
113
149
  * Controls double-tap zoom gestures.
114
- * @remark When `false`, double-tap zoom gestures are disabled.
150
+ * @remarks When `false`, double-tap zoom gestures are disabled.
115
151
  * @defaultValue true
116
152
  */
117
153
  enableDoubleTapZoom?: boolean;
@@ -123,7 +159,7 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
123
159
  /**
124
160
  * Enables snap scrolling mode.
125
161
  *
126
- * @remark
162
+ * @remarks
127
163
  * **`false` (default)**: Paging mode (`pagingEnabled: true`)
128
164
  * - Scrolls by full screen size increments
129
165
  *
@@ -136,7 +172,7 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
136
172
  enableSnapMode?: boolean;
137
173
  /**
138
174
  * The spacing between items in pixels.
139
- * @remark Only applied when `enableSnapMode` is `true`.
175
+ * @remarks Only applied when `enableSnapMode` is `true`.
140
176
  * @defaultValue 0
141
177
  */
142
178
  itemSpacing?: number;
@@ -145,6 +181,25 @@ export interface GestureViewerProps<T = any, LC = typeof RNScrollView> {
145
181
  * @defaultValue 2
146
182
  */
147
183
  maxZoomScale?: number;
184
+ /**
185
+ * Trigger-based animation settings
186
+ * @remarks You can customize animation duration, easing, and system reduce-motion behavior.
187
+ *
188
+ * @example
189
+ * ```tsx
190
+ * <GestureViewer
191
+ * triggerAnimation={{
192
+ * duration: 250,
193
+ * easing: Easing.out(Easing.cubic),
194
+ * reduceMotion: 'system',
195
+ * onAnimationComplete: () => {
196
+ * console.log('Animation complete');
197
+ * },
198
+ * }}
199
+ * />
200
+ * ```
201
+ */
202
+ triggerAnimation?: TriggerAnimationConfig;
148
203
  }
149
204
 
150
205
  /**
@@ -18,7 +18,7 @@ import {
18
18
  import { runOnJS } from 'react-native-worklets';
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<
@@ -41,15 +41,20 @@ export const useGestureViewer = <T = any>({
41
41
  itemSpacing = 0,
42
42
  enableSnapMode = false,
43
43
  id = 'default',
44
+ onDismissStart,
45
+ triggerAnimation,
44
46
  }: UseGestureViewerProps<T>) => {
45
47
  const { width: screenWidth, height: screenHeight } = useWindowDimensions();
46
48
  const width = enableSnapMode ? customWidth || screenWidth : screenWidth;
47
49
 
48
50
  const [isZoomed, setIsZoomed] = useState(false);
49
51
  const [isRotated, setIsRotated] = useState(false);
52
+ const [shouldStartTriggerAnimation, setShouldStartTriggerAnimation] = useState(false);
50
53
  const [manager, setManager] = useState<GestureViewerManager | null>(null);
51
54
 
52
55
  const listRef = useRef<any>(null);
56
+ const triggerRectRef = useRef<TriggerRect | null>(null);
57
+ const onAnimationCompleteRef = useRef(triggerAnimation?.onAnimationComplete);
53
58
 
54
59
  const initialTranslateY = useSharedValue(0);
55
60
  const initialTranslateX = useSharedValue(0);
@@ -61,8 +66,22 @@ export const useGestureViewer = <T = any>({
61
66
  const backdropOpacity = useSharedValue(1);
62
67
  const rotation = useSharedValue(0);
63
68
 
69
+ const triggerScale = useSharedValue(1);
70
+ const triggerTranslateX = useSharedValue(0);
71
+ const triggerTranslateY = useSharedValue(0);
72
+ const triggerOpacity = useSharedValue(1);
73
+
64
74
  const dataLength = data?.length || 0;
65
75
 
76
+ const animationConfig = useMemo(
77
+ () => ({
78
+ duration: triggerAnimation?.duration ?? 300,
79
+ easing: triggerAnimation?.easing ?? Easing.bezier(0.25, 0.1, 0.25, 1.0),
80
+ reduceMotion: triggerAnimation?.reduceMotion,
81
+ }),
82
+ [triggerAnimation?.duration, triggerAnimation?.easing, triggerAnimation?.reduceMotion],
83
+ );
84
+
66
85
  const dismissOptions = useMemo(() => {
67
86
  return {
68
87
  enabled: dismiss?.enabled ?? true,
@@ -112,6 +131,10 @@ export const useGestureViewer = <T = any>({
112
131
  [manager],
113
132
  );
114
133
 
134
+ const onAnimationComplete = useCallback(() => {
135
+ onAnimationCompleteRef.current?.();
136
+ }, []);
137
+
115
138
  useAnimatedReaction(
116
139
  () => scale.value,
117
140
  (currentScale, previousScale) => {
@@ -199,6 +222,105 @@ export const useGestureViewer = <T = any>({
199
222
  };
200
223
  }, [adjustedInitialIndex, translateY, backdropOpacity, translateX, scale, startScale, rotation, scrollTo]);
201
224
 
225
+ useEffect(() => {
226
+ onAnimationCompleteRef.current = triggerAnimation?.onAnimationComplete;
227
+ }, [triggerAnimation?.onAnimationComplete]);
228
+
229
+ useEffect(() => {
230
+ if (shouldStartTriggerAnimation && triggerRectRef.current) {
231
+ const startX = triggerRectRef.current.x + triggerRectRef.current.width / 2 - screenWidth / 2;
232
+ const startY = triggerRectRef.current.y + triggerRectRef.current.height / 2 - screenHeight / 2;
233
+ const initialScaleFromTrigger = Math.min(
234
+ triggerRectRef.current.width / screenWidth,
235
+ triggerRectRef.current.height / screenHeight,
236
+ );
237
+
238
+ triggerScale.value = initialScaleFromTrigger;
239
+ triggerTranslateX.value = startX;
240
+ triggerTranslateY.value = startY;
241
+ triggerOpacity.value = 0;
242
+
243
+ triggerScale.value = withTiming(1, animationConfig, (finished) => {
244
+ if (finished) {
245
+ runOnJS(onAnimationComplete)();
246
+ }
247
+ });
248
+ triggerTranslateX.value = withTiming(0, animationConfig);
249
+ triggerTranslateY.value = withTiming(0, animationConfig);
250
+ triggerOpacity.value = withTiming(1, {
251
+ duration: animationConfig.duration / 2,
252
+ easing: animationConfig.easing,
253
+ reduceMotion: animationConfig.reduceMotion,
254
+ });
255
+
256
+ setShouldStartTriggerAnimation(false);
257
+ }
258
+ }, [
259
+ shouldStartTriggerAnimation,
260
+ animationConfig,
261
+ screenWidth,
262
+ screenHeight,
263
+ triggerOpacity,
264
+ triggerScale,
265
+ triggerTranslateX,
266
+ triggerTranslateY,
267
+ onAnimationComplete,
268
+ ]);
269
+
270
+ useEffect(() => {
271
+ const node = registry.getTriggerNode(id);
272
+
273
+ if (node && typeof node.measure === 'function') {
274
+ node.measure((_x, _y, width, height, pageX, pageY) => {
275
+ triggerRectRef.current = { x: pageX, y: pageY, width, height };
276
+ triggerOpacity.value = 0;
277
+ setShouldStartTriggerAnimation(true);
278
+ registry.clearTriggerNode(id);
279
+ });
280
+ }
281
+
282
+ return () => {
283
+ triggerRectRef.current = null;
284
+ };
285
+ }, [id, triggerOpacity]);
286
+
287
+ const handleDismiss = useCallback(() => {
288
+ onDismissStart?.();
289
+
290
+ if (triggerRectRef.current) {
291
+ const endX = triggerRectRef.current.x + triggerRectRef.current.width / 2 - screenWidth / 2;
292
+ const endY = triggerRectRef.current.y + triggerRectRef.current.height / 2 - screenHeight / 2;
293
+ const endScale = Math.min(
294
+ triggerRectRef.current.width / screenWidth,
295
+ triggerRectRef.current.height / screenHeight,
296
+ );
297
+
298
+ triggerScale.value = withTiming(endScale, animationConfig);
299
+ triggerTranslateX.value = withTiming(endX, animationConfig);
300
+ triggerTranslateY.value = withTiming(endY, animationConfig);
301
+ triggerOpacity.value = withTiming(0, animationConfig, (finished) => {
302
+ if (finished && onDismiss) {
303
+ runOnJS(onDismiss)();
304
+ }
305
+ });
306
+ return;
307
+ }
308
+
309
+ if (onDismiss) {
310
+ runOnJS(onDismiss)();
311
+ }
312
+ }, [
313
+ animationConfig,
314
+ onDismiss,
315
+ onDismissStart,
316
+ screenWidth,
317
+ screenHeight,
318
+ triggerTranslateX,
319
+ triggerScale,
320
+ triggerTranslateY,
321
+ triggerOpacity,
322
+ ]);
323
+
202
324
  const onMomentumScrollEnd = useCallback(
203
325
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
204
326
  if (!enableHorizontalSwipe) {
@@ -220,7 +342,7 @@ export const useGestureViewer = <T = any>({
220
342
  scrollTo(jumpToIndex, false);
221
343
  }
222
344
 
223
- const currentIndex = manager?.getState() ?? 0;
345
+ const currentIndex = manager?.getState()?.currentIndex;
224
346
 
225
347
  if (realIndex !== currentIndex && realIndex >= 0 && realIndex < dataLength) {
226
348
  if (manager) {
@@ -256,19 +378,21 @@ export const useGestureViewer = <T = any>({
256
378
  );
257
379
 
258
380
  const dismissGesture = useMemo(() => {
381
+ const canDismiss = !isZoomed && dismissOptions.enabled;
382
+
259
383
  return Gesture.Pan()
260
384
  .minDistance(10)
261
385
  .averageTouches(true)
262
386
  .activeCursor('grabbing')
263
387
  .activeOffsetY([-10, 10])
264
388
  .failOffsetX([-10, 10])
265
- .enabled(!isZoomed)
389
+ .enabled(canDismiss)
266
390
  .onUpdate((event) => {
267
391
  translateY.value = event.translationY / dismissOptions.resistance;
268
392
  })
269
393
  .onEnd((event) => {
270
- if (event.translationY > dismissOptions.threshold && dismissOptions.enabled && onDismiss) {
271
- runOnJS(onDismiss)();
394
+ if (canDismiss && event.translationY > dismissOptions.threshold) {
395
+ runOnJS(handleDismiss)();
272
396
  return;
273
397
  }
274
398
 
@@ -280,7 +404,7 @@ export const useGestureViewer = <T = any>({
280
404
  energyThreshold: 6e-9,
281
405
  });
282
406
  });
283
- }, [translateY, dismissOptions.threshold, dismissOptions.enabled, onDismiss, dismissOptions.resistance, isZoomed]);
407
+ }, [translateY, dismissOptions, handleDismiss, isZoomed]);
284
408
 
285
409
  const zoomPinchGesture = useMemo(() => {
286
410
  return Gesture.Pinch()
@@ -451,7 +575,12 @@ export const useGestureViewer = <T = any>({
451
575
 
452
576
  const animatedStyle = useAnimatedStyle(() => {
453
577
  return {
578
+ opacity: triggerOpacity.value,
454
579
  transform: [
580
+ { translateX: triggerTranslateX.value },
581
+ { translateY: triggerTranslateY.value },
582
+ { scale: triggerScale.value },
583
+
455
584
  { translateY: translateY.value },
456
585
  { translateX: translateX.value },
457
586
  { scale: scale.value },
@@ -461,13 +590,15 @@ export const useGestureViewer = <T = any>({
461
590
  });
462
591
 
463
592
  const backdropStyle = useAnimatedStyle(() => {
593
+ const baseOpacity = triggerOpacity.value;
594
+
464
595
  if (!dismissOptions.fadeBackdrop || scale.value !== 1) {
465
- return { opacity: 1 };
596
+ return { opacity: baseOpacity };
466
597
  }
467
598
 
468
- const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
599
+ const dismissOpacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
469
600
 
470
- return { opacity };
601
+ return { opacity: baseOpacity * dismissOpacity };
471
602
  }, [dismissOptions.fadeBackdrop]);
472
603
 
473
604
  const onScrollBeginDrag = useCallback(() => {
@@ -476,7 +607,6 @@ export const useGestureViewer = <T = any>({
476
607
 
477
608
  return {
478
609
  dataLength,
479
- translateY,
480
610
  listRef,
481
611
  isZoomed,
482
612
  isRotated,
@@ -485,6 +615,7 @@ export const useGestureViewer = <T = any>({
485
615
 
486
616
  onMomentumScrollEnd,
487
617
  onScrollBeginDrag,
618
+ handleDismiss,
488
619
 
489
620
  animatedStyle,
490
621
  backdropStyle,