@momo-kits/tab-view 0.0.55-alpha.30
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/Pager.android.tsx +1 -0
- package/Pager.ios.tsx +1 -0
- package/Pager.tsx +1 -0
- package/PagerViewAdapter.tsx +160 -0
- package/PanResponderAdapter.tsx +331 -0
- package/PlatformPressable.tsx +45 -0
- package/SceneMap.tsx +25 -0
- package/SceneView.tsx +142 -0
- package/TabBar.tsx +559 -0
- package/TabBarIndicator.tsx +160 -0
- package/TabBarItem.tsx +296 -0
- package/TabView.tsx +156 -0
- package/index.tsx +15 -0
- package/package.json +19 -0
- package/publish.sh +29 -0
- package/types.tsx +58 -0
- package/useAnimatedValue.tsx +12 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './PagerViewAdapter';
|
package/Pager.ios.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './PagerViewAdapter';
|
package/Pager.tsx
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './PanResponderAdapter';
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Animated, Keyboard, StyleSheet } from 'react-native';
|
|
3
|
+
import ViewPager, {
|
|
4
|
+
PageScrollStateChangedNativeEvent,
|
|
5
|
+
} from 'react-native-pager-view';
|
|
6
|
+
import useAnimatedValue from './useAnimatedValue';
|
|
7
|
+
import type {
|
|
8
|
+
NavigationState,
|
|
9
|
+
Route,
|
|
10
|
+
Listener,
|
|
11
|
+
EventEmitterProps,
|
|
12
|
+
PagerProps,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
const AnimatedViewPager = Animated.createAnimatedComponent(ViewPager);
|
|
16
|
+
|
|
17
|
+
type Props<T extends Route> = PagerProps & {
|
|
18
|
+
onIndexChange: (index: number) => void;
|
|
19
|
+
navigationState: NavigationState<T>;
|
|
20
|
+
children: (
|
|
21
|
+
props: EventEmitterProps & {
|
|
22
|
+
// Animated value which represents the state of current index
|
|
23
|
+
// It can include fractional digits as it represents the intermediate value
|
|
24
|
+
position: Animated.AnimatedInterpolation;
|
|
25
|
+
// Function to actually render the content of the pager
|
|
26
|
+
// The parent component takes care of rendering
|
|
27
|
+
render: (children: React.ReactNode) => React.ReactNode;
|
|
28
|
+
// Callback to call when switching the tab
|
|
29
|
+
// The tab switch animation is performed even if the index in state is unchanged
|
|
30
|
+
jumpTo: (key: string) => void;
|
|
31
|
+
}
|
|
32
|
+
) => React.ReactElement;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export default function PagerViewAdapter<T extends Route>({
|
|
36
|
+
keyboardDismissMode = 'auto',
|
|
37
|
+
swipeEnabled = true,
|
|
38
|
+
navigationState,
|
|
39
|
+
onIndexChange,
|
|
40
|
+
onSwipeStart,
|
|
41
|
+
onSwipeEnd,
|
|
42
|
+
children,
|
|
43
|
+
style,
|
|
44
|
+
...rest
|
|
45
|
+
}: Props<T>) {
|
|
46
|
+
const { index } = navigationState;
|
|
47
|
+
|
|
48
|
+
const listenersRef = React.useRef<Listener[]>([]);
|
|
49
|
+
|
|
50
|
+
const pagerRef = React.useRef<ViewPager>();
|
|
51
|
+
const indexRef = React.useRef<number>(index);
|
|
52
|
+
const navigationStateRef = React.useRef(navigationState);
|
|
53
|
+
|
|
54
|
+
const position = useAnimatedValue(index);
|
|
55
|
+
const offset = useAnimatedValue(0);
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
navigationStateRef.current = navigationState;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const jumpTo = React.useCallback((key: string) => {
|
|
62
|
+
const index = navigationStateRef.current.routes.findIndex(
|
|
63
|
+
(route: { key: string }) => route.key === key
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
pagerRef.current?.setPage(index);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
React.useEffect(() => {
|
|
70
|
+
if (keyboardDismissMode === 'auto') {
|
|
71
|
+
Keyboard.dismiss();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (indexRef.current !== index) {
|
|
75
|
+
pagerRef.current?.setPage(index);
|
|
76
|
+
}
|
|
77
|
+
}, [keyboardDismissMode, index]);
|
|
78
|
+
|
|
79
|
+
const onPageScrollStateChanged = (
|
|
80
|
+
state: PageScrollStateChangedNativeEvent
|
|
81
|
+
) => {
|
|
82
|
+
const { pageScrollState } = state.nativeEvent;
|
|
83
|
+
|
|
84
|
+
switch (pageScrollState) {
|
|
85
|
+
case 'idle':
|
|
86
|
+
onSwipeEnd?.();
|
|
87
|
+
return;
|
|
88
|
+
case 'dragging': {
|
|
89
|
+
const subscription = offset.addListener(({ value }) => {
|
|
90
|
+
const next =
|
|
91
|
+
index + (value > 0 ? Math.ceil(value) : Math.floor(value));
|
|
92
|
+
|
|
93
|
+
if (next !== index) {
|
|
94
|
+
listenersRef.current.forEach((listener) => listener(next));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
offset.removeListener(subscription);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
onSwipeStart?.();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const addEnterListener = React.useCallback((listener: Listener) => {
|
|
107
|
+
listenersRef.current.push(listener);
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
const index = listenersRef.current.indexOf(listener);
|
|
111
|
+
|
|
112
|
+
if (index > -1) {
|
|
113
|
+
listenersRef.current.splice(index, 1);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}, []);
|
|
117
|
+
|
|
118
|
+
return children({
|
|
119
|
+
position: Animated.add(position, offset),
|
|
120
|
+
addEnterListener,
|
|
121
|
+
jumpTo,
|
|
122
|
+
render: (children) => (
|
|
123
|
+
<AnimatedViewPager
|
|
124
|
+
{...rest}
|
|
125
|
+
ref={pagerRef}
|
|
126
|
+
style={[styles.container, style]}
|
|
127
|
+
initialPage={index}
|
|
128
|
+
keyboardDismissMode={
|
|
129
|
+
keyboardDismissMode === 'auto' ? 'on-drag' : keyboardDismissMode
|
|
130
|
+
}
|
|
131
|
+
onPageScroll={Animated.event(
|
|
132
|
+
[
|
|
133
|
+
{
|
|
134
|
+
nativeEvent: {
|
|
135
|
+
position: position,
|
|
136
|
+
offset: offset,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
{ useNativeDriver: true }
|
|
141
|
+
)}
|
|
142
|
+
onPageSelected={(e) => {
|
|
143
|
+
const index = e.nativeEvent.position;
|
|
144
|
+
indexRef.current = index;
|
|
145
|
+
onIndexChange(index);
|
|
146
|
+
}}
|
|
147
|
+
onPageScrollStateChanged={onPageScrollStateChanged}
|
|
148
|
+
scrollEnabled={swipeEnabled}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</AnimatedViewPager>
|
|
152
|
+
),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const styles = StyleSheet.create({
|
|
157
|
+
container: {
|
|
158
|
+
flex: 1,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
PanResponder,
|
|
5
|
+
Keyboard,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
GestureResponderEvent,
|
|
8
|
+
PanResponderGestureState,
|
|
9
|
+
I18nManager,
|
|
10
|
+
View,
|
|
11
|
+
} from 'react-native';
|
|
12
|
+
import useAnimatedValue from './useAnimatedValue';
|
|
13
|
+
import type {
|
|
14
|
+
NavigationState,
|
|
15
|
+
Route,
|
|
16
|
+
Layout,
|
|
17
|
+
EventEmitterProps,
|
|
18
|
+
PagerProps,
|
|
19
|
+
Listener,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
type Props<T extends Route> = PagerProps & {
|
|
23
|
+
layout: Layout;
|
|
24
|
+
onIndexChange: (index: number) => void;
|
|
25
|
+
navigationState: NavigationState<T>;
|
|
26
|
+
children: (
|
|
27
|
+
props: EventEmitterProps & {
|
|
28
|
+
// Animated value which represents the state of current index
|
|
29
|
+
// It can include fractional digits as it represents the intermediate value
|
|
30
|
+
position: Animated.AnimatedInterpolation;
|
|
31
|
+
// Function to actually render the content of the pager
|
|
32
|
+
// The parent component takes care of rendering
|
|
33
|
+
render: (children: React.ReactNode) => React.ReactNode;
|
|
34
|
+
// Callback to call when switching the tab
|
|
35
|
+
// The tab switch animation is performed even if the index in state is unchanged
|
|
36
|
+
jumpTo: (key: string) => void;
|
|
37
|
+
},
|
|
38
|
+
) => React.ReactElement;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DEAD_ZONE = 12;
|
|
42
|
+
|
|
43
|
+
const DefaultTransitionSpec = {
|
|
44
|
+
timing: Animated.spring,
|
|
45
|
+
stiffness: 1000,
|
|
46
|
+
damping: 500,
|
|
47
|
+
mass: 3,
|
|
48
|
+
overshootClamping: true,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default function PanResponderAdapter<T extends Route>({
|
|
52
|
+
layout,
|
|
53
|
+
keyboardDismissMode = 'auto',
|
|
54
|
+
swipeEnabled = true,
|
|
55
|
+
navigationState,
|
|
56
|
+
onIndexChange,
|
|
57
|
+
onSwipeStart,
|
|
58
|
+
onSwipeEnd,
|
|
59
|
+
children,
|
|
60
|
+
style,
|
|
61
|
+
}: Props<T>) {
|
|
62
|
+
const { routes, index } = navigationState;
|
|
63
|
+
|
|
64
|
+
const panX = useAnimatedValue(0);
|
|
65
|
+
|
|
66
|
+
const listenersRef = React.useRef<Listener[]>([]);
|
|
67
|
+
|
|
68
|
+
const navigationStateRef = React.useRef(navigationState);
|
|
69
|
+
const layoutRef = React.useRef(layout);
|
|
70
|
+
const onIndexChangeRef = React.useRef(onIndexChange);
|
|
71
|
+
|
|
72
|
+
const currentIndexRef = React.useRef(index);
|
|
73
|
+
const pendingIndexRef = React.useRef<number>();
|
|
74
|
+
|
|
75
|
+
const swipeVelocityThreshold = 0.15;
|
|
76
|
+
const swipeDistanceThreshold = layout.width / 1.75;
|
|
77
|
+
|
|
78
|
+
const jumpToIndex = React.useCallback(
|
|
79
|
+
(index: number) => {
|
|
80
|
+
const offset = -index * layoutRef.current.width;
|
|
81
|
+
|
|
82
|
+
const { timing, ...transitionConfig } = DefaultTransitionSpec;
|
|
83
|
+
|
|
84
|
+
Animated.parallel([
|
|
85
|
+
timing(panX, {
|
|
86
|
+
...transitionConfig,
|
|
87
|
+
toValue: offset,
|
|
88
|
+
useNativeDriver: false,
|
|
89
|
+
}),
|
|
90
|
+
]).start(({ finished }) => {
|
|
91
|
+
if (finished) {
|
|
92
|
+
onIndexChangeRef.current(index);
|
|
93
|
+
pendingIndexRef.current = undefined;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pendingIndexRef.current = index;
|
|
98
|
+
},
|
|
99
|
+
[panX],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
navigationStateRef.current = navigationState;
|
|
104
|
+
layoutRef.current = layout;
|
|
105
|
+
onIndexChangeRef.current = onIndexChange;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
const offset = -navigationStateRef.current.index * layout.width;
|
|
110
|
+
|
|
111
|
+
panX.setValue(offset);
|
|
112
|
+
}, [layout.width, panX]);
|
|
113
|
+
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
if (keyboardDismissMode === 'auto') {
|
|
116
|
+
Keyboard.dismiss();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (layout.width && currentIndexRef.current !== index) {
|
|
120
|
+
currentIndexRef.current = index;
|
|
121
|
+
jumpToIndex(index);
|
|
122
|
+
}
|
|
123
|
+
}, [jumpToIndex, keyboardDismissMode, layout.width, index]);
|
|
124
|
+
|
|
125
|
+
const isMovingHorizontally = (
|
|
126
|
+
_: GestureResponderEvent,
|
|
127
|
+
gestureState: PanResponderGestureState,
|
|
128
|
+
) => {
|
|
129
|
+
return (
|
|
130
|
+
Math.abs(gestureState.dx) > Math.abs(gestureState.dy * 2) &&
|
|
131
|
+
Math.abs(gestureState.vx) > Math.abs(gestureState.vy * 2)
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const canMoveScreen = (
|
|
136
|
+
event: GestureResponderEvent,
|
|
137
|
+
gestureState: PanResponderGestureState,
|
|
138
|
+
) => {
|
|
139
|
+
if (swipeEnabled === false) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
isMovingHorizontally(event, gestureState) &&
|
|
147
|
+
((diffX >= DEAD_ZONE && currentIndexRef.current > 0) ||
|
|
148
|
+
(diffX <= -DEAD_ZONE &&
|
|
149
|
+
currentIndexRef.current < routes.length - 1))
|
|
150
|
+
);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const startGesture = () => {
|
|
154
|
+
onSwipeStart?.();
|
|
155
|
+
|
|
156
|
+
if (keyboardDismissMode === 'on-drag') {
|
|
157
|
+
Keyboard.dismiss();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
panX.stopAnimation();
|
|
161
|
+
// @ts-expect-error: _value is private, but docs use it as well
|
|
162
|
+
panX.setOffset(panX._value);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const respondToGesture = (
|
|
166
|
+
_: GestureResponderEvent,
|
|
167
|
+
gestureState: PanResponderGestureState,
|
|
168
|
+
) => {
|
|
169
|
+
const diffX = I18nManager.isRTL ? -gestureState.dx : gestureState.dx;
|
|
170
|
+
|
|
171
|
+
if (
|
|
172
|
+
// swiping left
|
|
173
|
+
(diffX > 0 && index <= 0) ||
|
|
174
|
+
// swiping right
|
|
175
|
+
(diffX < 0 && index >= routes.length - 1)
|
|
176
|
+
) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (layout.width) {
|
|
181
|
+
// @ts-expect-error: _offset is private, but docs use it as well
|
|
182
|
+
const position = (panX._offset + diffX) / -layout.width;
|
|
183
|
+
const next =
|
|
184
|
+
position > index ? Math.ceil(position) : Math.floor(position);
|
|
185
|
+
|
|
186
|
+
if (next !== index) {
|
|
187
|
+
listenersRef.current.forEach((listener) => listener(next));
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
panX.setValue(diffX);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const finishGesture = (
|
|
195
|
+
_: GestureResponderEvent,
|
|
196
|
+
gestureState: PanResponderGestureState,
|
|
197
|
+
) => {
|
|
198
|
+
panX.flattenOffset();
|
|
199
|
+
|
|
200
|
+
onSwipeEnd?.();
|
|
201
|
+
|
|
202
|
+
const currentIndex =
|
|
203
|
+
typeof pendingIndexRef.current === 'number'
|
|
204
|
+
? pendingIndexRef.current
|
|
205
|
+
: currentIndexRef.current;
|
|
206
|
+
|
|
207
|
+
let nextIndex = currentIndex;
|
|
208
|
+
|
|
209
|
+
if (
|
|
210
|
+
Math.abs(gestureState.dx) > Math.abs(gestureState.dy) &&
|
|
211
|
+
Math.abs(gestureState.vx) > Math.abs(gestureState.vy) &&
|
|
212
|
+
(Math.abs(gestureState.dx) > swipeDistanceThreshold ||
|
|
213
|
+
Math.abs(gestureState.vx) > swipeVelocityThreshold)
|
|
214
|
+
) {
|
|
215
|
+
nextIndex = Math.round(
|
|
216
|
+
Math.min(
|
|
217
|
+
Math.max(
|
|
218
|
+
0,
|
|
219
|
+
I18nManager.isRTL
|
|
220
|
+
? currentIndex +
|
|
221
|
+
gestureState.dx / Math.abs(gestureState.dx)
|
|
222
|
+
: currentIndex -
|
|
223
|
+
gestureState.dx / Math.abs(gestureState.dx),
|
|
224
|
+
),
|
|
225
|
+
routes.length - 1,
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
currentIndexRef.current = nextIndex;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isFinite(nextIndex)) {
|
|
233
|
+
nextIndex = currentIndex;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
jumpToIndex(nextIndex);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// TODO: use the listeners
|
|
240
|
+
const addEnterListener = React.useCallback((listener: Listener) => {
|
|
241
|
+
listenersRef.current.push(listener);
|
|
242
|
+
|
|
243
|
+
return () => {
|
|
244
|
+
const index = listenersRef.current.indexOf(listener);
|
|
245
|
+
|
|
246
|
+
if (index > -1) {
|
|
247
|
+
listenersRef.current.splice(index, 1);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}, []);
|
|
251
|
+
|
|
252
|
+
const jumpTo = React.useCallback(
|
|
253
|
+
(key: string) => {
|
|
254
|
+
const index = navigationStateRef.current.routes.findIndex(
|
|
255
|
+
(route: { key: string }) => route.key === key,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
jumpToIndex(index);
|
|
259
|
+
},
|
|
260
|
+
[jumpToIndex],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const panResponder = PanResponder.create({
|
|
264
|
+
onMoveShouldSetPanResponder: canMoveScreen,
|
|
265
|
+
onMoveShouldSetPanResponderCapture: canMoveScreen,
|
|
266
|
+
onPanResponderGrant: startGesture,
|
|
267
|
+
onPanResponderMove: respondToGesture,
|
|
268
|
+
onPanResponderTerminate: finishGesture,
|
|
269
|
+
onPanResponderRelease: finishGesture,
|
|
270
|
+
onPanResponderTerminationRequest: () => true,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const maxTranslate = layout.width * (routes.length - 1);
|
|
274
|
+
const translateX = Animated.multiply(
|
|
275
|
+
panX.interpolate({
|
|
276
|
+
inputRange: [-maxTranslate, 0],
|
|
277
|
+
outputRange: [-maxTranslate, 0],
|
|
278
|
+
extrapolate: 'clamp',
|
|
279
|
+
}),
|
|
280
|
+
I18nManager.isRTL ? -1 : 1,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return children({
|
|
284
|
+
position: layout.width
|
|
285
|
+
? Animated.divide(panX, -layout.width)
|
|
286
|
+
: new Animated.Value(index),
|
|
287
|
+
addEnterListener,
|
|
288
|
+
jumpTo,
|
|
289
|
+
render: (children) => (
|
|
290
|
+
<Animated.View
|
|
291
|
+
style={[
|
|
292
|
+
styles.sheet,
|
|
293
|
+
layout.width
|
|
294
|
+
? {
|
|
295
|
+
width: routes.length * layout.width,
|
|
296
|
+
transform: [{ translateX }],
|
|
297
|
+
}
|
|
298
|
+
: null,
|
|
299
|
+
style,
|
|
300
|
+
]}
|
|
301
|
+
{...panResponder.panHandlers}>
|
|
302
|
+
{React.Children.map(children, (child, i) => {
|
|
303
|
+
const route = routes[i];
|
|
304
|
+
const focused = i === index;
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<View
|
|
308
|
+
key={route.key}
|
|
309
|
+
style={
|
|
310
|
+
layout.width
|
|
311
|
+
? { width: layout.width }
|
|
312
|
+
: focused
|
|
313
|
+
? StyleSheet.absoluteFill
|
|
314
|
+
: null
|
|
315
|
+
}>
|
|
316
|
+
{focused || layout.width ? child : null}
|
|
317
|
+
</View>
|
|
318
|
+
);
|
|
319
|
+
})}
|
|
320
|
+
</Animated.View>
|
|
321
|
+
),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const styles = StyleSheet.create({
|
|
326
|
+
sheet: {
|
|
327
|
+
flex: 1,
|
|
328
|
+
flexDirection: 'row',
|
|
329
|
+
alignItems: 'stretch',
|
|
330
|
+
},
|
|
331
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { Platform, Pressable, PressableProps } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export type Props = PressableProps & {
|
|
5
|
+
pressColor?: string;
|
|
6
|
+
pressOpacity?: number;
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const ANDROID_VERSION_LOLLIPOP = 21;
|
|
11
|
+
const ANDROID_SUPPORTS_RIPPLE =
|
|
12
|
+
Platform.OS === 'android' && Platform.Version >= ANDROID_VERSION_LOLLIPOP;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* PlatformPressable provides an abstraction on top of TouchableNativeFeedback and
|
|
16
|
+
* TouchableOpacity to handle platform differences.
|
|
17
|
+
*
|
|
18
|
+
* On Android, you can pass the props of TouchableNativeFeedback.
|
|
19
|
+
* On other platforms, you can pass the props of TouchableOpacity.
|
|
20
|
+
*/
|
|
21
|
+
export default function PlatformPressable({
|
|
22
|
+
android_ripple,
|
|
23
|
+
pressColor = 'rgba(0, 0, 0, .32)',
|
|
24
|
+
pressOpacity,
|
|
25
|
+
style,
|
|
26
|
+
...rest
|
|
27
|
+
}: Props) {
|
|
28
|
+
return (
|
|
29
|
+
<Pressable
|
|
30
|
+
android_ripple={
|
|
31
|
+
ANDROID_SUPPORTS_RIPPLE
|
|
32
|
+
? { color: pressColor, ...android_ripple }
|
|
33
|
+
: undefined
|
|
34
|
+
}
|
|
35
|
+
style={({ pressed }) => [
|
|
36
|
+
{
|
|
37
|
+
opacity:
|
|
38
|
+
pressed && !ANDROID_SUPPORTS_RIPPLE ? pressOpacity : 1,
|
|
39
|
+
},
|
|
40
|
+
typeof style === 'function' ? style({ pressed }) : style,
|
|
41
|
+
]}
|
|
42
|
+
{...rest}
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
}
|
package/SceneMap.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { SceneRendererProps } from './types';
|
|
3
|
+
|
|
4
|
+
class SceneComponent<
|
|
5
|
+
T extends { component: React.ComponentType<any> }
|
|
6
|
+
> extends React.PureComponent<T> {
|
|
7
|
+
render() {
|
|
8
|
+
const { component, ...rest } = this.props;
|
|
9
|
+
return React.createElement(component, rest);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function SceneMap<T extends any>(scenes: {
|
|
14
|
+
[key: string]: React.ComponentType<T>;
|
|
15
|
+
}) {
|
|
16
|
+
return ({ route, jumpTo, position }: SceneRendererProps & { route: any }) => (
|
|
17
|
+
<SceneComponent
|
|
18
|
+
key={route.key}
|
|
19
|
+
component={scenes[route.key]}
|
|
20
|
+
route={route}
|
|
21
|
+
jumpTo={jumpTo}
|
|
22
|
+
position={position}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
package/SceneView.tsx
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, StyleSheet, StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
import type {
|
|
4
|
+
SceneRendererProps,
|
|
5
|
+
EventEmitterProps,
|
|
6
|
+
NavigationState,
|
|
7
|
+
Route,
|
|
8
|
+
} from './types';
|
|
9
|
+
|
|
10
|
+
type Props<T extends Route> = SceneRendererProps &
|
|
11
|
+
EventEmitterProps & {
|
|
12
|
+
navigationState: NavigationState<T>;
|
|
13
|
+
lazy: boolean;
|
|
14
|
+
lazyPreloadDistance: number;
|
|
15
|
+
index: number;
|
|
16
|
+
children: (props: { loading: boolean }) => React.ReactNode;
|
|
17
|
+
style?: StyleProp<ViewStyle>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type State = {
|
|
21
|
+
loading: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default class SceneView<T extends Route> extends React.Component<
|
|
25
|
+
Props<T>,
|
|
26
|
+
State
|
|
27
|
+
> {
|
|
28
|
+
static getDerivedStateFromProps(props: Props<Route>, state: State) {
|
|
29
|
+
if (
|
|
30
|
+
state.loading &&
|
|
31
|
+
Math.abs(props.navigationState.index - props.index) <=
|
|
32
|
+
props.lazyPreloadDistance
|
|
33
|
+
) {
|
|
34
|
+
// Always render the route when it becomes focused
|
|
35
|
+
return { loading: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
state = {
|
|
42
|
+
loading:
|
|
43
|
+
Math.abs(this.props.navigationState.index - this.props.index) >
|
|
44
|
+
this.props.lazyPreloadDistance,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
componentDidMount() {
|
|
48
|
+
if (this.props.lazy) {
|
|
49
|
+
// If lazy mode is enabled, listen to when we enter screens
|
|
50
|
+
this.unsubscribe = this.props.addEnterListener(this.handleEnter);
|
|
51
|
+
} else if (this.state.loading) {
|
|
52
|
+
// If lazy mode is not enabled, render the scene with a delay if not loaded already
|
|
53
|
+
// This improves the initial startup time as the scene is no longer blocking
|
|
54
|
+
this.timerHandler = setTimeout(
|
|
55
|
+
() => this.setState({ loading: false }),
|
|
56
|
+
0
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
componentDidUpdate(prevProps: Props<T>, prevState: State) {
|
|
62
|
+
if (
|
|
63
|
+
this.props.lazy !== prevProps.lazy ||
|
|
64
|
+
this.state.loading !== prevState.loading
|
|
65
|
+
) {
|
|
66
|
+
// We only need the listener if the tab hasn't loaded yet and lazy is enabled
|
|
67
|
+
if (this.props.lazy && this.state.loading) {
|
|
68
|
+
this.unsubscribe?.();
|
|
69
|
+
this.unsubscribe = this.props.addEnterListener(this.handleEnter);
|
|
70
|
+
} else {
|
|
71
|
+
this.unsubscribe?.();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
componentWillUnmount() {
|
|
77
|
+
this.unsubscribe?.();
|
|
78
|
+
|
|
79
|
+
if (this.timerHandler) {
|
|
80
|
+
clearTimeout(this.timerHandler);
|
|
81
|
+
this.timerHandler = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private timerHandler: NodeJS.Timeout | undefined;
|
|
86
|
+
|
|
87
|
+
private unsubscribe: (() => void) | null = null;
|
|
88
|
+
|
|
89
|
+
private handleEnter = (value: number) => {
|
|
90
|
+
const { index } = this.props;
|
|
91
|
+
|
|
92
|
+
// If we're entering the current route, we need to load it
|
|
93
|
+
if (value === index) {
|
|
94
|
+
this.setState((prevState) => {
|
|
95
|
+
if (prevState.loading) {
|
|
96
|
+
return { loading: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
render() {
|
|
105
|
+
const { navigationState, index, layout, style } = this.props;
|
|
106
|
+
const { loading } = this.state;
|
|
107
|
+
|
|
108
|
+
const focused = navigationState.index === index;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<View
|
|
112
|
+
accessibilityElementsHidden={!focused}
|
|
113
|
+
importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
|
|
114
|
+
style={[
|
|
115
|
+
styles.route,
|
|
116
|
+
// If we don't have the layout yet, make the focused screen fill the container
|
|
117
|
+
// This avoids delay before we are able to render pages side by side
|
|
118
|
+
layout.width
|
|
119
|
+
? { width: layout.width }
|
|
120
|
+
: focused
|
|
121
|
+
? StyleSheet.absoluteFill
|
|
122
|
+
: null,
|
|
123
|
+
style,
|
|
124
|
+
]}
|
|
125
|
+
>
|
|
126
|
+
{
|
|
127
|
+
// Only render the route only if it's either focused or layout is available
|
|
128
|
+
// When layout is not available, we must not render unfocused routes
|
|
129
|
+
// so that the focused route can fill the screen
|
|
130
|
+
focused || layout.width ? this.props.children({ loading }) : null
|
|
131
|
+
}
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const styles = StyleSheet.create({
|
|
138
|
+
route: {
|
|
139
|
+
flex: 1,
|
|
140
|
+
overflow: 'hidden',
|
|
141
|
+
},
|
|
142
|
+
});
|