@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/TabBar.tsx ADDED
@@ -0,0 +1,559 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Animated,
4
+ StyleSheet,
5
+ View,
6
+ StyleProp,
7
+ ViewStyle,
8
+ TextStyle,
9
+ LayoutChangeEvent,
10
+ I18nManager,
11
+ Platform,
12
+ FlatList,
13
+ ListRenderItemInfo,
14
+ } from 'react-native';
15
+ import TabBarItem, { Props as TabBarItemProps } from './TabBarItem';
16
+ import TabBarIndicator, { Props as IndicatorProps } from './TabBarIndicator';
17
+ import type {
18
+ Route,
19
+ Scene,
20
+ SceneRendererProps,
21
+ NavigationState,
22
+ Layout,
23
+ Event,
24
+ } from './types';
25
+ import { Colors, Spacing } from '@momo-kits/v2-core';
26
+
27
+ export type Props<T extends Route> = SceneRendererProps & {
28
+ navigationState: NavigationState<T>;
29
+ scrollEnabled?: boolean;
30
+ bounces?: boolean;
31
+ activeColor?: string;
32
+ inactiveColor?: string;
33
+ activeStyle?: StyleProp<TextStyle>;
34
+ inactiveStyle?: StyleProp<TextStyle>;
35
+ pressColor?: string;
36
+ pressOpacity?: number;
37
+ getLabelText: (scene: Scene<T>) => string | undefined;
38
+ getAccessible: (scene: Scene<T>) => boolean | undefined;
39
+ getAccessibilityLabel: (scene: Scene<T>) => string | undefined;
40
+ getTestID: (scene: Scene<T>) => string | undefined;
41
+ renderLabel?: (
42
+ scene: Scene<T> & {
43
+ focused: boolean;
44
+ color: string;
45
+ },
46
+ ) => React.ReactNode;
47
+ renderIcon?: (
48
+ scene: Scene<T> & {
49
+ focused: boolean;
50
+ color: string;
51
+ },
52
+ ) => React.ReactNode;
53
+ renderBadge?: (scene: Scene<T>) => React.ReactNode;
54
+ renderIndicator: (props: IndicatorProps<T>) => React.ReactNode;
55
+ renderTabBarItem?: (
56
+ props: TabBarItemProps<T> & { key: string },
57
+ ) => React.ReactElement;
58
+ onTabPress?: (scene: Scene<T> & Event) => void;
59
+ onTabLongPress?: (scene: Scene<T>) => void;
60
+ tabStyle?: StyleProp<ViewStyle>;
61
+ indicatorStyle?: StyleProp<ViewStyle>;
62
+ indicatorContainerStyle?: StyleProp<ViewStyle>;
63
+ labelStyle?: StyleProp<TextStyle>;
64
+ contentContainerStyle?: StyleProp<ViewStyle>;
65
+ style?: StyleProp<ViewStyle>;
66
+ gap?: number;
67
+ };
68
+
69
+ type State = {
70
+ layout: Layout;
71
+ tabWidths: { [key: string]: number };
72
+ };
73
+
74
+ const Separator = ({ width }: { width: number }) => {
75
+ return <View style={{ width }} />;
76
+ };
77
+
78
+ export default class TabBar<T extends Route> extends React.Component<
79
+ Props<T>,
80
+ State
81
+ > {
82
+ static defaultProps = {
83
+ getLabelText: ({ route }: Scene<Route>) => route.title,
84
+ getAccessible: ({ route }: Scene<Route>) =>
85
+ typeof route.accessible !== 'undefined' ? route.accessible : true,
86
+ getAccessibilityLabel: ({ route }: Scene<Route>) =>
87
+ typeof route.accessibilityLabel === 'string'
88
+ ? route.accessibilityLabel
89
+ : typeof route.title === 'string'
90
+ ? route.title
91
+ : undefined,
92
+ getTestID: ({ route }: Scene<Route>) => route.testID,
93
+ renderIndicator: (props: IndicatorProps<Route>) => (
94
+ <TabBarIndicator {...props} />
95
+ ),
96
+ gap: 0,
97
+ };
98
+
99
+ state: State = {
100
+ layout: { width: 0, height: 0 },
101
+ tabWidths: {},
102
+ };
103
+
104
+ componentDidUpdate(prevProps: Props<T>, prevState: State) {
105
+ const { navigationState } = this.props;
106
+ const { layout, tabWidths } = this.state;
107
+
108
+ if (
109
+ prevProps.navigationState.routes.length !==
110
+ navigationState.routes.length ||
111
+ prevProps.navigationState.index !== navigationState.index ||
112
+ prevState.layout.width !== layout.width ||
113
+ prevState.tabWidths !== tabWidths
114
+ ) {
115
+ if (
116
+ this.getFlattenedTabWidth(this.props.tabStyle) === 'auto' &&
117
+ !(
118
+ layout.width &&
119
+ navigationState.routes.every(
120
+ (r) => typeof tabWidths[r.key] === 'number',
121
+ )
122
+ )
123
+ ) {
124
+ // When tab width is dynamic, only adjust the scroll once we have all tab widths and layout
125
+ return;
126
+ }
127
+
128
+ this.resetScroll(navigationState.index);
129
+ }
130
+ }
131
+
132
+ // to store the layout.width of each tab
133
+ // when all onLayout's are fired, this would be set in state
134
+ private measuredTabWidths: { [key: string]: number } = {};
135
+
136
+ private scrollAmount = new Animated.Value(0);
137
+
138
+ private flatListRef = React.createRef<FlatList>();
139
+
140
+ private getFlattenedTabWidth = (style: StyleProp<ViewStyle>) => {
141
+ const tabStyle = StyleSheet.flatten(style);
142
+
143
+ return tabStyle ? tabStyle.width : undefined;
144
+ };
145
+
146
+ private getComputedTabWidth = (
147
+ index: number,
148
+ layout: Layout,
149
+ routes: Route[],
150
+ scrollEnabled: boolean | undefined,
151
+ tabWidths: { [key: string]: number },
152
+ flattenedWidth: string | number | undefined,
153
+ ) => {
154
+ if (flattenedWidth === 'auto') {
155
+ return tabWidths[routes[index].key] || 0;
156
+ }
157
+
158
+ switch (typeof flattenedWidth) {
159
+ case 'number':
160
+ return flattenedWidth;
161
+ case 'string':
162
+ if (flattenedWidth.endsWith('%')) {
163
+ const width = parseFloat(flattenedWidth);
164
+ if (Number.isFinite(width)) {
165
+ return layout.width * (width / 100);
166
+ }
167
+ }
168
+ }
169
+
170
+ if (scrollEnabled) {
171
+ return (layout.width / 5) * 2;
172
+ }
173
+ return layout.width / routes.length;
174
+ };
175
+
176
+ private getMaxScrollDistance = (tabBarWidth: number, layoutWidth: number) =>
177
+ tabBarWidth - layoutWidth;
178
+
179
+ private getTabBarWidth = (props: Props<T>, state: State) => {
180
+ const { layout, tabWidths } = state;
181
+ const { scrollEnabled, tabStyle } = props;
182
+ const { routes } = props.navigationState;
183
+
184
+ return routes.reduce<number>(
185
+ (acc, _, i) =>
186
+ acc +
187
+ (i > 0 ? props.gap ?? 0 : 0) +
188
+ this.getComputedTabWidth(
189
+ i,
190
+ layout,
191
+ routes,
192
+ scrollEnabled,
193
+ tabWidths,
194
+ this.getFlattenedTabWidth(tabStyle),
195
+ ),
196
+ 0,
197
+ );
198
+ };
199
+
200
+ private normalizeScrollValue = (
201
+ props: Props<T>,
202
+ state: State,
203
+ value: number,
204
+ ) => {
205
+ const { layout } = state;
206
+ const tabBarWidth = this.getTabBarWidth(props, state);
207
+ const maxDistance = this.getMaxScrollDistance(
208
+ tabBarWidth,
209
+ layout.width,
210
+ );
211
+ const scrollValue = Math.max(Math.min(value, maxDistance), 0);
212
+
213
+ if (Platform.OS === 'android' && I18nManager.isRTL) {
214
+ // On Android, scroll value is not applied in reverse in RTL
215
+ // so we need to manually adjust it to apply correct value
216
+ return maxDistance - scrollValue;
217
+ }
218
+
219
+ return scrollValue;
220
+ };
221
+
222
+ private getScrollAmount = (
223
+ props: Props<T>,
224
+ state: State,
225
+ index: number,
226
+ ) => {
227
+ const { layout, tabWidths } = state;
228
+ const { scrollEnabled, tabStyle } = props;
229
+ const { routes } = props.navigationState;
230
+
231
+ const centerDistance = Array.from({ length: index + 1 }).reduce<number>(
232
+ (total, _, i) => {
233
+ const tabWidth = this.getComputedTabWidth(
234
+ i,
235
+ layout,
236
+ routes,
237
+ scrollEnabled,
238
+ tabWidths,
239
+ this.getFlattenedTabWidth(tabStyle),
240
+ );
241
+
242
+ // To get the current index centered we adjust scroll amount by width of indexes
243
+ // 0 through (i - 1) and add half the width of current index i
244
+ return (
245
+ total +
246
+ (index === i
247
+ ? (tabWidth + (props.gap ?? 0)) / 2
248
+ : tabWidth + (props.gap ?? 0))
249
+ );
250
+ },
251
+ 0,
252
+ );
253
+
254
+ const scrollAmount = centerDistance - layout.width / 2;
255
+
256
+ return this.normalizeScrollValue(props, state, scrollAmount);
257
+ };
258
+
259
+ private resetScroll = (index: number) => {
260
+ if (this.props.scrollEnabled) {
261
+ this.flatListRef.current?.scrollToOffset({
262
+ offset: this.getScrollAmount(this.props, this.state, index),
263
+ animated: true,
264
+ });
265
+ }
266
+ };
267
+
268
+ private handleLayout = (e: LayoutChangeEvent) => {
269
+ const { height, width } = e.nativeEvent.layout;
270
+
271
+ if (
272
+ this.state.layout.width === width &&
273
+ this.state.layout.height === height
274
+ ) {
275
+ return;
276
+ }
277
+
278
+ this.setState({
279
+ layout: {
280
+ height,
281
+ width,
282
+ },
283
+ });
284
+ };
285
+
286
+ private getTranslateX = (
287
+ scrollAmount: Animated.Value,
288
+ maxScrollDistance: number,
289
+ ) =>
290
+ Animated.multiply(
291
+ Platform.OS === 'android' && I18nManager.isRTL
292
+ ? Animated.add(
293
+ maxScrollDistance,
294
+ Animated.multiply(scrollAmount, -1),
295
+ )
296
+ : scrollAmount,
297
+ I18nManager.isRTL ? 1 : -1,
298
+ );
299
+
300
+ render() {
301
+ const {
302
+ position,
303
+ navigationState,
304
+ jumpTo,
305
+ scrollEnabled,
306
+ bounces,
307
+ getAccessibilityLabel,
308
+ getAccessible,
309
+ getLabelText,
310
+ getTestID,
311
+ renderBadge,
312
+ renderIcon,
313
+ renderLabel,
314
+ renderTabBarItem,
315
+ activeColor = Colors.black_21,
316
+ inactiveColor = Colors.black_09,
317
+ pressColor,
318
+ pressOpacity,
319
+ onTabPress,
320
+ onTabLongPress,
321
+ tabStyle,
322
+ labelStyle,
323
+ indicatorStyle,
324
+ contentContainerStyle,
325
+ style,
326
+ indicatorContainerStyle,
327
+ gap = 0,
328
+ } = this.props;
329
+ const { layout, tabWidths } = this.state;
330
+ const { routes } = navigationState;
331
+
332
+ const isWidthDynamic = this.getFlattenedTabWidth(tabStyle) === 'auto';
333
+ const tabBarWidth = this.getTabBarWidth(this.props, this.state);
334
+ const separatorsWidth = Math.max(0, routes.length - 1) * gap;
335
+ const separatorPercent = (separatorsWidth / tabBarWidth) * 100;
336
+
337
+ const tabBarWidthPercent = `${routes.length * 40}%`;
338
+ const translateX = this.getTranslateX(
339
+ this.scrollAmount,
340
+ this.getMaxScrollDistance(tabBarWidth, layout.width),
341
+ );
342
+
343
+ return (
344
+ <Animated.View
345
+ onLayout={this.handleLayout}
346
+ style={[styles.tabBar, style]}>
347
+ <Animated.View
348
+ pointerEvents="none"
349
+ style={[
350
+ styles.indicatorContainer,
351
+ scrollEnabled
352
+ ? { transform: [{ translateX }] as any }
353
+ : null,
354
+ tabBarWidth > separatorsWidth
355
+ ? { width: tabBarWidth - separatorsWidth }
356
+ : scrollEnabled
357
+ ? { width: tabBarWidthPercent }
358
+ : null,
359
+ indicatorContainerStyle,
360
+ ]}>
361
+ {this.props.renderIndicator({
362
+ position,
363
+ layout,
364
+ navigationState,
365
+ jumpTo,
366
+ width: isWidthDynamic
367
+ ? 'auto'
368
+ : `${(100 - separatorPercent) / routes.length}%`,
369
+ style: indicatorStyle,
370
+ getTabWidth: (i: number) =>
371
+ this.getComputedTabWidth(
372
+ i,
373
+ layout,
374
+ routes,
375
+ scrollEnabled,
376
+ tabWidths,
377
+ this.getFlattenedTabWidth(tabStyle),
378
+ ),
379
+ gap,
380
+ })}
381
+ </Animated.View>
382
+ <View style={styles.scroll}>
383
+ <Animated.FlatList
384
+ data={routes as Animated.WithAnimatedValue<T>[]}
385
+ keyExtractor={(item) => item.key}
386
+ horizontal
387
+ accessibilityRole="tablist"
388
+ keyboardShouldPersistTaps="handled"
389
+ scrollEnabled={scrollEnabled}
390
+ bounces={bounces}
391
+ alwaysBounceHorizontal={false}
392
+ scrollsToTop={false}
393
+ showsHorizontalScrollIndicator={false}
394
+ showsVerticalScrollIndicator={false}
395
+ automaticallyAdjustContentInsets={false}
396
+ overScrollMode="never"
397
+ contentContainerStyle={[
398
+ styles.tabContent,
399
+ scrollEnabled
400
+ ? {
401
+ width:
402
+ tabBarWidth > separatorsWidth
403
+ ? tabBarWidth
404
+ : tabBarWidthPercent,
405
+ }
406
+ : styles.container,
407
+ contentContainerStyle,
408
+ ]}
409
+ scrollEventThrottle={16}
410
+ renderItem={({
411
+ item: route,
412
+ index,
413
+ }: ListRenderItemInfo<T>) => {
414
+ const props: TabBarItemProps<T> & { key: string } =
415
+ {
416
+ key: route.key,
417
+ position: position,
418
+ route: route,
419
+ navigationState: navigationState,
420
+ getAccessibilityLabel:
421
+ getAccessibilityLabel,
422
+ getAccessible: getAccessible,
423
+ getLabelText: getLabelText,
424
+ getTestID: getTestID,
425
+ renderBadge: renderBadge,
426
+ renderIcon: renderIcon,
427
+ renderLabel: renderLabel,
428
+ activeColor: activeColor,
429
+ inactiveColor: inactiveColor,
430
+
431
+ pressColor: pressColor,
432
+ pressOpacity: pressOpacity,
433
+ onLayout: isWidthDynamic
434
+ ? (e) => {
435
+ this.measuredTabWidths[
436
+ route.key
437
+ ] = e.nativeEvent.layout.width;
438
+
439
+ // When we have measured widths for all of the tabs, we should updates the state
440
+ // We avoid doing separate setState for each layout since it triggers multiple renders and slows down app
441
+ if (
442
+ routes.every(
443
+ (r) =>
444
+ typeof this
445
+ .measuredTabWidths[
446
+ r.key
447
+ ] === 'number',
448
+ )
449
+ ) {
450
+ this.setState({
451
+ tabWidths: {
452
+ ...this
453
+ .measuredTabWidths,
454
+ },
455
+ });
456
+ }
457
+ }
458
+ : undefined,
459
+ onPress: () => {
460
+ const event: Scene<T> & Event = {
461
+ route,
462
+ defaultPrevented: false,
463
+ preventDefault: () => {
464
+ event.defaultPrevented = true;
465
+ },
466
+ };
467
+
468
+ onTabPress?.(event);
469
+
470
+ if (event.defaultPrevented) {
471
+ return;
472
+ }
473
+
474
+ this.props.jumpTo(route.key);
475
+ },
476
+ onLongPress: () =>
477
+ onTabLongPress?.({ route }),
478
+ labelStyle: labelStyle,
479
+ style: [
480
+ tabStyle,
481
+ // Calculate the deafult width for tab for FlatList to work.
482
+ this.getFlattenedTabWidth(tabStyle) ===
483
+ undefined && {
484
+ width: this.getComputedTabWidth(
485
+ index,
486
+ layout,
487
+ routes,
488
+ scrollEnabled,
489
+ tabWidths,
490
+ this.getFlattenedTabWidth(
491
+ tabStyle,
492
+ ),
493
+ ),
494
+ },
495
+ ],
496
+ };
497
+
498
+ return (
499
+ <React.Fragment key={route.key}>
500
+ {gap > 0 && index > 0 ? (
501
+ <Separator width={gap} />
502
+ ) : null}
503
+ {renderTabBarItem ? (
504
+ renderTabBarItem(props)
505
+ ) : (
506
+ <TabBarItem {...props} />
507
+ )}
508
+ </React.Fragment>
509
+ );
510
+ }}
511
+ onScroll={Animated.event(
512
+ [
513
+ {
514
+ nativeEvent: {
515
+ contentOffset: { x: this.scrollAmount },
516
+ },
517
+ },
518
+ ],
519
+ { useNativeDriver: true },
520
+ )}
521
+ ref={this.flatListRef}
522
+ />
523
+ </View>
524
+ </Animated.View>
525
+ );
526
+ }
527
+ }
528
+
529
+ const styles = StyleSheet.create({
530
+ container: {
531
+ flex: 1,
532
+ },
533
+ scroll: {
534
+ overflow: Platform.select({ default: 'scroll', web: undefined }),
535
+ },
536
+ tabBar: {
537
+ backgroundColor: Colors.black_01,
538
+ elevation: 4,
539
+ shadowColor: 'black',
540
+ shadowOpacity: 0.1,
541
+ shadowRadius: StyleSheet.hairlineWidth,
542
+ shadowOffset: {
543
+ height: StyleSheet.hairlineWidth,
544
+ width: 0,
545
+ },
546
+ zIndex: 1,
547
+ },
548
+ tabContent: {
549
+ flexDirection: 'row',
550
+ flexWrap: 'nowrap',
551
+ },
552
+ indicatorContainer: {
553
+ position: 'absolute',
554
+ top: 0,
555
+ left: 0,
556
+ right: 0,
557
+ bottom: 0,
558
+ },
559
+ });