react-native-gesture-image-viewer 1.2.2 → 1.2.3
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/README.md +1 -1
- package/package.json +2 -1
- package/src/GestureViewer.tsx +171 -0
- package/src/GestureViewerManager.ts +103 -0
- package/src/GestureViewerRegistry.ts +66 -0
- package/src/WebPagingFixStyle.tsx +24 -0
- package/src/index.tsx +3 -0
- package/src/types.ts +144 -0
- package/src/useGestureViewer.ts +424 -0
- package/src/useGestureViewerController.ts +48 -0
- package/src/utils.ts +29 -0
package/README.md
CHANGED
|
@@ -193,7 +193,7 @@ function App() {
|
|
|
193
193
|
|
|
194
194
|
#### Content Components
|
|
195
195
|
|
|
196
|
-
You can inject various types of content components like `expo-image`, `FastImage`, etc., through the `
|
|
196
|
+
You can inject various types of content components like `expo-image`, `FastImage`, etc., through the `renderItem` prop to use gestures.
|
|
197
197
|
|
|
198
198
|
```tsx
|
|
199
199
|
import { GestureViewer } from 'react-native-gesture-image-viewer';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-gesture-image-viewer",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
4
4
|
"description": "🖼️ A highly customizable and easy-to-use React Native image viewer with gesture support and external controls",
|
|
5
5
|
"main": "./lib/module/index.js",
|
|
6
6
|
"types": "./lib/typescript/src/index.d.ts",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
|
+
"src",
|
|
17
18
|
"android",
|
|
18
19
|
"ios",
|
|
19
20
|
"cpp",
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo } from 'react';
|
|
2
|
+
import { type FlatList, Platform, type ScrollViewProps, StyleSheet, useWindowDimensions, View } from 'react-native';
|
|
3
|
+
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
4
|
+
import Animated from 'react-native-reanimated';
|
|
5
|
+
import { registry } from './GestureViewerRegistry';
|
|
6
|
+
import type { GestureViewerProps } from './types';
|
|
7
|
+
import { useGestureViewer } from './useGestureViewer';
|
|
8
|
+
import { isFlashListLike, isFlatListLike, isScrollViewLike } from './utils';
|
|
9
|
+
import WebPagingFixStyle from './WebPagingFixStyle';
|
|
10
|
+
|
|
11
|
+
export function GestureViewer<T = any, LC = typeof FlatList>({
|
|
12
|
+
id = 'default',
|
|
13
|
+
data,
|
|
14
|
+
renderItem: renderItemProp,
|
|
15
|
+
renderContainer,
|
|
16
|
+
ListComponent,
|
|
17
|
+
width: customWidth,
|
|
18
|
+
listProps,
|
|
19
|
+
backdropStyle: backdropStyleProps,
|
|
20
|
+
containerStyle,
|
|
21
|
+
initialIndex = 0,
|
|
22
|
+
itemSpacing = 0,
|
|
23
|
+
useSnap = false,
|
|
24
|
+
...props
|
|
25
|
+
}: GestureViewerProps<T, LC>) {
|
|
26
|
+
const Component = ListComponent as React.ComponentType<any>;
|
|
27
|
+
|
|
28
|
+
const { width: screenWidth } = useWindowDimensions();
|
|
29
|
+
|
|
30
|
+
const width = useSnap ? customWidth || screenWidth : screenWidth;
|
|
31
|
+
|
|
32
|
+
const { listRef, isZoomed, dismissGesture, zoomGesture, onMomentumScrollEnd, animatedStyle, backdropStyle } =
|
|
33
|
+
useGestureViewer({
|
|
34
|
+
id,
|
|
35
|
+
data,
|
|
36
|
+
width,
|
|
37
|
+
initialIndex,
|
|
38
|
+
itemSpacing,
|
|
39
|
+
useSnap,
|
|
40
|
+
...props,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const renderItem = useCallback(
|
|
44
|
+
({ item, index }: { item: T; index: number }) => {
|
|
45
|
+
return (
|
|
46
|
+
<View
|
|
47
|
+
key={typeof item === 'string' ? item : index}
|
|
48
|
+
style={[
|
|
49
|
+
{
|
|
50
|
+
width,
|
|
51
|
+
height: '100%',
|
|
52
|
+
justifyContent: 'center',
|
|
53
|
+
alignItems: 'center',
|
|
54
|
+
marginHorizontal: itemSpacing / 2,
|
|
55
|
+
},
|
|
56
|
+
]}
|
|
57
|
+
>
|
|
58
|
+
{renderItemProp(item, index)}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
[width, itemSpacing, renderItemProp],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const getItemLayout = useCallback(
|
|
66
|
+
(_: ArrayLike<T> | null | undefined, index: number) => ({
|
|
67
|
+
length: width + itemSpacing,
|
|
68
|
+
offset: (width + itemSpacing) * index,
|
|
69
|
+
index,
|
|
70
|
+
}),
|
|
71
|
+
[width, itemSpacing],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const keyExtractor = useCallback(
|
|
75
|
+
(item: T, index: number) => (typeof item === 'string' ? item : `image-${index}`),
|
|
76
|
+
[],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const gesture = useMemo(() => {
|
|
80
|
+
return Gesture.Race(dismissGesture, zoomGesture);
|
|
81
|
+
}, [zoomGesture, dismissGesture]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
registry.createManager(id);
|
|
85
|
+
|
|
86
|
+
return () => registry.deleteManager(id);
|
|
87
|
+
}, [id]);
|
|
88
|
+
|
|
89
|
+
const commonProps: ScrollViewProps = useMemo(
|
|
90
|
+
() => ({
|
|
91
|
+
horizontal: true,
|
|
92
|
+
scrollEnabled: !isZoomed,
|
|
93
|
+
showsHorizontalScrollIndicator: false,
|
|
94
|
+
onMomentumScrollEnd: onMomentumScrollEnd,
|
|
95
|
+
...(useSnap
|
|
96
|
+
? {
|
|
97
|
+
snapToInterval: width + itemSpacing,
|
|
98
|
+
snapToAlignment: 'center',
|
|
99
|
+
decelerationRate: 'fast',
|
|
100
|
+
}
|
|
101
|
+
: {
|
|
102
|
+
pagingEnabled: true,
|
|
103
|
+
}),
|
|
104
|
+
scrollEventThrottle: 16,
|
|
105
|
+
removeClippedSubviews: true,
|
|
106
|
+
}),
|
|
107
|
+
[width, itemSpacing, isZoomed, onMomentumScrollEnd, useSnap],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const listComponent = (
|
|
111
|
+
<GestureHandlerRootView>
|
|
112
|
+
<GestureDetector gesture={gesture}>
|
|
113
|
+
<View style={[styles.container, containerStyle]}>
|
|
114
|
+
<Animated.View style={[styles.background, backdropStyleProps, backdropStyle]} />
|
|
115
|
+
<Animated.View
|
|
116
|
+
style={[styles.content, animatedStyle]}
|
|
117
|
+
{...(Platform.OS === 'web' &&
|
|
118
|
+
isFlashListLike(Component) && { dataSet: { 'flash-list-paging-enabled-fix': true } })}
|
|
119
|
+
>
|
|
120
|
+
{isScrollViewLike(Component) ? (
|
|
121
|
+
<Component ref={listRef} {...commonProps} {...listProps}>
|
|
122
|
+
{data.map((item, index) => renderItem({ item, index }))}
|
|
123
|
+
</Component>
|
|
124
|
+
) : (
|
|
125
|
+
isFlatListLike(Component) && (
|
|
126
|
+
<Component
|
|
127
|
+
ref={listRef}
|
|
128
|
+
{...commonProps}
|
|
129
|
+
data={data}
|
|
130
|
+
renderItem={renderItem}
|
|
131
|
+
initialScrollIndex={initialIndex}
|
|
132
|
+
keyExtractor={keyExtractor}
|
|
133
|
+
windowSize={3}
|
|
134
|
+
maxToRenderPerBatch={3}
|
|
135
|
+
getItemLayout={getItemLayout}
|
|
136
|
+
{...(isFlashListLike(Component) && { estimatedItemSize: width + itemSpacing })}
|
|
137
|
+
// NOTE - https://github.com/necolas/react-native-web/issues/1299
|
|
138
|
+
{...(Platform.OS === 'web' &&
|
|
139
|
+
isFlatListLike(Component) && { dataSet: { 'flat-list-paging-enabled-fix': true } })}
|
|
140
|
+
{...listProps}
|
|
141
|
+
/>
|
|
142
|
+
)
|
|
143
|
+
)}
|
|
144
|
+
</Animated.View>
|
|
145
|
+
<WebPagingFixStyle Component={Component} />
|
|
146
|
+
</View>
|
|
147
|
+
</GestureDetector>
|
|
148
|
+
</GestureHandlerRootView>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return renderContainer ? renderContainer(listComponent) : listComponent;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const styles = StyleSheet.create({
|
|
155
|
+
container: {
|
|
156
|
+
flex: 1,
|
|
157
|
+
},
|
|
158
|
+
content: {
|
|
159
|
+
flex: 1,
|
|
160
|
+
width: '100%',
|
|
161
|
+
height: '100%',
|
|
162
|
+
},
|
|
163
|
+
background: {
|
|
164
|
+
position: 'absolute',
|
|
165
|
+
top: 0,
|
|
166
|
+
left: 0,
|
|
167
|
+
right: 0,
|
|
168
|
+
bottom: 0,
|
|
169
|
+
backgroundColor: 'black',
|
|
170
|
+
},
|
|
171
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type GestureViewerManagerState = {
|
|
2
|
+
currentIndex: number;
|
|
3
|
+
dataLength: number;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
class GestureViewerManager {
|
|
7
|
+
private currentIndex = 0;
|
|
8
|
+
private dataLength = 0;
|
|
9
|
+
private width = 0;
|
|
10
|
+
private listRef: any | null = null;
|
|
11
|
+
private enableSwipeGesture = true;
|
|
12
|
+
private listeners = new Set<(state: GestureViewerManagerState) => void>();
|
|
13
|
+
|
|
14
|
+
// private updateState(newState: Partial<any>) {
|
|
15
|
+
// Object.assign(this, newState);
|
|
16
|
+
// this.notifyListeners();
|
|
17
|
+
// }
|
|
18
|
+
|
|
19
|
+
private notifyListeners() {
|
|
20
|
+
const state = this.getState();
|
|
21
|
+
|
|
22
|
+
this.listeners.forEach((listener) => listener(state));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
subscribe(listener: (state: GestureViewerManagerState) => void) {
|
|
26
|
+
this.listeners.add(listener);
|
|
27
|
+
|
|
28
|
+
return () => {
|
|
29
|
+
this.listeners.delete(listener);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getState() {
|
|
34
|
+
return {
|
|
35
|
+
currentIndex: this.currentIndex,
|
|
36
|
+
dataLength: this.dataLength,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
setWidth(width: number) {
|
|
41
|
+
this.width = width;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setListRef(ref: any) {
|
|
45
|
+
this.listRef = ref;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setDataLength(length: number) {
|
|
49
|
+
this.dataLength = length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setEnableSwipeGesture(enabled: boolean) {
|
|
53
|
+
this.enableSwipeGesture = enabled;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setCurrentIndex(index: number) {
|
|
57
|
+
if (index !== this.currentIndex) {
|
|
58
|
+
this.currentIndex = index;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
notifyStateChange() {
|
|
63
|
+
this.notifyListeners();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
goToIndex = (index: number) => {
|
|
67
|
+
if (index < 0 || index >= this.dataLength || !this.enableSwipeGesture || !this.listRef) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.currentIndex = index;
|
|
72
|
+
|
|
73
|
+
if (this.listRef.scrollToIndex) {
|
|
74
|
+
this.listRef.scrollToIndex({ index, animated: true });
|
|
75
|
+
} else if (this.listRef.scrollTo) {
|
|
76
|
+
this.listRef.scrollTo({ x: index * this.width, animated: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.notifyListeners();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
goToPrevious = () => {
|
|
83
|
+
if (this.currentIndex > 0) {
|
|
84
|
+
this.goToIndex(this.currentIndex - 1);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
goToNext = () => {
|
|
89
|
+
if (this.currentIndex < this.dataLength - 1) {
|
|
90
|
+
this.goToIndex(this.currentIndex + 1);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
cleanUp() {
|
|
95
|
+
this.listeners.clear();
|
|
96
|
+
this.listRef = null;
|
|
97
|
+
this.enableSwipeGesture = true;
|
|
98
|
+
this.currentIndex = 0;
|
|
99
|
+
this.dataLength = 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default GestureViewerManager;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import GestureViewerManager from './GestureViewerManager';
|
|
2
|
+
|
|
3
|
+
class GestureViewerRegistry {
|
|
4
|
+
private managers = new Map<string, GestureViewerManager>();
|
|
5
|
+
private subscribers = new Map<string, Set<(manager: GestureViewerManager | null) => void>>();
|
|
6
|
+
|
|
7
|
+
subscribeToManager(id: string, callback: (manager: GestureViewerManager | null) => void) {
|
|
8
|
+
if (!this.subscribers.has(id)) {
|
|
9
|
+
this.subscribers.set(id, new Set());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
this.subscribers.get(id)?.add(callback);
|
|
13
|
+
|
|
14
|
+
const manager = this.managers.get(id) || null;
|
|
15
|
+
|
|
16
|
+
callback(manager);
|
|
17
|
+
|
|
18
|
+
return () => {
|
|
19
|
+
const subscribers = this.subscribers.get(id);
|
|
20
|
+
|
|
21
|
+
subscribers?.delete(callback);
|
|
22
|
+
|
|
23
|
+
if (subscribers && subscribers.size === 0) {
|
|
24
|
+
this.subscribers.delete(id);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createManager(id: string): GestureViewerManager | null {
|
|
30
|
+
if (this.managers.has(id)) {
|
|
31
|
+
return this.managers.get(id) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const manager = new GestureViewerManager();
|
|
35
|
+
this.managers.set(id, manager);
|
|
36
|
+
|
|
37
|
+
this.notifySubscribers(id, manager);
|
|
38
|
+
|
|
39
|
+
return manager;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
getManager(id: string): GestureViewerManager | null {
|
|
43
|
+
return this.managers.get(id) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
deleteManager(id: string) {
|
|
47
|
+
const manager = this.managers.get(id);
|
|
48
|
+
|
|
49
|
+
if (manager) {
|
|
50
|
+
manager.cleanUp();
|
|
51
|
+
this.managers.delete(id);
|
|
52
|
+
|
|
53
|
+
this.notifySubscribers(id, null);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
notifySubscribers(id: string, manager: GestureViewerManager | null) {
|
|
58
|
+
const listeners = this.subscribers.get(id);
|
|
59
|
+
|
|
60
|
+
if (listeners) {
|
|
61
|
+
[...listeners].forEach((callback) => callback(manager));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const registry = new GestureViewerRegistry();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import { isFlashListLike, isFlatListLike } from './utils';
|
|
3
|
+
|
|
4
|
+
type WebPagingFixStyleProps = {
|
|
5
|
+
Component: React.ComponentType<any>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function WebPagingFixStyle({ Component }: WebPagingFixStyleProps) {
|
|
9
|
+
if (Platform.OS !== 'web') {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (isFlashListLike(Component)) {
|
|
14
|
+
return <style>{`[data-flash-list-paging-enabled-fix] > div {height: 100%;}`}</style>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (isFlatListLike(Component)) {
|
|
18
|
+
return <style>{`[data-flat-list-paging-enabled-fix] > div > div > div {height: 100%;}`}</style>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default WebPagingFixStyle;
|
package/src/index.tsx
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import type { FlatList as RNFlatList, ScrollView as RNScrollView, StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import type { FlatList as GHFlatList, ScrollView as GHScrollView } from 'react-native-gesture-handler';
|
|
4
|
+
|
|
5
|
+
export type FlatListComponent = typeof RNFlatList | typeof GHFlatList;
|
|
6
|
+
export type ScrollViewComponent = typeof RNScrollView | typeof GHScrollView;
|
|
7
|
+
|
|
8
|
+
type GetComponentProps<T> = T extends React.ComponentType<infer P> ? P : never;
|
|
9
|
+
|
|
10
|
+
type ConditionalListProps<LC> = LC extends FlatListComponent
|
|
11
|
+
? React.ComponentProps<LC>
|
|
12
|
+
: LC extends ScrollViewComponent
|
|
13
|
+
? React.ComponentProps<LC>
|
|
14
|
+
: GetComponentProps<LC>;
|
|
15
|
+
|
|
16
|
+
export interface GestureViewerProps<T = any, LC = typeof RNFlatList> {
|
|
17
|
+
/**
|
|
18
|
+
* 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.
|
|
20
|
+
* @default 'default'
|
|
21
|
+
*/
|
|
22
|
+
id?: string;
|
|
23
|
+
/**
|
|
24
|
+
* The data to display in the `GestureViewer`.
|
|
25
|
+
*/
|
|
26
|
+
data: T[];
|
|
27
|
+
/**
|
|
28
|
+
* The index of the item to display in the `GestureViewer` when the component is mounted.
|
|
29
|
+
* @default 0
|
|
30
|
+
*/
|
|
31
|
+
initialIndex?: number;
|
|
32
|
+
/**
|
|
33
|
+
* A callback function that is called when the index of the item changes.
|
|
34
|
+
*/
|
|
35
|
+
onIndexChange?: (index: number) => void;
|
|
36
|
+
/**
|
|
37
|
+
* A callback function that is called when the `GestureViewer` is dismissed.
|
|
38
|
+
*/
|
|
39
|
+
onDismiss?: () => void;
|
|
40
|
+
/**
|
|
41
|
+
* A callback function that is called to render the item.
|
|
42
|
+
*/
|
|
43
|
+
renderItem: (item: T, index: number) => React.ReactElement;
|
|
44
|
+
/**
|
|
45
|
+
* A callback function that is called to render the container.
|
|
46
|
+
*/
|
|
47
|
+
renderContainer?: (children: React.ReactElement) => React.ReactElement;
|
|
48
|
+
/**
|
|
49
|
+
* Support for any list component like `ScrollView`, `FlatList`, `FlashList` through the `ListComponent` prop.
|
|
50
|
+
*/
|
|
51
|
+
ListComponent: LC;
|
|
52
|
+
/**
|
|
53
|
+
* 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.
|
|
55
|
+
* @default screen width
|
|
56
|
+
*/
|
|
57
|
+
width?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Enables snap scrolling mode.
|
|
60
|
+
*
|
|
61
|
+
* @remark
|
|
62
|
+
* **`false` (default)**: Paging mode (`pagingEnabled: true`)
|
|
63
|
+
* - Scrolls by full screen size increments
|
|
64
|
+
*
|
|
65
|
+
* **`true`**: Snap mode (`snapToInterval` auto-calculated)
|
|
66
|
+
* - `snapToInterval` is automatically calculated based on `width` and `itemSpacing` values
|
|
67
|
+
* - Use this option when you need item spacing
|
|
68
|
+
* @default false
|
|
69
|
+
*
|
|
70
|
+
*/
|
|
71
|
+
useSnap?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* `dismissThreshold` controls when `onDismiss` is called by applying a threshold value during vertical gestures.
|
|
74
|
+
* @default 80
|
|
75
|
+
*/
|
|
76
|
+
dismissThreshold?: number;
|
|
77
|
+
// swipeThreshold?: number;
|
|
78
|
+
// velocityThreshold?: number;
|
|
79
|
+
/**
|
|
80
|
+
* Calls `onDismiss` function when swiping down.
|
|
81
|
+
* @remark Useful for closing modals with downward swipe gestures.
|
|
82
|
+
* @default true
|
|
83
|
+
*/
|
|
84
|
+
enableDismissGesture?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Controls left/right swipe gestures.
|
|
87
|
+
* @remark When `false`, horizontal gestures are disabled.
|
|
88
|
+
* @default true
|
|
89
|
+
*/
|
|
90
|
+
enableSwipeGesture?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* `resistance` controls the range of vertical movement by applying resistance during vertical gestures.
|
|
93
|
+
* @default 2
|
|
94
|
+
*/
|
|
95
|
+
resistance?: number;
|
|
96
|
+
/**
|
|
97
|
+
* 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.
|
|
99
|
+
*/
|
|
100
|
+
listProps?: Partial<ConditionalListProps<LC>>;
|
|
101
|
+
/**
|
|
102
|
+
* The style of the backdrop.
|
|
103
|
+
*/
|
|
104
|
+
backdropStyle?: StyleProp<ViewStyle>;
|
|
105
|
+
/**
|
|
106
|
+
* The style of the container.
|
|
107
|
+
*/
|
|
108
|
+
containerStyle?: StyleProp<ViewStyle>;
|
|
109
|
+
/**
|
|
110
|
+
* By default, the background `opacity` gradually decreases from 1 to 0 during downward swipe gestures.
|
|
111
|
+
* @remark When `false`, this animation is disabled.
|
|
112
|
+
* @default true
|
|
113
|
+
*/
|
|
114
|
+
animateBackdrop?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Only works when zoom is active, allows moving item position when zoomed.
|
|
117
|
+
* @remark When `false`, gesture movement is disabled during zoom.
|
|
118
|
+
* @default true
|
|
119
|
+
*/
|
|
120
|
+
enableZoomPanGesture?: boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Controls two-finger pinch gestures.
|
|
123
|
+
* @remark When `false`, two-finger zoom gestures are disabled.
|
|
124
|
+
* @default true
|
|
125
|
+
*/
|
|
126
|
+
enableZoomGesture?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Controls double-tap zoom gestures.
|
|
129
|
+
* @remark When `false`, double-tap zoom gestures are disabled.
|
|
130
|
+
* @default true
|
|
131
|
+
*/
|
|
132
|
+
enableDoubleTapGesture?: boolean;
|
|
133
|
+
/**
|
|
134
|
+
* The maximum zoom scale.
|
|
135
|
+
* @default 2
|
|
136
|
+
*/
|
|
137
|
+
maxZoomScale?: number;
|
|
138
|
+
/**
|
|
139
|
+
* The spacing between items in pixels.
|
|
140
|
+
* @remark Only applied when `useSnap` is `true`.
|
|
141
|
+
* @default 0
|
|
142
|
+
*/
|
|
143
|
+
itemSpacing?: number;
|
|
144
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
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
|
+
|
|
23
|
+
type UseGestureViewerProps<T = any> = Omit<
|
|
24
|
+
GestureViewerProps<T>,
|
|
25
|
+
'renderItem' | 'renderContainer' | 'ListComponent' | 'listProps' | 'containerStyle' | 'backdropStyle'
|
|
26
|
+
>;
|
|
27
|
+
|
|
28
|
+
export const useGestureViewer = <T = any>({
|
|
29
|
+
data,
|
|
30
|
+
initialIndex = 0,
|
|
31
|
+
onIndexChange,
|
|
32
|
+
onDismiss,
|
|
33
|
+
width: customWidth,
|
|
34
|
+
dismissThreshold = 80,
|
|
35
|
+
resistance = 2,
|
|
36
|
+
// swipeThreshold = 0.5,
|
|
37
|
+
// velocityThreshold = 200,
|
|
38
|
+
animateBackdrop = true,
|
|
39
|
+
enableDismissGesture = true,
|
|
40
|
+
enableSwipeGesture = true,
|
|
41
|
+
enableZoomGesture = true,
|
|
42
|
+
enableDoubleTapGesture = true,
|
|
43
|
+
enableZoomPanGesture = true,
|
|
44
|
+
maxZoomScale = 2,
|
|
45
|
+
itemSpacing = 0,
|
|
46
|
+
useSnap = false,
|
|
47
|
+
id = 'default',
|
|
48
|
+
}: UseGestureViewerProps<T>) => {
|
|
49
|
+
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
|
50
|
+
const width = useSnap ? customWidth || screenWidth : screenWidth;
|
|
51
|
+
|
|
52
|
+
const [isZoomed, setIsZoomed] = useState(false);
|
|
53
|
+
|
|
54
|
+
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
55
|
+
const [manager, setManager] = useState<GestureViewerManager | null>(null);
|
|
56
|
+
|
|
57
|
+
const unsubscribeRef = useRef<(() => void) | null>(null);
|
|
58
|
+
|
|
59
|
+
const initialTranslateY = useSharedValue(0);
|
|
60
|
+
const initialTranslateX = useSharedValue(0);
|
|
61
|
+
const startScale = useSharedValue(1);
|
|
62
|
+
|
|
63
|
+
const translateY = useSharedValue(0);
|
|
64
|
+
const translateX = useSharedValue(0);
|
|
65
|
+
const scale = useSharedValue(1);
|
|
66
|
+
const backdropOpacity = useSharedValue(1);
|
|
67
|
+
|
|
68
|
+
const listRef = useRef<any>(null);
|
|
69
|
+
|
|
70
|
+
const dataLength = data?.length || 0;
|
|
71
|
+
|
|
72
|
+
const constrainToBounds = useCallback(
|
|
73
|
+
(translateX: number, translateY: number, scale: number) => {
|
|
74
|
+
'worklet';
|
|
75
|
+
if (scale <= 1) {
|
|
76
|
+
return {
|
|
77
|
+
x: translateX,
|
|
78
|
+
y: translateY,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const maxTranslateX = (width * scale - width) / 2;
|
|
83
|
+
const maxTranslateY = (screenHeight * scale - screenHeight) / 2;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
x: Math.max(-maxTranslateX, Math.min(maxTranslateX, translateX)),
|
|
87
|
+
y: Math.max(-maxTranslateY, Math.min(maxTranslateY, translateY)),
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
[width, screenHeight],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
useAnimatedReaction(
|
|
94
|
+
() => scale.value,
|
|
95
|
+
(currentScale) => {
|
|
96
|
+
runOnJS(setIsZoomed)(currentScale > 1);
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const handleManagerChange = (manager: GestureViewerManager | null) => {
|
|
102
|
+
unsubscribeRef.current?.();
|
|
103
|
+
unsubscribeRef.current = null;
|
|
104
|
+
|
|
105
|
+
setManager(manager);
|
|
106
|
+
|
|
107
|
+
if (manager) {
|
|
108
|
+
setCurrentIndex(manager.getState().currentIndex);
|
|
109
|
+
unsubscribeRef.current = manager.subscribe((state) => {
|
|
110
|
+
setCurrentIndex(state.currentIndex);
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setCurrentIndex(0);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const unsubscribeFromRegistry = registry.subscribeToManager(id, handleManagerChange);
|
|
119
|
+
|
|
120
|
+
return () => {
|
|
121
|
+
unsubscribeFromRegistry();
|
|
122
|
+
unsubscribeRef.current?.();
|
|
123
|
+
};
|
|
124
|
+
}, [id]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!manager) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
manager.setDataLength(dataLength);
|
|
132
|
+
manager.setEnableSwipeGesture(enableSwipeGesture);
|
|
133
|
+
manager.setCurrentIndex(initialIndex);
|
|
134
|
+
manager.setWidth(width + itemSpacing);
|
|
135
|
+
manager.notifyStateChange();
|
|
136
|
+
}, [dataLength, enableSwipeGesture, initialIndex, manager, width, itemSpacing]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!manager || !listRef.current) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
manager.setListRef(listRef.current);
|
|
144
|
+
}, [manager]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
onIndexChange?.(currentIndex);
|
|
148
|
+
}, [currentIndex, onIndexChange]);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
translateY.value = 0;
|
|
152
|
+
translateX.value = 0;
|
|
153
|
+
scale.value = 1;
|
|
154
|
+
backdropOpacity.value = 1;
|
|
155
|
+
startScale.value = 1;
|
|
156
|
+
|
|
157
|
+
if (initialIndex <= 0 || !listRef.current) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const runAfterInteractions = InteractionManager.runAfterInteractions(() => {
|
|
162
|
+
if (listRef.current.scrollToIndex) {
|
|
163
|
+
listRef.current.scrollToIndex({
|
|
164
|
+
index: initialIndex,
|
|
165
|
+
animated: false,
|
|
166
|
+
});
|
|
167
|
+
} else if (listRef.current.scrollTo) {
|
|
168
|
+
listRef.current.scrollTo({
|
|
169
|
+
x: initialIndex * (width + itemSpacing),
|
|
170
|
+
animated: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
runAfterInteractions?.cancel();
|
|
177
|
+
};
|
|
178
|
+
}, [initialIndex, translateY, backdropOpacity, translateX, scale, startScale, width, itemSpacing]);
|
|
179
|
+
|
|
180
|
+
const onMomentumScrollEnd = useCallback(
|
|
181
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
182
|
+
if (!enableSwipeGesture) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const contentOffset = event.nativeEvent.contentOffset;
|
|
187
|
+
const newIndex = Math.round(contentOffset.x / (width + itemSpacing));
|
|
188
|
+
|
|
189
|
+
if (newIndex !== currentIndex && newIndex >= 0 && newIndex < dataLength) {
|
|
190
|
+
if (manager) {
|
|
191
|
+
manager.setCurrentIndex(newIndex);
|
|
192
|
+
setCurrentIndex(newIndex);
|
|
193
|
+
manager.notifyStateChange();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
translateX.value = withTiming(0);
|
|
197
|
+
translateY.value = withTiming(0);
|
|
198
|
+
initialTranslateX.value = withTiming(0);
|
|
199
|
+
initialTranslateY.value = withTiming(0);
|
|
200
|
+
startScale.value = withTiming(1);
|
|
201
|
+
scale.value = withTiming(1);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[
|
|
205
|
+
manager,
|
|
206
|
+
currentIndex,
|
|
207
|
+
dataLength,
|
|
208
|
+
width,
|
|
209
|
+
itemSpacing,
|
|
210
|
+
enableSwipeGesture,
|
|
211
|
+
translateX,
|
|
212
|
+
translateY,
|
|
213
|
+
scale,
|
|
214
|
+
initialTranslateX,
|
|
215
|
+
initialTranslateY,
|
|
216
|
+
startScale,
|
|
217
|
+
],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const dismissGesture = useMemo(() => {
|
|
221
|
+
return Gesture.Pan()
|
|
222
|
+
.minDistance(10)
|
|
223
|
+
.averageTouches(true)
|
|
224
|
+
.activeCursor('grabbing')
|
|
225
|
+
.activeOffsetY([-10, 10])
|
|
226
|
+
.failOffsetX([-10, 10])
|
|
227
|
+
.enabled(!isZoomed)
|
|
228
|
+
.onUpdate((event) => {
|
|
229
|
+
translateY.value = event.translationY / resistance;
|
|
230
|
+
})
|
|
231
|
+
.onEnd((event) => {
|
|
232
|
+
if (event.translationY > dismissThreshold && enableDismissGesture && onDismiss) {
|
|
233
|
+
runOnJS(onDismiss)();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
translateY.value = withSpring(0, {
|
|
238
|
+
damping: 15,
|
|
239
|
+
stiffness: 150,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}, [translateY, dismissThreshold, enableDismissGesture, onDismiss, resistance, isZoomed]);
|
|
243
|
+
|
|
244
|
+
const zoomPinchGesture = useMemo(() => {
|
|
245
|
+
return Gesture.Pinch()
|
|
246
|
+
.enabled(enableZoomGesture)
|
|
247
|
+
.onBegin(() => {
|
|
248
|
+
startScale.value = scale.value;
|
|
249
|
+
initialTranslateX.value = translateX.value;
|
|
250
|
+
initialTranslateY.value = translateY.value;
|
|
251
|
+
})
|
|
252
|
+
.onUpdate((event) => {
|
|
253
|
+
const newScale = startScale.value * event.scale;
|
|
254
|
+
|
|
255
|
+
const deltaScale = newScale - startScale.value;
|
|
256
|
+
const centerX = event.focalX - width / 2;
|
|
257
|
+
const centerY = event.focalY - screenHeight / 2;
|
|
258
|
+
|
|
259
|
+
scale.value = newScale;
|
|
260
|
+
// NOTE 새로운 이동값 = 기존 이동값 - (중심점 거리 × 스케일 변화량) / 원래 스케일 (중심점이 화면 중심에서 멀수록, 확대 배율이 클수록 더 많이 이동)
|
|
261
|
+
const newTranslateX = initialTranslateX.value - (centerX * deltaScale) / startScale.value;
|
|
262
|
+
const newTranslateY = initialTranslateY.value - (centerY * deltaScale) / startScale.value;
|
|
263
|
+
|
|
264
|
+
const constrained = constrainToBounds(newTranslateX, newTranslateY, newScale);
|
|
265
|
+
|
|
266
|
+
if (newScale <= 1) {
|
|
267
|
+
translateX.value = withTiming(0);
|
|
268
|
+
translateY.value = withTiming(0);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
translateX.value = constrained.x;
|
|
273
|
+
translateY.value = constrained.y;
|
|
274
|
+
})
|
|
275
|
+
.onEnd(() => {
|
|
276
|
+
if (scale.value > maxZoomScale) {
|
|
277
|
+
scale.value = withTiming(maxZoomScale, {
|
|
278
|
+
duration: 300,
|
|
279
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const constrained = constrainToBounds(translateX.value, translateY.value, maxZoomScale);
|
|
283
|
+
|
|
284
|
+
translateX.value = withTiming(constrained.x);
|
|
285
|
+
translateY.value = withTiming(constrained.y);
|
|
286
|
+
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (scale.value < 1) {
|
|
291
|
+
scale.value = withTiming(1, {
|
|
292
|
+
duration: 300,
|
|
293
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
294
|
+
});
|
|
295
|
+
translateX.value = withTiming(0);
|
|
296
|
+
translateY.value = withTiming(0);
|
|
297
|
+
initialTranslateX.value = withTiming(0);
|
|
298
|
+
initialTranslateY.value = withTiming(0);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const finalConstrained = constrainToBounds(translateX.value, translateY.value, scale.value);
|
|
303
|
+
translateX.value = withTiming(finalConstrained.x);
|
|
304
|
+
translateY.value = withTiming(finalConstrained.y);
|
|
305
|
+
});
|
|
306
|
+
}, [
|
|
307
|
+
scale,
|
|
308
|
+
enableZoomGesture,
|
|
309
|
+
maxZoomScale,
|
|
310
|
+
translateX,
|
|
311
|
+
translateY,
|
|
312
|
+
startScale,
|
|
313
|
+
initialTranslateX,
|
|
314
|
+
initialTranslateY,
|
|
315
|
+
width,
|
|
316
|
+
screenHeight,
|
|
317
|
+
constrainToBounds,
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const zoomPanGesture = useMemo(() => {
|
|
321
|
+
return Gesture.Pan()
|
|
322
|
+
.enabled(enableZoomPanGesture && isZoomed)
|
|
323
|
+
.activeCursor('grabbing')
|
|
324
|
+
.averageTouches(true)
|
|
325
|
+
.onBegin(() => {
|
|
326
|
+
initialTranslateX.value = translateX.value;
|
|
327
|
+
initialTranslateY.value = translateY.value;
|
|
328
|
+
})
|
|
329
|
+
.onUpdate((event) => {
|
|
330
|
+
if (scale.value > 1) {
|
|
331
|
+
const newTranslateX = initialTranslateX.value + event.translationX;
|
|
332
|
+
const newTranslateY = initialTranslateY.value + event.translationY;
|
|
333
|
+
|
|
334
|
+
const constrained = constrainToBounds(newTranslateX, newTranslateY, scale.value);
|
|
335
|
+
|
|
336
|
+
translateX.value = constrained.x;
|
|
337
|
+
translateY.value = constrained.y;
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}, [
|
|
341
|
+
translateX,
|
|
342
|
+
translateY,
|
|
343
|
+
enableZoomPanGesture,
|
|
344
|
+
isZoomed,
|
|
345
|
+
scale,
|
|
346
|
+
initialTranslateX,
|
|
347
|
+
initialTranslateY,
|
|
348
|
+
constrainToBounds,
|
|
349
|
+
]);
|
|
350
|
+
|
|
351
|
+
const doubleTapGesture = useMemo(() => {
|
|
352
|
+
return Gesture.Tap()
|
|
353
|
+
.enabled(enableDoubleTapGesture)
|
|
354
|
+
.numberOfTaps(2)
|
|
355
|
+
.onEnd((event) => {
|
|
356
|
+
const nextScale = scale.value > 1 ? 1 : maxZoomScale;
|
|
357
|
+
|
|
358
|
+
if (nextScale > 1) {
|
|
359
|
+
const centerX = event.x - width / 2;
|
|
360
|
+
const centerY = event.y - screenHeight / 2;
|
|
361
|
+
|
|
362
|
+
// NOTE 확대로 밀려난 거리만큼 반대로 이동해서 탭 지점을 제자리에 유지
|
|
363
|
+
translateX.value = withTiming(-centerX * (nextScale - 1), {
|
|
364
|
+
duration: 300,
|
|
365
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
366
|
+
});
|
|
367
|
+
translateY.value = withTiming(-centerY * (nextScale - 1), {
|
|
368
|
+
duration: 300,
|
|
369
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
translateX.value = withTiming(0, {
|
|
373
|
+
duration: 300,
|
|
374
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
375
|
+
});
|
|
376
|
+
translateY.value = withTiming(0, {
|
|
377
|
+
duration: 300,
|
|
378
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
scale.value = withTiming(nextScale, {
|
|
383
|
+
duration: 300,
|
|
384
|
+
easing: Easing.bezier(0.25, 0.1, 0.25, 1.0),
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}, [scale, enableDoubleTapGesture, maxZoomScale, translateX, translateY, width, screenHeight]);
|
|
388
|
+
|
|
389
|
+
const zoomGesture = useMemo(() => {
|
|
390
|
+
return Gesture.Race(zoomPinchGesture, Gesture.Exclusive(zoomPanGesture, doubleTapGesture));
|
|
391
|
+
}, [zoomPinchGesture, zoomPanGesture, doubleTapGesture]);
|
|
392
|
+
|
|
393
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
394
|
+
return {
|
|
395
|
+
transform: [{ translateY: translateY.value }, { translateX: translateX.value }, { scale: scale.value }],
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const backdropStyle = useAnimatedStyle(() => {
|
|
400
|
+
if (!animateBackdrop || scale.value !== 1) {
|
|
401
|
+
return { opacity: 1 };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const opacity = interpolate(translateY.value, [0, 200], [1, 0], 'clamp');
|
|
405
|
+
|
|
406
|
+
return { opacity };
|
|
407
|
+
}, [animateBackdrop]);
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
currentIndex,
|
|
411
|
+
dataLength,
|
|
412
|
+
translateY,
|
|
413
|
+
listRef,
|
|
414
|
+
isZoomed,
|
|
415
|
+
|
|
416
|
+
dismissGesture,
|
|
417
|
+
zoomGesture,
|
|
418
|
+
|
|
419
|
+
onMomentumScrollEnd,
|
|
420
|
+
|
|
421
|
+
animatedStyle,
|
|
422
|
+
backdropStyle,
|
|
423
|
+
};
|
|
424
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
+
currentIndex: state.currentIndex,
|
|
46
|
+
totalCount: state.dataLength,
|
|
47
|
+
};
|
|
48
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
};
|