@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.
@@ -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
+ });