Skip to content

Commit 5e92961

Browse files
authored
feat: add loop mode for GestureViewer (#51)
1 parent 053dd49 commit 5e92961

File tree

9 files changed

+238
-65
lines changed

9 files changed

+238
-65
lines changed

.changeset/giant-wolves-share.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"react-native-gesture-image-viewer": minor
3+
---
4+
5+
feat: add loop mode for GestureViewer
6+
- Add `enableLoop` prop for seamless boundary crossing
7+
- Implement `goToNext`/`goToPrevious` with loop animation
8+
- Support both FlatList, FlashList and ScrollView components
9+
10+
Example usage:
11+
12+
```tsx
13+
// New prop
14+
<GestureViewer
15+
enableLoop={true} // Enable loop mode
16+
data={images}
17+
renderItem={renderItem}
18+
/>
19+
20+
// Enhanced controller methods
21+
const { goToNext, goToPrevious } = useGestureViewerController();
22+
// Now supports loop transitions when enableLoop is true
23+
```

README-ko_kr.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function App() {
149149
<GestureViewer
150150
data={images}
151151
renderItem={renderImage}
152+
enableLoop={false}
152153
enableDismissGesture
153154
enableSwipeGesture
154155
enableZoomGesture
@@ -161,6 +162,7 @@ function App() {
161162

162163
|속성|설명|기본값|
163164
|:--:|:-----|:--:|
165+
|`enableLoop`|루프 모드를 활성화합니다. `true`일 때 마지막 아이템에서 다음으로 가면 첫 번째로, 첫 번째에서 이전으로 가면 마지막으로 돌아갑니다.|`false`|
164166
|`enableDismissGesture`|아래로 스와이프할 때 `onDismiss` 함수를 호출합니다. 모달을 아래로 스와이프해서 닫는 제스처에 유용합니다.|`true`|
165167
|`enableSwipeGesture`|좌우 스와이프 제스처를 제어합니다. `false`일 때 가로 제스처가 비활성화됩니다.|`true`|
166168
|`enableZoomGesture`|두 손가락 핀치 제스처를 제어합니다. `false`일 때 핀치 줌 제스처가 비활성화됩니다. 핀치 줌은 두 손가락 사이의 중심점을 기준으로 확대됩니다.|`true`|

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ function App() {
149149
<GestureViewer
150150
data={images}
151151
renderItem={renderImage}
152+
enableLoop={false}
152153
enableDismissGesture
153154
enableSwipeGesture
154155
enableZoomGesture
@@ -161,6 +162,7 @@ function App() {
161162

162163
|Property|Description|Default|
163164
|:--:|:-----|:--:|
165+
|`enableLoop`|Enables loop mode. When `true`, navigating next from the last item goes to the first item, and navigating previous from the first item goes to the last item.|`false`|
164166
|`enableDismissGesture`|Calls `onDismiss` function when swiping down. Useful for closing modals with downward swipe gestures.|`true`|
165167
|`enableSwipeGesture`|Controls left/right swipe gestures. When `false`, horizontal gestures are disabled.|`true`|
166168
|`enableZoomGesture`|Controls two-finger pinch gestures with focal point zooming. When `false`, pinch zoom is disabled. Zoom centers on the point between your two fingers for intuitive scaling.|`true`|

example/src/Example.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const images = [
1616

1717
function Example() {
1818
const [visible, setVisible] = useState(false);
19+
const [enableLoop, setEnableLoop] = useState(false);
1920

2021
const { goToIndex, goToPrevious, goToNext, currentIndex, totalCount, zoomIn, zoomOut, resetZoom, rotate } =
2122
useGestureViewerController();
@@ -42,13 +43,15 @@ function Example() {
4243

4344
return (
4445
<View style={styles.container}>
45-
<Button title="Open" onPress={() => setVisible(true)} />
46+
<Button title={`Loop: ${enableLoop ? 'ON' : 'OFF'}`} onPress={() => setEnableLoop(!enableLoop)} />
47+
<Button title="Open Gallery" onPress={() => setVisible(true)} />
4648
<Modal visible={visible} onRequestClose={() => setVisible(false)}>
4749
<View style={{ flex: 1 }}>
4850
<GestureViewer
4951
data={images}
5052
initialIndex={0}
5153
onDismiss={() => setVisible(false)}
54+
enableLoop={enableLoop}
5255
ListComponent={FlashList}
5356
renderItem={renderImage}
5457
backdropStyle={{ backgroundColor: 'rgba(0, 0, 0, 0.90)' }}

src/GestureViewer.tsx

Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { useCallback, useEffect, useMemo } from 'react';
1+
import { useCallback, useEffect, useMemo, useRef } from 'react';
22
import { type FlatList, Platform, type ScrollViewProps, StyleSheet, useWindowDimensions, View } from 'react-native';
33
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
44
import Animated from 'react-native-reanimated';
55
import { registry } from './GestureViewerRegistry';
66
import type { GestureViewerProps } from './types';
77
import { useGestureViewer } from './useGestureViewer';
8-
import { isFlashListLike, isFlatListLike, isScrollViewLike } from './utils';
8+
import { createLoopData, isFlashListLike, isFlatListLike, isScrollViewLike } from './utils';
99
import WebPagingFixStyle from './WebPagingFixStyle';
1010

1111
export function GestureViewer<T = any, LC = typeof FlatList>({
@@ -21,21 +21,28 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
2121
initialIndex = 0,
2222
itemSpacing = 0,
2323
useSnap = false,
24+
enableLoop = false,
2425
...props
2526
}: GestureViewerProps<T, LC>) {
2627
const Component = ListComponent as React.ComponentType<any>;
2728

29+
const dataRef = useRef(data);
30+
dataRef.current = data;
31+
2832
const { width: screenWidth } = useWindowDimensions();
2933

3034
const width = useSnap ? customWidth || screenWidth : screenWidth;
3135

36+
const loopData = useMemo(() => createLoopData(dataRef, enableLoop), [enableLoop]);
37+
3238
const {
3339
listRef,
3440
isZoomed,
3541
isRotated,
3642
dismissGesture,
3743
zoomGesture,
3844
onMomentumScrollEnd,
45+
onScrollBeginDrag,
3946
animatedStyle,
4047
backdropStyle,
4148
} = useGestureViewer({
@@ -45,14 +52,26 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
4552
initialIndex,
4653
itemSpacing,
4754
useSnap,
55+
enableLoop,
4856
...props,
4957
});
5058

59+
const keyExtractor = useCallback(
60+
(item: T, index: number) => {
61+
if (enableLoop) {
62+
return typeof item === 'string' ? `${item}-${index}` : `item-${index}`;
63+
}
64+
65+
return typeof item === 'string' ? item : `image-${index}`;
66+
},
67+
[enableLoop],
68+
);
69+
5170
const renderItem = useCallback(
5271
({ item, index }: { item: T; index: number }) => {
5372
return (
5473
<View
55-
key={typeof item === 'string' ? item : index}
74+
key={keyExtractor(item, index)}
5675
style={[
5776
{
5877
width,
@@ -67,7 +86,7 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
6786
</View>
6887
);
6988
},
70-
[width, itemSpacing, renderItemProp],
89+
[width, itemSpacing, renderItemProp, keyExtractor],
7190
);
7291

7392
const getItemLayout = useCallback(
@@ -79,11 +98,6 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
7998
[width, itemSpacing],
8099
);
81100

82-
const keyExtractor = useCallback(
83-
(item: T, index: number) => (typeof item === 'string' ? item : `image-${index}`),
84-
[],
85-
);
86-
87101
const gesture = useMemo(() => {
88102
return Gesture.Race(dismissGesture, zoomGesture);
89103
}, [zoomGesture, dismissGesture]);
@@ -94,25 +108,27 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
94108
return () => registry.deleteManager(id);
95109
}, [id]);
96110

97-
const commonProps: ScrollViewProps = useMemo(
98-
() => ({
99-
horizontal: true,
100-
scrollEnabled: !isZoomed && !isRotated,
101-
showsHorizontalScrollIndicator: false,
102-
onMomentumScrollEnd: onMomentumScrollEnd,
103-
...(useSnap
104-
? {
105-
snapToInterval: width + itemSpacing,
106-
snapToAlignment: 'center',
107-
decelerationRate: 'fast',
108-
}
109-
: {
110-
pagingEnabled: true,
111-
}),
112-
scrollEventThrottle: 16,
113-
removeClippedSubviews: true,
114-
}),
115-
[width, itemSpacing, isZoomed, isRotated, onMomentumScrollEnd, useSnap],
111+
const commonProps = useMemo(
112+
() =>
113+
({
114+
horizontal: true,
115+
scrollEnabled: !isZoomed && !isRotated,
116+
showsHorizontalScrollIndicator: false,
117+
onMomentumScrollEnd: onMomentumScrollEnd,
118+
onScrollBeginDrag,
119+
...(useSnap
120+
? {
121+
snapToInterval: width + itemSpacing,
122+
snapToAlignment: 'center',
123+
decelerationRate: 'fast',
124+
}
125+
: {
126+
pagingEnabled: true,
127+
}),
128+
scrollEventThrottle: 16,
129+
removeClippedSubviews: true,
130+
}) satisfies ScrollViewProps,
131+
[width, itemSpacing, isZoomed, isRotated, onMomentumScrollEnd, onScrollBeginDrag, useSnap],
116132
);
117133

118134
const listComponent = (
@@ -127,16 +143,16 @@ export function GestureViewer<T = any, LC = typeof FlatList>({
127143
>
128144
{isScrollViewLike(Component) ? (
129145
<Component ref={listRef} {...commonProps} {...listProps}>
130-
{data.map((item, index) => renderItem({ item, index }))}
146+
{loopData.map((item, index) => renderItem({ item, index }))}
131147
</Component>
132148
) : (
133149
isFlatListLike(Component) && (
134150
<Component
135151
ref={listRef}
136152
{...commonProps}
137-
data={data}
153+
data={loopData}
138154
renderItem={renderItem}
139-
initialScrollIndex={initialIndex}
155+
initialScrollIndex={enableLoop && data.length > 1 ? initialIndex + 1 : initialIndex}
140156
keyExtractor={keyExtractor}
141157
windowSize={3}
142158
maxToRenderPerBatch={3}

src/GestureViewerManager.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
GestureViewerEventData,
66
GestureViewerEventType,
77
} from './types';
8-
import { createBoundsConstraint } from './utils';
8+
import { createBoundsConstraint, createScrollAction } from './utils';
99

1010
class GestureViewerManager {
1111
private currentIndex = 0;
@@ -18,9 +18,13 @@ class GestureViewerManager {
1818
private maxZoomScale = 2;
1919
private listRef: any | null = null;
2020
private enableSwipeGesture = true;
21+
private enableLoop = false;
2122
private listeners = new Set<(state: GestureViewerControllerState) => void>();
2223
private rotation: SharedValue<number> | null = null;
2324
private eventListeners = new Map<GestureViewerEventType, Set<(data: any) => void>>();
25+
private animationTimeout: NodeJS.Timeout | null = null;
26+
27+
private static readonly LOOP_ANIMATION_DURATION = 300;
2428

2529
private notifyListeners() {
2630
const state = this.getState();
@@ -79,6 +83,10 @@ class GestureViewerManager {
7983
};
8084
}
8185

86+
setEnableLoop(enabled: boolean) {
87+
this.enableLoop = enabled;
88+
}
89+
8290
setWidth(width: number) {
8391
this.width = width;
8492
}
@@ -215,47 +223,85 @@ class GestureViewerManager {
215223
};
216224

217225
goToIndex = (index: number) => {
218-
if (index < 0 || index >= this.dataLength || !this.enableSwipeGesture || !this.listRef) {
226+
if (!this.enableSwipeGesture || !this.listRef) {
219227
return;
220228
}
221229

222-
this.currentIndex = index;
230+
this.cancelAnimation();
231+
232+
const { scrollTo } = createScrollAction(this.listRef, this.width);
233+
234+
if (this.enableLoop && this.dataLength > 1) {
235+
if (index < 0) {
236+
scrollTo(0, true);
237+
238+
this.animationTimeout = setTimeout(() => {
239+
scrollTo(this.dataLength, false);
240+
this.updateCurrentIndex(this.dataLength - 1);
241+
}, GestureViewerManager.LOOP_ANIMATION_DURATION);
242+
return;
243+
}
244+
245+
if (index >= this.dataLength) {
246+
scrollTo(this.dataLength + 1, true);
247+
248+
this.animationTimeout = setTimeout(() => {
249+
scrollTo(1, false);
250+
this.updateCurrentIndex(0);
251+
}, GestureViewerManager.LOOP_ANIMATION_DURATION);
252+
return;
253+
}
254+
255+
const scrollIndex = index + 1;
256+
257+
scrollTo(scrollIndex, true);
258+
this.updateCurrentIndex(index);
259+
260+
return;
261+
}
223262

224-
if (this.listRef.scrollToIndex) {
225-
this.listRef.scrollToIndex({ index, animated: true });
226-
} else if (this.listRef.scrollTo) {
227-
this.listRef.scrollTo({ x: index * this.width, animated: true });
263+
if (index < 0 || index >= this.dataLength) {
264+
return;
228265
}
229266

230-
this.notifyListeners();
267+
scrollTo(index, true);
268+
this.updateCurrentIndex(index);
231269
};
232270

233-
goToPrevious = () => {
234-
if (this.currentIndex > 0) {
235-
this.goToIndex(this.currentIndex - 1);
271+
cancelAnimation = () => {
272+
if (this.animationTimeout) {
273+
clearTimeout(this.animationTimeout);
274+
this.animationTimeout = null;
236275
}
237276
};
238277

278+
goToPrevious = () => {
279+
this.goToIndex(this.currentIndex - 1);
280+
};
281+
239282
goToNext = () => {
240-
if (this.currentIndex < this.dataLength - 1) {
241-
this.goToIndex(this.currentIndex + 1);
242-
}
283+
this.goToIndex(this.currentIndex + 1);
243284
};
244285

245286
cleanUp() {
287+
this.cancelAnimation();
246288
this.listeners.clear();
247289
this.listRef = null;
248290
this.enableSwipeGesture = true;
249291
this.currentIndex = 0;
250292
this.dataLength = 0;
251-
252293
this.maxZoomScale = 2;
253294
this.scale = null;
254295
this.translateX = null;
255296
this.translateY = null;
256297
this.rotation = null;
257298
this.eventListeners.clear();
258299
}
300+
301+
private updateCurrentIndex = (targetIndex: number) => {
302+
this.currentIndex = targetIndex;
303+
this.notifyListeners();
304+
};
259305
}
260306

261307
export default GestureViewerManager;

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
130130
* @defaultValue true
131131
*/
132132
enableDoubleTapGesture?: boolean;
133+
/**
134+
* Enables infinite loop navigation.
135+
* @defaultValue false
136+
*/
137+
enableLoop?: boolean;
133138
/**
134139
* The maximum zoom scale.
135140
* @defaultValue 2

0 commit comments

Comments
 (0)