@momo-kits/foundation 0.167.1-duo.1 → 0.167.1-duo.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/Application/BottomSheet.tsx +115 -14
- package/Application/BottomTab/DuoTabBar.tsx +410 -0
- package/Application/BottomTab/index.tsx +38 -28
- package/Application/Components/DuoGlassBackground.tsx +177 -0
- package/Application/Components/HeaderExtendHeader.tsx +40 -27
- package/Application/Components/HeaderLeft.tsx +17 -0
- package/Application/Components/HeaderRight.tsx +59 -9
- package/Application/Components/HeaderTitle.tsx +53 -17
- package/Application/Components/OptionalGlassView.ts +11 -0
- package/Application/Components/SearchHeader.tsx +15 -14
- package/Application/Components/SideBarButton.tsx +128 -0
- package/Application/ModalScreen.tsx +16 -2
- package/Application/NavigationContainer.tsx +22 -32
- package/Application/Navigator.ts +1 -3
- package/Application/StackScreen.tsx +2 -5
- package/Application/WidgetContainer.tsx +9 -7
- package/Application/device.ts +29 -0
- package/Application/types.ts +5 -1
- package/Application/utils.tsx +10 -3
- package/Consts/index.ts +0 -1
- package/Consts/theme.ts +5 -6
- package/Input/Input.tsx +3 -4
- package/Input/InputMoney.tsx +31 -51
- package/Input/InputPhoneNumber.tsx +3 -4
- package/Input/InputSearch.tsx +3 -4
- package/Input/InputTextArea.tsx +3 -4
- package/Layout/Card.tsx +8 -26
- package/Layout/FloatingButton.tsx +3 -3
- package/Layout/Item.tsx +1 -10
- package/Layout/Screen.tsx +146 -40
- package/Layout/Section.tsx +5 -18
- package/Layout/index.ts +4 -4
- package/Layout/utils.ts +280 -5
- package/Popup/PopupNotify.tsx +33 -22
- package/Popup/PopupPromotion.tsx +20 -8
- package/Popup/utils.ts +32 -0
- package/Text/index.tsx +2 -11
- package/Text/utils.ts +1 -2
- package/package.json +4 -3
- package/Consts/designSystemFont.ts +0 -38
- package/Layout/GridSystem.tsx +0 -137
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
3
9
|
BackHandler,
|
|
4
10
|
Dimensions,
|
|
11
|
+
Keyboard,
|
|
5
12
|
KeyboardAvoidingView,
|
|
13
|
+
KeyboardEvent,
|
|
14
|
+
LayoutAnimation,
|
|
6
15
|
Modal,
|
|
7
16
|
PanResponder,
|
|
8
17
|
Platform,
|
|
9
18
|
Pressable,
|
|
10
19
|
StyleSheet,
|
|
11
20
|
TouchableOpacity,
|
|
21
|
+
useWindowDimensions,
|
|
12
22
|
View,
|
|
13
23
|
} from 'react-native';
|
|
14
24
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -22,22 +32,38 @@ import Animated, {
|
|
|
22
32
|
Easing,
|
|
23
33
|
Extrapolation,
|
|
24
34
|
interpolate,
|
|
25
|
-
runOnJS,
|
|
26
35
|
useAnimatedStyle,
|
|
27
36
|
useSharedValue,
|
|
28
37
|
withSpring,
|
|
29
38
|
withTiming,
|
|
30
39
|
} from 'react-native-reanimated';
|
|
40
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
31
41
|
import layoutStyles from '../Layout/styles';
|
|
42
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
43
|
+
|
|
44
|
+
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
45
|
+
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
32
46
|
|
|
33
47
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
34
48
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
35
49
|
const context = useContext<any>(MiniAppContext);
|
|
36
|
-
const
|
|
50
|
+
const windowSize = useWindowDimensions();
|
|
51
|
+
const isDuo = isIPhoneDuo();
|
|
52
|
+
const isDuoCentered = isDuo && windowSize.width >= DUO_REGULAR_MIN_WIDTH;
|
|
53
|
+
const isPortrait = windowSize.height > windowSize.width;
|
|
54
|
+
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
55
|
+
const heightDevice = isDuo
|
|
56
|
+
? windowSize.height
|
|
57
|
+
: Dimensions.get('screen').height;
|
|
37
58
|
const action = useRef<undefined | string>(undefined);
|
|
38
59
|
const insets = useSafeAreaInsets();
|
|
39
60
|
const heightHeader = useHeaderHeight();
|
|
40
|
-
const
|
|
61
|
+
const topGap = Math.max(
|
|
62
|
+
getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
63
|
+
Spacing.XL,
|
|
64
|
+
);
|
|
65
|
+
const bottomInset =
|
|
66
|
+
Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
41
67
|
const keyboardOffset = heightHeader - bottomInset;
|
|
42
68
|
|
|
43
69
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -58,6 +84,43 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
58
84
|
}: BottomSheetParams = props.route.params;
|
|
59
85
|
|
|
60
86
|
const translateY = useSharedValue(heightDevice);
|
|
87
|
+
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
88
|
+
|
|
89
|
+
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
90
|
+
// so lift it here and shrink maxHeight by the same amount instead
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!isDuo || !useKeyboardAvoidingView) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const onKeyboardChange = (e: KeyboardEvent) => {
|
|
96
|
+
const overlap = windowSize.height - e.endCoordinates.screenY;
|
|
97
|
+
const lift = Math.max(
|
|
98
|
+
overlap - bottomInset - (keyboardVerticalOffset ?? 0),
|
|
99
|
+
0,
|
|
100
|
+
);
|
|
101
|
+
if (e.duration) {
|
|
102
|
+
LayoutAnimation.configureNext({
|
|
103
|
+
duration: e.duration,
|
|
104
|
+
update: {
|
|
105
|
+
duration: e.duration,
|
|
106
|
+
type: LayoutAnimation.Types[e.easing] || 'keyboard',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
setKeyboardLift(lift);
|
|
111
|
+
};
|
|
112
|
+
const subscriptions = [
|
|
113
|
+
Keyboard.addListener('keyboardWillChangeFrame', onKeyboardChange),
|
|
114
|
+
Keyboard.addListener('keyboardWillHide', onKeyboardChange),
|
|
115
|
+
];
|
|
116
|
+
return () => subscriptions.forEach(sub => sub.remove());
|
|
117
|
+
}, [
|
|
118
|
+
bottomInset,
|
|
119
|
+
isDuo,
|
|
120
|
+
keyboardVerticalOffset,
|
|
121
|
+
useKeyboardAvoidingView,
|
|
122
|
+
windowSize.height,
|
|
123
|
+
]);
|
|
61
124
|
|
|
62
125
|
const openAnimation = useCallback(() => {
|
|
63
126
|
translateY.value = withTiming(0, {
|
|
@@ -74,7 +137,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
74
137
|
duration: 200,
|
|
75
138
|
easing: Easing.bezier(0.3, 0.0, 0.8, 0.15),
|
|
76
139
|
},
|
|
77
|
-
() =>
|
|
140
|
+
() => scheduleOnRN(callback),
|
|
78
141
|
);
|
|
79
142
|
},
|
|
80
143
|
[heightDevice, translateY],
|
|
@@ -137,9 +200,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
137
200
|
}
|
|
138
201
|
closeAnimation(() => {
|
|
139
202
|
navigator?.pop();
|
|
140
|
-
|
|
203
|
+
scheduleOnRN(() => {
|
|
141
204
|
callback?.();
|
|
142
|
-
})
|
|
205
|
+
});
|
|
143
206
|
});
|
|
144
207
|
},
|
|
145
208
|
[barrierDismissible, closeAnimation, navigator],
|
|
@@ -248,6 +311,23 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
248
311
|
};
|
|
249
312
|
});
|
|
250
313
|
|
|
314
|
+
// landscape side buttons span the full height, so inset the content; in
|
|
315
|
+
// portrait the side status bar only covers the top corner
|
|
316
|
+
const sideInsetStyle = isDuo &&
|
|
317
|
+
!isDuoCentered &&
|
|
318
|
+
!isPortrait && {
|
|
319
|
+
paddingLeft: insets.left,
|
|
320
|
+
paddingRight: insets.right,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const screen = (
|
|
324
|
+
<Screen
|
|
325
|
+
{...props}
|
|
326
|
+
{...props.route.params}
|
|
327
|
+
onRequestClose={onRequestClose}
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
|
|
251
331
|
return (
|
|
252
332
|
<Container
|
|
253
333
|
transparent
|
|
@@ -264,7 +344,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
264
344
|
android: undefined,
|
|
265
345
|
})}
|
|
266
346
|
keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
|
|
267
|
-
enabled={useKeyboardAvoidingView}
|
|
347
|
+
enabled={useKeyboardAvoidingView && !isDuo}
|
|
268
348
|
contentContainerStyle={styles.container}
|
|
269
349
|
style={styles.container}
|
|
270
350
|
>
|
|
@@ -287,15 +367,34 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
287
367
|
borderTopRightRadius: Radius.M,
|
|
288
368
|
overflow: 'hidden',
|
|
289
369
|
},
|
|
370
|
+
isDuo && {
|
|
371
|
+
maxHeight: windowSize.height - topGap - keyboardLift,
|
|
372
|
+
marginBottom: keyboardLift,
|
|
373
|
+
},
|
|
374
|
+
isDuoCentered && {
|
|
375
|
+
// keeps the portrait width in landscape, like system sheets
|
|
376
|
+
width: Math.min(
|
|
377
|
+
Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
|
|
378
|
+
windowSize.width -
|
|
379
|
+
Math.max(insets.left, insets.right, Spacing.L) * 2,
|
|
380
|
+
),
|
|
381
|
+
alignSelf: 'center',
|
|
382
|
+
},
|
|
290
383
|
showBaseLineDebug && styles.debugBaseLine,
|
|
291
384
|
]}
|
|
292
385
|
>
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
386
|
+
{isDuo ? (
|
|
387
|
+
// content shrinks so a tall sheet keeps its header and footer
|
|
388
|
+
<View style={[styles.shrink, sideInsetStyle]}>
|
|
389
|
+
{renderHeader()}
|
|
390
|
+
<View style={styles.shrink}>{screen}</View>
|
|
391
|
+
</View>
|
|
392
|
+
) : (
|
|
393
|
+
<>
|
|
394
|
+
{renderHeader()}
|
|
395
|
+
{screen}
|
|
396
|
+
</>
|
|
397
|
+
)}
|
|
299
398
|
{footerComponent && (
|
|
300
399
|
<View
|
|
301
400
|
style={[
|
|
@@ -304,6 +403,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
304
403
|
paddingBottom: Spacing.S,
|
|
305
404
|
backgroundColor: theme.colors.background.surface,
|
|
306
405
|
},
|
|
406
|
+
sideInsetStyle,
|
|
307
407
|
]}
|
|
308
408
|
>
|
|
309
409
|
{footerComponent}
|
|
@@ -353,6 +453,7 @@ const styles = StyleSheet.create({
|
|
|
353
453
|
justifyContent: 'center',
|
|
354
454
|
marginHorizontal: Spacing.M,
|
|
355
455
|
},
|
|
456
|
+
shrink: { flexShrink: 1 },
|
|
356
457
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
357
458
|
});
|
|
358
459
|
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
|
2
|
+
import { CommonActions, Route } from '@react-navigation/native';
|
|
3
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
LayoutChangeEvent,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
TouchableOpacity,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import Animated, {
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
withSpring,
|
|
14
|
+
} from 'react-native-reanimated';
|
|
15
|
+
import { Colors, Radius, Spacing } from '../../Consts';
|
|
16
|
+
import { ApplicationContext } from '../../Context';
|
|
17
|
+
import { Icon } from '../../Icon';
|
|
18
|
+
import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
|
|
19
|
+
import { Text } from '../../Text';
|
|
20
|
+
import { GlassView } from '../Components/OptionalGlassView';
|
|
21
|
+
import { FloatingButtonProps } from '../types';
|
|
22
|
+
import { TabBarIcon } from './TabBarIcon';
|
|
23
|
+
|
|
24
|
+
type Props = BottomTabBarProps & {
|
|
25
|
+
activeTintColor: string;
|
|
26
|
+
inactiveTintColor: string;
|
|
27
|
+
floatingButton?: FloatingButtonProps;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ITEM_SIZE = DuoMetrics.controlSize;
|
|
31
|
+
const ICON_SIZE = 28;
|
|
32
|
+
const QR_ICON_SIZE = 24;
|
|
33
|
+
const RAIL_PADDING = 6;
|
|
34
|
+
const RAIL_GAP = Spacing.XXS;
|
|
35
|
+
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
36
|
+
const RAIL_BUBBLE_WIDTH = 44;
|
|
37
|
+
const RAIL_BUBBLE_HEIGHT = 58;
|
|
38
|
+
const FLOATING_HEIGHT = 62;
|
|
39
|
+
const FLOATING_PADDING = Spacing.XS;
|
|
40
|
+
const FLOATING_QR_SIZE = 44;
|
|
41
|
+
// floating bar spans 6 of the 8 portrait grid columns, like the content
|
|
42
|
+
const GRID_COLUMNS = 8;
|
|
43
|
+
const GRID_SPAN = 6;
|
|
44
|
+
const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* space tab content leaves under the Duo floating tab bar, 0 elsewhere
|
|
48
|
+
*/
|
|
49
|
+
const useDuoTabBarInset = () => {
|
|
50
|
+
const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
|
|
51
|
+
return isIPhoneDuo && !sideBar
|
|
52
|
+
? FLOATING_HEIGHT + Spacing.M + insets.bottom
|
|
53
|
+
: 0;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const gridSpanWidth = (width: number) => {
|
|
57
|
+
const columnWidth =
|
|
58
|
+
(width - 2 * Spacing.M - (GRID_COLUMNS - 1) * Spacing.M) / GRID_COLUMNS;
|
|
59
|
+
return columnWidth * GRID_SPAN + (GRID_SPAN - 1) * Spacing.M;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
63
|
+
const { theme } = useContext(ApplicationContext);
|
|
64
|
+
if (GlassView) {
|
|
65
|
+
return (
|
|
66
|
+
<GlassView
|
|
67
|
+
pointerEvents={'none'}
|
|
68
|
+
effect={'glass'}
|
|
69
|
+
cornerRadius={radius}
|
|
70
|
+
useShadow
|
|
71
|
+
style={StyleSheet.absoluteFill}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<View
|
|
77
|
+
pointerEvents={'none'}
|
|
78
|
+
style={[
|
|
79
|
+
StyleSheet.absoluteFill,
|
|
80
|
+
styles.fallbackMaterial,
|
|
81
|
+
{
|
|
82
|
+
borderRadius: radius,
|
|
83
|
+
backgroundColor: theme.colors.background.surface,
|
|
84
|
+
borderColor: theme.colors.border.default,
|
|
85
|
+
},
|
|
86
|
+
]}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const getTabItems = ({
|
|
92
|
+
state,
|
|
93
|
+
descriptors,
|
|
94
|
+
navigation,
|
|
95
|
+
activeTintColor,
|
|
96
|
+
inactiveTintColor,
|
|
97
|
+
}: Props) => {
|
|
98
|
+
return state.routes.map((route: Route<string>, index: number) => {
|
|
99
|
+
const { options } = descriptors[route.key];
|
|
100
|
+
const focused = index === state.index;
|
|
101
|
+
const label = options.title ?? route.name;
|
|
102
|
+
|
|
103
|
+
const onPress = () => {
|
|
104
|
+
const event = navigation.emit({
|
|
105
|
+
type: 'tabPress',
|
|
106
|
+
target: route.key,
|
|
107
|
+
canPreventDefault: true,
|
|
108
|
+
});
|
|
109
|
+
if (!focused && !event.defaultPrevented) {
|
|
110
|
+
navigation.dispatch({
|
|
111
|
+
...CommonActions.navigate(route.name),
|
|
112
|
+
target: state.key,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onLongPress = () => {
|
|
118
|
+
navigation.emit({ type: 'tabLongPress', target: route.key });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const icon = options.tabBarIcon && (
|
|
122
|
+
<TabBarIcon
|
|
123
|
+
route={route}
|
|
124
|
+
badge={options.tabBarBadge}
|
|
125
|
+
badgeStyle={options.tabBarBadgeStyle}
|
|
126
|
+
activeOpacity={focused ? 1 : 0}
|
|
127
|
+
inactiveOpacity={focused ? 0 : 1}
|
|
128
|
+
activeTintColor={activeTintColor}
|
|
129
|
+
inactiveTintColor={inactiveTintColor}
|
|
130
|
+
renderIcon={options.tabBarIcon}
|
|
131
|
+
style={styles.icon}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { route, focused, label, onPress, onLongPress, icon };
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
|
|
140
|
+
icon,
|
|
141
|
+
size,
|
|
142
|
+
}) => {
|
|
143
|
+
if (typeof icon === 'string') {
|
|
144
|
+
return <Icon source={icon} size={size} color={Colors.black_01} />;
|
|
145
|
+
}
|
|
146
|
+
return <>{icon}</>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E)
|
|
151
|
+
*/
|
|
152
|
+
const DuoRailTabBar: React.FC<Props> = props => {
|
|
153
|
+
const { theme } = useContext(ApplicationContext);
|
|
154
|
+
const { sideBar } = useScreenLayout();
|
|
155
|
+
const { state, floatingButton } = props;
|
|
156
|
+
const items = getTabItems(props);
|
|
157
|
+
const bubbleOffset = useSharedValue(state.index * (ITEM_SIZE + RAIL_GAP));
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
bubbleOffset.value = withSpring(
|
|
161
|
+
state.index * (ITEM_SIZE + RAIL_GAP),
|
|
162
|
+
SPRING,
|
|
163
|
+
);
|
|
164
|
+
}, [state.index, bubbleOffset]);
|
|
165
|
+
|
|
166
|
+
const bubbleStyle = useAnimatedStyle(() => ({
|
|
167
|
+
transform: [{ translateY: bubbleOffset.value }],
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
if (!sideBar) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<View
|
|
176
|
+
pointerEvents={'box-none'}
|
|
177
|
+
style={[
|
|
178
|
+
styles.railColumn,
|
|
179
|
+
{ [sideBar.edge]: 0, width: sideBar.width },
|
|
180
|
+
sideBar.edge === 'right'
|
|
181
|
+
? { alignItems: 'flex-start', paddingLeft: Spacing.M }
|
|
182
|
+
: { alignItems: 'flex-end', paddingRight: Spacing.M },
|
|
183
|
+
]}
|
|
184
|
+
>
|
|
185
|
+
<View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
|
|
186
|
+
<BarMaterial radius={Radius.XL} />
|
|
187
|
+
<Animated.View
|
|
188
|
+
pointerEvents={'none'}
|
|
189
|
+
style={[
|
|
190
|
+
styles.railBubble,
|
|
191
|
+
{ backgroundColor: theme.colors.background.selected },
|
|
192
|
+
bubbleStyle,
|
|
193
|
+
]}
|
|
194
|
+
/>
|
|
195
|
+
{items.map(item => (
|
|
196
|
+
<TouchableOpacity
|
|
197
|
+
key={item.route.key}
|
|
198
|
+
accessibilityRole={'tab'}
|
|
199
|
+
accessibilityLabel={item.label}
|
|
200
|
+
accessibilityState={{ selected: item.focused }}
|
|
201
|
+
onPress={item.onPress}
|
|
202
|
+
onLongPress={item.onLongPress}
|
|
203
|
+
style={styles.railItem}
|
|
204
|
+
>
|
|
205
|
+
{item.icon}
|
|
206
|
+
</TouchableOpacity>
|
|
207
|
+
))}
|
|
208
|
+
</View>
|
|
209
|
+
{floatingButton && (
|
|
210
|
+
<TouchableOpacity
|
|
211
|
+
accessibilityRole={'button'}
|
|
212
|
+
accessibilityLabel={floatingButton.label}
|
|
213
|
+
onPress={floatingButton.onPress}
|
|
214
|
+
style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
|
|
215
|
+
>
|
|
216
|
+
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
217
|
+
</TouchableOpacity>
|
|
218
|
+
)}
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M)
|
|
225
|
+
*/
|
|
226
|
+
const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
227
|
+
const { theme } = useContext(ApplicationContext);
|
|
228
|
+
const { width, insets } = useScreenLayout();
|
|
229
|
+
const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
|
|
230
|
+
const items = getTabItems(props);
|
|
231
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
232
|
+
const barWidth = gridSpanWidth(width);
|
|
233
|
+
|
|
234
|
+
const useQr = !!floatingButton && state.routes.length % 2 === 0;
|
|
235
|
+
const middle = state.routes.length / 2;
|
|
236
|
+
const cellWidth = rowWidth / (state.routes.length + (useQr ? 1 : 0));
|
|
237
|
+
const visualIndex =
|
|
238
|
+
useQr && state.index >= middle ? state.index + 1 : state.index;
|
|
239
|
+
|
|
240
|
+
const bubbleOffset = useSharedValue(0);
|
|
241
|
+
const measured = useRef(false);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const target = visualIndex * cellWidth;
|
|
245
|
+
// snap on first layout so the bubble doesn't slide in from the left edge
|
|
246
|
+
bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
|
|
247
|
+
measured.current = cellWidth > 0;
|
|
248
|
+
}, [visualIndex, cellWidth, bubbleOffset]);
|
|
249
|
+
|
|
250
|
+
const bubbleStyle = useAnimatedStyle(() => ({
|
|
251
|
+
transform: [{ translateX: bubbleOffset.value }],
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
const onRowLayout = (e: LayoutChangeEvent) =>
|
|
255
|
+
setRowWidth(e.nativeEvent.layout.width);
|
|
256
|
+
|
|
257
|
+
const cells = items.map(item => (
|
|
258
|
+
<TouchableOpacity
|
|
259
|
+
key={item.route.key}
|
|
260
|
+
accessibilityRole={'tab'}
|
|
261
|
+
accessibilityLabel={item.label}
|
|
262
|
+
accessibilityState={{ selected: item.focused }}
|
|
263
|
+
onPress={item.onPress}
|
|
264
|
+
onLongPress={item.onLongPress}
|
|
265
|
+
style={styles.floatingItem}
|
|
266
|
+
>
|
|
267
|
+
{item.icon}
|
|
268
|
+
<Text
|
|
269
|
+
numberOfLines={1}
|
|
270
|
+
typography={'label_xs_medium'}
|
|
271
|
+
color={item.focused ? activeTintColor : inactiveTintColor}
|
|
272
|
+
>
|
|
273
|
+
{item.label}
|
|
274
|
+
</Text>
|
|
275
|
+
</TouchableOpacity>
|
|
276
|
+
));
|
|
277
|
+
|
|
278
|
+
if (useQr && floatingButton) {
|
|
279
|
+
cells.splice(
|
|
280
|
+
middle,
|
|
281
|
+
0,
|
|
282
|
+
<View key={'duo_tab_qr'} style={styles.floatingItem}>
|
|
283
|
+
<TouchableOpacity
|
|
284
|
+
accessibilityRole={'button'}
|
|
285
|
+
accessibilityLabel={floatingButton.label}
|
|
286
|
+
onPress={floatingButton.onPress}
|
|
287
|
+
style={[styles.floatingQr, { backgroundColor: theme.colors.primary }]}
|
|
288
|
+
>
|
|
289
|
+
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
290
|
+
</TouchableOpacity>
|
|
291
|
+
</View>,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<View
|
|
297
|
+
style={[
|
|
298
|
+
styles.floating,
|
|
299
|
+
{
|
|
300
|
+
width: barWidth,
|
|
301
|
+
left: (width - barWidth) / 2,
|
|
302
|
+
bottom: insets.bottom + Spacing.M,
|
|
303
|
+
},
|
|
304
|
+
]}
|
|
305
|
+
accessibilityLabel={'bottom_tab_bar'}
|
|
306
|
+
>
|
|
307
|
+
<BarMaterial radius={FLOATING_HEIGHT / 2} />
|
|
308
|
+
<View style={styles.floatingRow} onLayout={onRowLayout}>
|
|
309
|
+
{rowWidth > 0 && (
|
|
310
|
+
<Animated.View
|
|
311
|
+
pointerEvents={'none'}
|
|
312
|
+
style={[
|
|
313
|
+
styles.floatingBubble,
|
|
314
|
+
{
|
|
315
|
+
width: cellWidth,
|
|
316
|
+
backgroundColor: theme.colors.background.selected,
|
|
317
|
+
},
|
|
318
|
+
bubbleStyle,
|
|
319
|
+
]}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
{cells}
|
|
323
|
+
</View>
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* iPhone Duo tab bar: glass rail in the side bar, or a floating glass bar when unfolded portrait
|
|
330
|
+
*/
|
|
331
|
+
const DuoTabBar: React.FC<Props> = props => {
|
|
332
|
+
const { sideBar } = useScreenLayout();
|
|
333
|
+
return sideBar ? (
|
|
334
|
+
<DuoRailTabBar {...props} />
|
|
335
|
+
) : (
|
|
336
|
+
<DuoFloatingTabBar {...props} />
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const styles = StyleSheet.create({
|
|
341
|
+
fallbackMaterial: {
|
|
342
|
+
borderWidth: 1,
|
|
343
|
+
},
|
|
344
|
+
icon: {
|
|
345
|
+
width: ICON_SIZE,
|
|
346
|
+
height: ICON_SIZE,
|
|
347
|
+
},
|
|
348
|
+
railColumn: {
|
|
349
|
+
position: 'absolute',
|
|
350
|
+
bottom: Spacing.XL,
|
|
351
|
+
gap: Spacing.M,
|
|
352
|
+
},
|
|
353
|
+
rail: {
|
|
354
|
+
width: ITEM_SIZE,
|
|
355
|
+
paddingVertical: RAIL_PADDING,
|
|
356
|
+
gap: RAIL_GAP,
|
|
357
|
+
},
|
|
358
|
+
railBubble: {
|
|
359
|
+
position: 'absolute',
|
|
360
|
+
top: RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2,
|
|
361
|
+
left: (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
362
|
+
width: RAIL_BUBBLE_WIDTH,
|
|
363
|
+
height: RAIL_BUBBLE_HEIGHT,
|
|
364
|
+
borderRadius: RAIL_BUBBLE_WIDTH / 2,
|
|
365
|
+
},
|
|
366
|
+
railItem: {
|
|
367
|
+
width: ITEM_SIZE,
|
|
368
|
+
height: ITEM_SIZE,
|
|
369
|
+
alignItems: 'center',
|
|
370
|
+
justifyContent: 'center',
|
|
371
|
+
},
|
|
372
|
+
railQr: {
|
|
373
|
+
width: ITEM_SIZE,
|
|
374
|
+
height: ITEM_SIZE,
|
|
375
|
+
borderRadius: ITEM_SIZE / 2,
|
|
376
|
+
alignItems: 'center',
|
|
377
|
+
justifyContent: 'center',
|
|
378
|
+
},
|
|
379
|
+
floating: {
|
|
380
|
+
position: 'absolute',
|
|
381
|
+
height: FLOATING_HEIGHT,
|
|
382
|
+
padding: FLOATING_PADDING,
|
|
383
|
+
},
|
|
384
|
+
floatingRow: {
|
|
385
|
+
flex: 1,
|
|
386
|
+
flexDirection: 'row',
|
|
387
|
+
},
|
|
388
|
+
floatingBubble: {
|
|
389
|
+
position: 'absolute',
|
|
390
|
+
top: 0,
|
|
391
|
+
bottom: 0,
|
|
392
|
+
left: 0,
|
|
393
|
+
borderRadius: (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2,
|
|
394
|
+
},
|
|
395
|
+
floatingItem: {
|
|
396
|
+
flex: 1,
|
|
397
|
+
alignItems: 'center',
|
|
398
|
+
justifyContent: 'center',
|
|
399
|
+
gap: Spacing.XXS,
|
|
400
|
+
},
|
|
401
|
+
floatingQr: {
|
|
402
|
+
width: FLOATING_QR_SIZE,
|
|
403
|
+
height: FLOATING_QR_SIZE,
|
|
404
|
+
borderRadius: FLOATING_QR_SIZE / 2,
|
|
405
|
+
alignItems: 'center',
|
|
406
|
+
justifyContent: 'center',
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
export { DuoTabBar, useDuoTabBarInset };
|