react-native-tab-view 4.2.2 → 5.0.0-alpha.0
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/lib/module/Pager.android.js +1 -1
- package/lib/module/Pager.android.js.map +1 -1
- package/lib/module/Pager.ios.js +1 -1
- package/lib/module/Pager.ios.js.map +1 -1
- package/lib/module/PagerViewAdapter.js +3 -127
- package/lib/module/PagerViewAdapter.js.map +1 -1
- package/lib/module/PagerViewAdapter.native.js +130 -0
- package/lib/module/PagerViewAdapter.native.js.map +1 -0
- package/lib/module/PanResponderAdapter.js +43 -31
- package/lib/module/PanResponderAdapter.js.map +1 -1
- package/lib/module/PlatformPressable.js.map +1 -1
- package/lib/module/SceneView.js +22 -31
- package/lib/module/SceneView.js.map +1 -1
- package/lib/module/ScrollViewAdapter.js +210 -0
- package/lib/module/ScrollViewAdapter.js.map +1 -0
- package/lib/module/TabBar.js +98 -61
- package/lib/module/TabBar.js.map +1 -1
- package/lib/module/TabBarIndicator.js +6 -9
- package/lib/module/TabBarIndicator.js.map +1 -1
- package/lib/module/TabBarItem.js +4 -4
- package/lib/module/TabBarItem.js.map +1 -1
- package/lib/module/TabView.js +65 -84
- package/lib/module/TabView.js.map +1 -1
- package/lib/module/index.js +3 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/useMeasureLayout.js +36 -0
- package/lib/module/useMeasureLayout.js.map +1 -0
- package/lib/typescript/src/PagerViewAdapter.d.ts +3 -17
- package/lib/typescript/src/PagerViewAdapter.d.ts.map +1 -1
- package/lib/typescript/src/PagerViewAdapter.native.d.ts +6 -0
- package/lib/typescript/src/PagerViewAdapter.native.d.ts.map +1 -0
- package/lib/typescript/src/PanResponderAdapter.d.ts +3 -17
- package/lib/typescript/src/PanResponderAdapter.d.ts.map +1 -1
- package/lib/typescript/src/PlatformPressable.d.ts +2 -2
- package/lib/typescript/src/PlatformPressable.d.ts.map +1 -1
- package/lib/typescript/src/SceneView.d.ts +1 -1
- package/lib/typescript/src/SceneView.d.ts.map +1 -1
- package/lib/typescript/src/ScrollViewAdapter.d.ts +12 -0
- package/lib/typescript/src/ScrollViewAdapter.d.ts.map +1 -0
- package/lib/typescript/src/TabBar.d.ts +7 -7
- package/lib/typescript/src/TabBar.d.ts.map +1 -1
- package/lib/typescript/src/TabBarIndicator.d.ts +1 -1
- package/lib/typescript/src/TabBarIndicator.d.ts.map +1 -1
- package/lib/typescript/src/TabBarItem.d.ts +4 -4
- package/lib/typescript/src/TabBarItem.d.ts.map +1 -1
- package/lib/typescript/src/TabBarItemLabel.d.ts +2 -2
- package/lib/typescript/src/TabBarItemLabel.d.ts.map +1 -1
- package/lib/typescript/src/TabView.d.ts +5 -5
- package/lib/typescript/src/TabView.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +4 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +64 -8
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/useMeasureLayout.d.ts +5 -0
- package/lib/typescript/src/useMeasureLayout.d.ts.map +1 -0
- package/package.json +8 -8
- package/src/PagerViewAdapter.native.tsx +174 -0
- package/src/PagerViewAdapter.tsx +8 -181
- package/src/PanResponderAdapter.tsx +64 -77
- package/src/PlatformPressable.tsx +2 -1
- package/src/SceneView.tsx +23 -45
- package/src/ScrollViewAdapter.tsx +268 -0
- package/src/TabBar.tsx +134 -133
- package/src/TabBarIndicator.tsx +7 -11
- package/src/TabBarItem.tsx +8 -6
- package/src/TabBarItemLabel.tsx +2 -2
- package/src/TabView.tsx +79 -100
- package/src/index.tsx +10 -0
- package/src/types.tsx +75 -17
- package/src/useMeasureLayout.tsx +34 -0
|
@@ -10,35 +10,11 @@ import {
|
|
|
10
10
|
} from 'react-native';
|
|
11
11
|
import useLatestCallback from 'use-latest-callback';
|
|
12
12
|
|
|
13
|
-
import type {
|
|
14
|
-
EventEmitterProps,
|
|
15
|
-
Layout,
|
|
16
|
-
Listener,
|
|
17
|
-
NavigationState,
|
|
18
|
-
PagerProps,
|
|
19
|
-
Route,
|
|
20
|
-
} from './types';
|
|
13
|
+
import type { AdapterProps, Listener } from './types';
|
|
21
14
|
import { useAnimatedValue } from './useAnimatedValue';
|
|
15
|
+
import { useMeasureLayout } from './useMeasureLayout';
|
|
22
16
|
|
|
23
|
-
type
|
|
24
|
-
layout: Layout;
|
|
25
|
-
onIndexChange: (index: number) => void;
|
|
26
|
-
onTabSelect?: (props: { index: number }) => void;
|
|
27
|
-
navigationState: NavigationState<T>;
|
|
28
|
-
children: (
|
|
29
|
-
props: EventEmitterProps & {
|
|
30
|
-
// Animated value which represents the state of current index
|
|
31
|
-
// It can include fractional digits as it represents the intermediate value
|
|
32
|
-
position: Animated.AnimatedInterpolation<number>;
|
|
33
|
-
// Function to actually render the content of the pager
|
|
34
|
-
// The parent component takes care of rendering
|
|
35
|
-
render: (children: React.ReactNode) => React.ReactNode;
|
|
36
|
-
// Callback to call when switching the tab
|
|
37
|
-
// The tab switch animation is performed even if the index in state is unchanged
|
|
38
|
-
jumpTo: (key: string) => void;
|
|
39
|
-
}
|
|
40
|
-
) => React.ReactElement;
|
|
41
|
-
};
|
|
17
|
+
export type PanResponderAdapterProps = AdapterProps;
|
|
42
18
|
|
|
43
19
|
const DEAD_ZONE = 12;
|
|
44
20
|
|
|
@@ -50,9 +26,8 @@ const DefaultTransitionSpec = {
|
|
|
50
26
|
overshootClamping: true,
|
|
51
27
|
};
|
|
52
28
|
|
|
53
|
-
export function PanResponderAdapter
|
|
54
|
-
|
|
55
|
-
keyboardDismissMode = 'auto',
|
|
29
|
+
export function PanResponderAdapter({
|
|
30
|
+
keyboardDismissMode,
|
|
56
31
|
swipeEnabled = true,
|
|
57
32
|
navigationState,
|
|
58
33
|
onIndexChange,
|
|
@@ -63,15 +38,17 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
63
38
|
style,
|
|
64
39
|
animationEnabled = false,
|
|
65
40
|
layoutDirection = 'ltr',
|
|
66
|
-
}:
|
|
41
|
+
}: PanResponderAdapterProps) {
|
|
67
42
|
const { routes, index } = navigationState;
|
|
68
43
|
|
|
44
|
+
const containerRef = React.useRef<View>(null);
|
|
45
|
+
const [layout, onLayout] = useMeasureLayout(containerRef);
|
|
46
|
+
|
|
69
47
|
const panX = useAnimatedValue(0);
|
|
70
48
|
|
|
71
|
-
const
|
|
49
|
+
const listeners = React.useRef<Set<Listener>>(new Set()).current;
|
|
72
50
|
|
|
73
51
|
const navigationStateRef = React.useRef(navigationState);
|
|
74
|
-
const layoutRef = React.useRef(layout);
|
|
75
52
|
const onIndexChangeRef = React.useRef(onIndexChange);
|
|
76
53
|
const onTabSelectRef = React.useRef(onTabSelect);
|
|
77
54
|
const currentIndexRef = React.useRef(index);
|
|
@@ -82,7 +59,7 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
82
59
|
|
|
83
60
|
const jumpToIndex = useLatestCallback(
|
|
84
61
|
(index: number, animate = animationEnabled) => {
|
|
85
|
-
const offset = -index *
|
|
62
|
+
const offset = -index * layout.width;
|
|
86
63
|
|
|
87
64
|
const { timing, ...transitionConfig } = DefaultTransitionSpec;
|
|
88
65
|
|
|
@@ -112,7 +89,6 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
112
89
|
|
|
113
90
|
React.useEffect(() => {
|
|
114
91
|
navigationStateRef.current = navigationState;
|
|
115
|
-
layoutRef.current = layout;
|
|
116
92
|
onIndexChangeRef.current = onIndexChange;
|
|
117
93
|
onTabSelectRef.current = onTabSelect;
|
|
118
94
|
});
|
|
@@ -197,7 +173,12 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
197
173
|
position > index ? Math.ceil(position) : Math.floor(position);
|
|
198
174
|
|
|
199
175
|
if (next !== index) {
|
|
200
|
-
|
|
176
|
+
listeners.forEach((listener) =>
|
|
177
|
+
listener({
|
|
178
|
+
type: 'enter',
|
|
179
|
+
index: next,
|
|
180
|
+
})
|
|
181
|
+
);
|
|
201
182
|
}
|
|
202
183
|
}
|
|
203
184
|
|
|
@@ -247,15 +228,11 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
247
228
|
jumpToIndex(nextIndex, true);
|
|
248
229
|
};
|
|
249
230
|
|
|
250
|
-
const
|
|
251
|
-
|
|
231
|
+
const subscribe = useLatestCallback((listener: Listener) => {
|
|
232
|
+
listeners.add(listener);
|
|
252
233
|
|
|
253
234
|
return () => {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (index > -1) {
|
|
257
|
-
listenersRef.current.splice(index, 1);
|
|
258
|
-
}
|
|
235
|
+
listeners.delete(listener);
|
|
259
236
|
};
|
|
260
237
|
});
|
|
261
238
|
|
|
@@ -295,47 +272,57 @@ export function PanResponderAdapter<T extends Route>({
|
|
|
295
272
|
|
|
296
273
|
return children({
|
|
297
274
|
position: position ?? new Animated.Value(index),
|
|
298
|
-
|
|
275
|
+
subscribe,
|
|
299
276
|
jumpTo,
|
|
300
277
|
render: (children) => (
|
|
301
|
-
<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
278
|
+
<View ref={containerRef} onLayout={onLayout} style={styles.container}>
|
|
279
|
+
<Animated.View
|
|
280
|
+
style={[
|
|
281
|
+
styles.sheet,
|
|
282
|
+
layout.width
|
|
283
|
+
? {
|
|
284
|
+
width: routes.length * layout.width,
|
|
285
|
+
transform: [{ translateX }],
|
|
286
|
+
}
|
|
287
|
+
: null,
|
|
288
|
+
style,
|
|
289
|
+
]}
|
|
290
|
+
{...panResponder.panHandlers}
|
|
291
|
+
>
|
|
292
|
+
{children.map((child, i) => {
|
|
293
|
+
const route = routes[i];
|
|
294
|
+
const focused = i === index;
|
|
295
|
+
|
|
296
|
+
if (!layout.width && !focused) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<View
|
|
302
|
+
key={route.key}
|
|
303
|
+
style={
|
|
304
|
+
layout.width
|
|
305
|
+
? { width: layout.width }
|
|
306
|
+
: focused
|
|
307
|
+
? StyleSheet.absoluteFill
|
|
308
|
+
: null
|
|
309
|
+
}
|
|
310
|
+
>
|
|
311
|
+
{child}
|
|
312
|
+
</View>
|
|
313
|
+
);
|
|
314
|
+
})}
|
|
315
|
+
</Animated.View>
|
|
316
|
+
</View>
|
|
334
317
|
),
|
|
335
318
|
});
|
|
336
319
|
}
|
|
337
320
|
|
|
338
321
|
const styles = StyleSheet.create({
|
|
322
|
+
container: {
|
|
323
|
+
flex: 1,
|
|
324
|
+
overflow: 'hidden',
|
|
325
|
+
},
|
|
339
326
|
sheet: {
|
|
340
327
|
flex: 1,
|
|
341
328
|
flexDirection: 'row',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import {
|
|
3
|
+
type ColorValue,
|
|
3
4
|
type GestureResponderEvent,
|
|
4
5
|
Platform,
|
|
5
6
|
Pressable,
|
|
@@ -8,7 +9,7 @@ import {
|
|
|
8
9
|
|
|
9
10
|
export type Props = Omit<PressableProps, 'onPress'> & {
|
|
10
11
|
href?: string;
|
|
11
|
-
pressColor?:
|
|
12
|
+
pressColor?: ColorValue;
|
|
12
13
|
pressOpacity?: number;
|
|
13
14
|
onPress?: (
|
|
14
15
|
e: React.MouseEvent<HTMLAnchorElement, MouseEvent> | GestureResponderEvent
|
package/src/SceneView.tsx
CHANGED
|
@@ -22,43 +22,41 @@ export function SceneView<T extends Route>({
|
|
|
22
22
|
children,
|
|
23
23
|
navigationState,
|
|
24
24
|
lazy,
|
|
25
|
-
layout,
|
|
26
25
|
index,
|
|
27
26
|
lazyPreloadDistance,
|
|
28
|
-
|
|
27
|
+
subscribe,
|
|
29
28
|
style,
|
|
30
29
|
}: Props<T>) {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
const isFocused = navigationState.index === index;
|
|
31
|
+
const isLoaded =
|
|
32
|
+
isFocused || Math.abs(navigationState.index - index) <= lazyPreloadDistance;
|
|
33
|
+
|
|
34
|
+
const [isLoading, setIsLoading] = React.useState(!isLoaded);
|
|
34
35
|
|
|
35
|
-
if (
|
|
36
|
-
isLoading &&
|
|
37
|
-
Math.abs(navigationState.index - index) <= lazyPreloadDistance
|
|
38
|
-
) {
|
|
36
|
+
if (isLoading && isLoaded) {
|
|
39
37
|
// Always render the route when it becomes focused
|
|
38
|
+
// Or close to the focused route based on preload distance
|
|
40
39
|
setIsLoading(false);
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
React.useEffect(() => {
|
|
44
|
-
const handleEnter = (value: number) => {
|
|
45
|
-
// If we're entering the current route, we need to load it
|
|
46
|
-
if (value === index) {
|
|
47
|
-
setIsLoading((prevState) => {
|
|
48
|
-
if (prevState) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
return prevState;
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
};
|
|
55
|
-
|
|
56
43
|
let unsubscribe: (() => void) | undefined;
|
|
57
44
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
58
45
|
|
|
59
46
|
if (lazy && isLoading) {
|
|
60
47
|
// If lazy mode is enabled, listen to when we enter screens
|
|
61
|
-
unsubscribe =
|
|
48
|
+
unsubscribe = subscribe((event) => {
|
|
49
|
+
// If we're entering the current route, we need to load it
|
|
50
|
+
if (event.type === 'enter' && event.index === index) {
|
|
51
|
+
setIsLoading((prevState) => {
|
|
52
|
+
if (prevState) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return prevState;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
62
60
|
} else if (isLoading) {
|
|
63
61
|
// If lazy mode is not enabled, render the scene with a delay if not loaded already
|
|
64
62
|
// This improves the initial startup time as the scene is no longer blocking
|
|
@@ -69,31 +67,11 @@ export function SceneView<T extends Route>({
|
|
|
69
67
|
unsubscribe?.();
|
|
70
68
|
clearTimeout(timer);
|
|
71
69
|
};
|
|
72
|
-
}, [
|
|
73
|
-
|
|
74
|
-
const focused = navigationState.index === index;
|
|
70
|
+
}, [subscribe, index, isLoading, lazy]);
|
|
75
71
|
|
|
76
72
|
return (
|
|
77
|
-
<View
|
|
78
|
-
|
|
79
|
-
style={[
|
|
80
|
-
styles.route,
|
|
81
|
-
// If we don't have the layout yet, make the focused screen fill the container
|
|
82
|
-
// This avoids delay before we are able to render pages side by side
|
|
83
|
-
layout.width
|
|
84
|
-
? { width: layout.width }
|
|
85
|
-
: focused
|
|
86
|
-
? StyleSheet.absoluteFill
|
|
87
|
-
: null,
|
|
88
|
-
style,
|
|
89
|
-
]}
|
|
90
|
-
>
|
|
91
|
-
{
|
|
92
|
-
// Only render the route only if it's either focused or layout is available
|
|
93
|
-
// When layout is not available, we must not render unfocused routes
|
|
94
|
-
// so that the focused route can fill the screen
|
|
95
|
-
focused || layout.width ? children({ loading: isLoading }) : null
|
|
96
|
-
}
|
|
73
|
+
<View aria-hidden={!isFocused} style={[styles.route, style]}>
|
|
74
|
+
{children({ loading: isLoading })}
|
|
97
75
|
</View>
|
|
98
76
|
);
|
|
99
77
|
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Animated,
|
|
4
|
+
Keyboard,
|
|
5
|
+
Platform,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
View,
|
|
9
|
+
type ViewProps,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import useLatestCallback from 'use-latest-callback';
|
|
12
|
+
|
|
13
|
+
import type { AdapterProps, Listener } from './types';
|
|
14
|
+
import { useMeasureLayout } from './useMeasureLayout';
|
|
15
|
+
|
|
16
|
+
export type ScrollViewAdapterProps = AdapterProps &
|
|
17
|
+
Omit<ViewProps, 'children'> & {
|
|
18
|
+
decelerationRate?: 'fast' | 'normal';
|
|
19
|
+
keyboardShouldPersistTaps?: 'always' | 'never' | 'handled';
|
|
20
|
+
bounces?: boolean;
|
|
21
|
+
overScrollMode?: 'always' | 'never' | 'auto';
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type ScrollEvent = Parameters<
|
|
25
|
+
NonNullable<React.ComponentProps<typeof Animated.ScrollView>['onScroll']>
|
|
26
|
+
>[0];
|
|
27
|
+
|
|
28
|
+
export function ScrollViewAdapter({
|
|
29
|
+
keyboardDismissMode,
|
|
30
|
+
swipeEnabled = true,
|
|
31
|
+
navigationState,
|
|
32
|
+
onIndexChange,
|
|
33
|
+
onTabSelect,
|
|
34
|
+
onSwipeStart,
|
|
35
|
+
onSwipeEnd,
|
|
36
|
+
children,
|
|
37
|
+
style,
|
|
38
|
+
animationEnabled = true,
|
|
39
|
+
layoutDirection: _, // Not supported in ScrollViewAdapter
|
|
40
|
+
decelerationRate = 'fast',
|
|
41
|
+
bounces = false,
|
|
42
|
+
overScrollMode = 'never',
|
|
43
|
+
keyboardShouldPersistTaps = 'always',
|
|
44
|
+
...rest
|
|
45
|
+
}: ScrollViewAdapterProps) {
|
|
46
|
+
const { index, routes } = navigationState;
|
|
47
|
+
|
|
48
|
+
const listeners = React.useRef<Set<Listener>>(new Set()).current;
|
|
49
|
+
|
|
50
|
+
const scrollViewRef = React.useRef<ScrollView>(null);
|
|
51
|
+
const containerRef = React.useRef<View>(null);
|
|
52
|
+
|
|
53
|
+
const isInitialRef = React.useRef(true);
|
|
54
|
+
|
|
55
|
+
const [layout, onLayout] = useMeasureLayout(containerRef, ({ width }) => {
|
|
56
|
+
if (isInitialRef.current) {
|
|
57
|
+
const x = index * width;
|
|
58
|
+
|
|
59
|
+
setContentOffset({ x, y: 0 });
|
|
60
|
+
|
|
61
|
+
offsetX.setValue(x);
|
|
62
|
+
} else if (indexRef.current !== index) {
|
|
63
|
+
scrollToIndex(index);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
isInitialRef.current = false;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const [contentOffset, setContentOffset] = React.useState(() => ({
|
|
70
|
+
x: index * layout.width,
|
|
71
|
+
y: 0,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
React.useEffect(() => {
|
|
75
|
+
// FIXME: contentOffset is not supported on Android
|
|
76
|
+
// So we manually scroll after state update
|
|
77
|
+
if (Platform.OS === 'android') {
|
|
78
|
+
requestAnimationFrame(() => {
|
|
79
|
+
scrollViewRef.current?.scrollTo({
|
|
80
|
+
x: contentOffset.x,
|
|
81
|
+
animated: false,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}, [animationEnabled, contentOffset.x]);
|
|
86
|
+
|
|
87
|
+
const [offsetX] = React.useState(() => new Animated.Value(contentOffset.x));
|
|
88
|
+
|
|
89
|
+
const scrollToIndex = useLatestCallback((index: number) => {
|
|
90
|
+
scrollViewRef.current?.scrollTo({
|
|
91
|
+
x: index * layout.width,
|
|
92
|
+
animated: animationEnabled,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const jumpTo = useLatestCallback((key: string) => {
|
|
97
|
+
const i = routes.findIndex((route) => route.key === key);
|
|
98
|
+
|
|
99
|
+
scrollToIndex(i);
|
|
100
|
+
|
|
101
|
+
if (keyboardDismissMode === 'auto') {
|
|
102
|
+
Keyboard.dismiss();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const handleSwipeStart = React.useCallback(() => {
|
|
107
|
+
onSwipeStart?.();
|
|
108
|
+
}, [onSwipeStart]);
|
|
109
|
+
|
|
110
|
+
const handleSwipeEnd = React.useCallback(() => {
|
|
111
|
+
onSwipeEnd?.();
|
|
112
|
+
}, [onSwipeEnd]);
|
|
113
|
+
|
|
114
|
+
const subscribe = useLatestCallback((listener: Listener) => {
|
|
115
|
+
listeners.add(listener);
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
listeners.delete(listener);
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const position = React.useMemo(
|
|
123
|
+
() => (layout.width ? Animated.divide(offsetX, layout.width) : null),
|
|
124
|
+
[layout.width, offsetX]
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const indexRef = React.useRef(index);
|
|
128
|
+
const timerRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
|
|
129
|
+
|
|
130
|
+
const onScrollEnd = (x: number) => {
|
|
131
|
+
const value = clamp(x / layout.width, 0, routes.length - 1);
|
|
132
|
+
|
|
133
|
+
if (value % 1 === 0) {
|
|
134
|
+
indexRef.current = value;
|
|
135
|
+
|
|
136
|
+
if (value !== index) {
|
|
137
|
+
onIndexChange(value);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
onTabSelect?.({ index: value });
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const onScroll = Animated.event(
|
|
145
|
+
[
|
|
146
|
+
{
|
|
147
|
+
nativeEvent: {
|
|
148
|
+
contentOffset: { x: offsetX },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
{
|
|
153
|
+
useNativeDriver: true,
|
|
154
|
+
listener: (event: ScrollEvent) => {
|
|
155
|
+
const { x } = event.nativeEvent.contentOffset;
|
|
156
|
+
|
|
157
|
+
const value = clamp(x / layout.width, 0, routes.length - 1);
|
|
158
|
+
|
|
159
|
+
// The offset will overlap the current and the adjacent page
|
|
160
|
+
// So we need to get the index of the adjacent page
|
|
161
|
+
const next = [Math.ceil(value), Math.floor(value)].find(
|
|
162
|
+
(i) => i !== index
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (next != null) {
|
|
166
|
+
listeners.forEach((listener) =>
|
|
167
|
+
listener({
|
|
168
|
+
type: 'enter',
|
|
169
|
+
index: next,
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// FIXME: onMomentumScrollEnd is not supported on Web
|
|
175
|
+
// So we workaround by using a timer
|
|
176
|
+
if (Platform.OS === 'web') {
|
|
177
|
+
clearTimeout(timerRef.current);
|
|
178
|
+
|
|
179
|
+
timerRef.current = setTimeout(() => {
|
|
180
|
+
onScrollEnd(x);
|
|
181
|
+
}, 100);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const onMomentumScrollEnd = (event: ScrollEvent) => {
|
|
188
|
+
onScrollEnd(event.nativeEvent.contentOffset.x);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return children({
|
|
192
|
+
position: position ?? new Animated.Value(index),
|
|
193
|
+
subscribe,
|
|
194
|
+
jumpTo,
|
|
195
|
+
render: (children) => (
|
|
196
|
+
<View ref={containerRef} onLayout={onLayout} style={styles.container}>
|
|
197
|
+
<Animated.ScrollView
|
|
198
|
+
{...rest}
|
|
199
|
+
ref={scrollViewRef}
|
|
200
|
+
horizontal
|
|
201
|
+
pagingEnabled
|
|
202
|
+
directionalLockEnabled
|
|
203
|
+
decelerationRate={decelerationRate}
|
|
204
|
+
bounces={bounces}
|
|
205
|
+
overScrollMode={overScrollMode}
|
|
206
|
+
keyboardShouldPersistTaps={keyboardShouldPersistTaps}
|
|
207
|
+
keyboardDismissMode={
|
|
208
|
+
keyboardDismissMode === 'auto' ? 'on-drag' : keyboardDismissMode
|
|
209
|
+
}
|
|
210
|
+
scrollEnabled={swipeEnabled && Boolean(layout.width)}
|
|
211
|
+
scrollToOverflowEnabled={false}
|
|
212
|
+
scrollsToTop={false}
|
|
213
|
+
automaticallyAdjustContentInsets={false}
|
|
214
|
+
showsHorizontalScrollIndicator={false}
|
|
215
|
+
scrollEventThrottle={1}
|
|
216
|
+
onScroll={onScroll}
|
|
217
|
+
onScrollBeginDrag={handleSwipeStart}
|
|
218
|
+
onScrollEndDrag={handleSwipeEnd}
|
|
219
|
+
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
220
|
+
contentOffset={contentOffset}
|
|
221
|
+
contentContainerStyle={{
|
|
222
|
+
width: layout.width ? `${routes.length * 100}%` : '100%',
|
|
223
|
+
}}
|
|
224
|
+
style={[styles.scroll, style]}
|
|
225
|
+
>
|
|
226
|
+
{children
|
|
227
|
+
.map((child, i) => {
|
|
228
|
+
const route = routes[i];
|
|
229
|
+
const focused = i === index;
|
|
230
|
+
|
|
231
|
+
if (!layout.width && !focused) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<View
|
|
237
|
+
key={route.key}
|
|
238
|
+
style={
|
|
239
|
+
layout.width
|
|
240
|
+
? // FIXME: percentage width doesn't work on web
|
|
241
|
+
// So we use a fixed width instead
|
|
242
|
+
{ width: layout.width }
|
|
243
|
+
: { width: '100%' }
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
{child}
|
|
247
|
+
</View>
|
|
248
|
+
);
|
|
249
|
+
})
|
|
250
|
+
.filter((child) => child !== null)}
|
|
251
|
+
</Animated.ScrollView>
|
|
252
|
+
</View>
|
|
253
|
+
),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
258
|
+
Math.max(min, Math.min(max, value));
|
|
259
|
+
|
|
260
|
+
const styles = StyleSheet.create({
|
|
261
|
+
container: {
|
|
262
|
+
flex: 1,
|
|
263
|
+
overflow: 'hidden',
|
|
264
|
+
},
|
|
265
|
+
scroll: {
|
|
266
|
+
flex: 1,
|
|
267
|
+
},
|
|
268
|
+
});
|