@momo-kits/foundation 0.167.1-beta.13 → 0.167.1-beta.14
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 +246 -42
- package/Application/BottomTab/BottomTabBar.tsx +6 -3
- package/Application/BottomTab/CustomBottomTabItem.tsx +3 -5
- package/Application/BottomTab/DuoBarCompression.tsx +135 -0
- package/Application/BottomTab/DuoTabBar.tsx +537 -0
- package/Application/BottomTab/index.tsx +145 -134
- package/Application/Components/DuoGlassBackground.tsx +158 -0
- package/Application/Components/DuoQuickReturnHeader.tsx +42 -0
- package/Application/Components/HeaderAnimated.tsx +9 -3
- package/Application/Components/HeaderExtendHeader.tsx +70 -28
- package/Application/Components/HeaderLeft.tsx +38 -0
- package/Application/Components/HeaderRight.tsx +144 -9
- package/Application/Components/HeaderTitle.tsx +43 -8
- package/Application/Components/SearchHeader.tsx +20 -15
- package/Application/Components/SideBarButton.tsx +134 -0
- package/Application/ModalScreen.tsx +20 -2
- package/Application/NavigationContainer.tsx +22 -38
- package/Application/Navigator.ts +1 -3
- package/Application/StackScreen.tsx +2 -5
- package/Application/device.ts +52 -0
- package/Application/types.ts +10 -1
- package/Application/utils.tsx +10 -7
- package/Badge/Badge.tsx +2 -3
- package/Badge/BadgeDotAnimation.tsx +3 -3
- package/Button/index.tsx +11 -11
- package/Consts/theme.ts +3 -4
- package/Input/Input.tsx +3 -4
- package/Input/InputMoney.tsx +123 -195
- package/Input/InputOTP.tsx +9 -12
- package/Input/InputPhoneNumber.tsx +3 -7
- package/Input/InputSearch.tsx +17 -14
- package/Input/InputTextArea.tsx +3 -4
- package/Input/SystemTextInput.tsx +2 -3
- package/Input/TextTyping.tsx +11 -6
- package/Input/styles.ts +9 -0
- package/Input/utils.ts +2 -3
- package/Layout/Card.tsx +8 -26
- package/Layout/FloatingButton.tsx +3 -4
- package/Layout/Item.tsx +1 -10
- package/Layout/ReservedRegions.tsx +78 -0
- package/Layout/Screen.tsx +242 -48
- package/Layout/Section.tsx +5 -18
- package/Layout/index.ts +4 -4
- package/Layout/styles.ts +5 -0
- package/Layout/utils.ts +478 -5
- package/Pagination/Dot.tsx +3 -6
- package/Popup/PopupNotify.tsx +49 -28
- package/Popup/PopupPromotion.tsx +21 -8
- package/Popup/utils.ts +26 -0
- package/Radio/index.tsx +2 -3
- package/Text/index.tsx +4 -0
- package/Text/utils.ts +2 -3
- package/Title/index.tsx +8 -11
- package/Title/styles.ts +0 -1
- package/package.json +10 -7
- package/Layout/GridSystem.tsx +0 -137
|
@@ -1,14 +1,25 @@
|
|
|
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,
|
|
19
|
+
StatusBar,
|
|
10
20
|
StyleSheet,
|
|
11
21
|
TouchableOpacity,
|
|
22
|
+
useWindowDimensions,
|
|
12
23
|
View,
|
|
13
24
|
} from 'react-native';
|
|
14
25
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -22,22 +33,58 @@ import Animated, {
|
|
|
22
33
|
Easing,
|
|
23
34
|
Extrapolation,
|
|
24
35
|
interpolate,
|
|
25
|
-
runOnJS,
|
|
26
36
|
useAnimatedStyle,
|
|
27
37
|
useSharedValue,
|
|
28
38
|
withSpring,
|
|
29
39
|
withTiming,
|
|
30
40
|
} from 'react-native-reanimated';
|
|
41
|
+
import { scheduleOnRN } from 'react-native-worklets';
|
|
31
42
|
import layoutStyles from '../Layout/styles';
|
|
43
|
+
import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
|
|
44
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
45
|
+
import { SideBarButton } from './Components/SideBarButton';
|
|
46
|
+
|
|
47
|
+
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
48
|
+
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
49
|
+
|
|
50
|
+
const INDICATOR_HEIGHT = 16;
|
|
51
|
+
const HEADER_HEIGHT = 56;
|
|
52
|
+
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
53
|
+
const SIDE_COLUMN_OFFSET =
|
|
54
|
+
INDICATOR_HEIGHT + (HEADER_HEIGHT - DuoMetrics.controlSize) / 2;
|
|
32
55
|
|
|
33
56
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
34
57
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
35
58
|
const context = useContext<any>(MiniAppContext);
|
|
36
|
-
const
|
|
59
|
+
const windowSize = useWindowDimensions();
|
|
60
|
+
const isDuo = isIPhoneDuo();
|
|
61
|
+
const isDuoCentered =
|
|
62
|
+
isDuo &&
|
|
63
|
+
Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
|
|
64
|
+
const isDuoFloating = isDuo && !isDuoCentered;
|
|
65
|
+
const { sideBar } = useScreenLayout();
|
|
66
|
+
const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
|
|
67
|
+
const sideColumn =
|
|
68
|
+
isDuoFloating &&
|
|
69
|
+
sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
|
|
70
|
+
sideBar
|
|
71
|
+
? sideBar
|
|
72
|
+
: undefined;
|
|
73
|
+
const hasSideColumn = !!sideColumn;
|
|
74
|
+
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
75
|
+
const screenHeight = Dimensions.get('screen').height;
|
|
76
|
+
const heightDevice = isDuo ? windowSize.height : screenHeight;
|
|
37
77
|
const action = useRef<undefined | string>(undefined);
|
|
38
78
|
const insets = useSafeAreaInsets();
|
|
39
79
|
const heightHeader = useHeaderHeight();
|
|
40
|
-
const
|
|
80
|
+
const topGap = Math.max(
|
|
81
|
+
sideColumn
|
|
82
|
+
? insets.top
|
|
83
|
+
: getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
84
|
+
Spacing.XL,
|
|
85
|
+
);
|
|
86
|
+
const isIOS = Platform.OS === 'ios';
|
|
87
|
+
const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
41
88
|
const keyboardOffset = heightHeader - bottomInset;
|
|
42
89
|
|
|
43
90
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -58,6 +105,46 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
58
105
|
}: BottomSheetParams = props.route.params;
|
|
59
106
|
|
|
60
107
|
const translateY = useSharedValue(heightDevice);
|
|
108
|
+
const onDismissRef = useRef(props.route.params?.onDismiss);
|
|
109
|
+
onDismissRef.current = props.route.params?.onDismiss;
|
|
110
|
+
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
111
|
+
const [sheetTop, setSheetTop] = useState(windowSize.height);
|
|
112
|
+
|
|
113
|
+
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
114
|
+
// so lift it here and shrink maxHeight by the same amount instead
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (!isDuo || !useKeyboardAvoidingView) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const onKeyboardChange = (e: KeyboardEvent) => {
|
|
120
|
+
const overlap = windowSize.height - e.endCoordinates.screenY;
|
|
121
|
+
const lift = Math.max(
|
|
122
|
+
overlap - bottomInset - (keyboardVerticalOffset ?? 0),
|
|
123
|
+
0,
|
|
124
|
+
);
|
|
125
|
+
if (e.duration) {
|
|
126
|
+
LayoutAnimation.configureNext({
|
|
127
|
+
duration: e.duration,
|
|
128
|
+
update: {
|
|
129
|
+
duration: e.duration,
|
|
130
|
+
type: LayoutAnimation.Types[e.easing] || 'keyboard',
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
setKeyboardLift(lift);
|
|
135
|
+
};
|
|
136
|
+
const subscriptions = [
|
|
137
|
+
Keyboard.addListener('keyboardWillChangeFrame', onKeyboardChange),
|
|
138
|
+
Keyboard.addListener('keyboardWillHide', onKeyboardChange),
|
|
139
|
+
];
|
|
140
|
+
return () => subscriptions.forEach(sub => sub.remove());
|
|
141
|
+
}, [
|
|
142
|
+
bottomInset,
|
|
143
|
+
isDuo,
|
|
144
|
+
keyboardVerticalOffset,
|
|
145
|
+
useKeyboardAvoidingView,
|
|
146
|
+
windowSize.height,
|
|
147
|
+
]);
|
|
61
148
|
|
|
62
149
|
const openAnimation = useCallback(() => {
|
|
63
150
|
translateY.value = withTiming(0, {
|
|
@@ -74,7 +161,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
74
161
|
duration: 200,
|
|
75
162
|
easing: Easing.bezier(0.3, 0.0, 0.8, 0.15),
|
|
76
163
|
},
|
|
77
|
-
() =>
|
|
164
|
+
() => scheduleOnRN(callback),
|
|
78
165
|
);
|
|
79
166
|
},
|
|
80
167
|
[heightDevice, translateY],
|
|
@@ -117,15 +204,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
117
204
|
}
|
|
118
205
|
|
|
119
206
|
/**
|
|
120
|
-
* emit dismiss event
|
|
207
|
+
* emit dismiss event, once: folding or rotating iPhone Duo changes the
|
|
208
|
+
* window height while the sheet stays open
|
|
121
209
|
*/
|
|
122
210
|
useEffect(() => {
|
|
123
|
-
translateY.value = heightDevice;
|
|
124
211
|
openAnimation();
|
|
125
212
|
return () => {
|
|
126
|
-
|
|
213
|
+
onDismissRef.current?.(action.current);
|
|
127
214
|
};
|
|
128
|
-
}, [
|
|
215
|
+
}, [openAnimation]);
|
|
129
216
|
|
|
130
217
|
/**
|
|
131
218
|
* handler dismiss
|
|
@@ -137,9 +224,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
137
224
|
}
|
|
138
225
|
closeAnimation(() => {
|
|
139
226
|
navigator?.pop();
|
|
140
|
-
|
|
227
|
+
scheduleOnRN(() => {
|
|
141
228
|
callback?.();
|
|
142
|
-
})
|
|
229
|
+
});
|
|
143
230
|
});
|
|
144
231
|
},
|
|
145
232
|
[barrierDismissible, closeAnimation, navigator],
|
|
@@ -166,13 +253,42 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
166
253
|
return () => backHandler.remove();
|
|
167
254
|
}, [barrierDismissible, onDismiss]);
|
|
168
255
|
|
|
256
|
+
const onPressBack = useCallback(() => {
|
|
257
|
+
onDismiss(true, leftOptions?.onPressIconLeft);
|
|
258
|
+
}, [leftOptions, onDismiss]);
|
|
259
|
+
|
|
260
|
+
const onPressClose = useCallback(() => {
|
|
261
|
+
action.current = 'icon_close';
|
|
262
|
+
onDismiss(true);
|
|
263
|
+
}, [onDismiss]);
|
|
264
|
+
|
|
265
|
+
const showBack = !!leftOptions?.onPressIconLeft;
|
|
266
|
+
const iconBack = leftOptions?.iconLeft ?? 'ic_back';
|
|
267
|
+
const sideColumnCount = showBack ? 2 : 1;
|
|
268
|
+
const sideColumnHeight =
|
|
269
|
+
sideColumnCount * DuoMetrics.controlSize +
|
|
270
|
+
(sideColumnCount - 1) * Spacing.M;
|
|
271
|
+
const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!coversStatusBar) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const entry = StatusBar.pushStackEntry({
|
|
278
|
+
barStyle: theme.dark ? 'light-content' : 'dark-content',
|
|
279
|
+
animated: true,
|
|
280
|
+
});
|
|
281
|
+
return () => StatusBar.popStackEntry(entry);
|
|
282
|
+
}, [coversStatusBar, theme.dark]);
|
|
283
|
+
|
|
169
284
|
/**
|
|
170
285
|
* render header
|
|
171
286
|
*/
|
|
172
287
|
const renderHeader = useCallback(() => {
|
|
288
|
+
const showButtons = !hasSideColumn;
|
|
173
289
|
return (
|
|
174
290
|
<View {...panResponder.panHandlers}>
|
|
175
|
-
<View style={{ height:
|
|
291
|
+
<View style={{ height: INDICATOR_HEIGHT }}>
|
|
176
292
|
<View style={styles.indicator} />
|
|
177
293
|
</View>
|
|
178
294
|
<View
|
|
@@ -184,14 +300,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
184
300
|
},
|
|
185
301
|
]}
|
|
186
302
|
>
|
|
187
|
-
{
|
|
188
|
-
<TouchableOpacity
|
|
189
|
-
|
|
190
|
-
onPress={() => {
|
|
191
|
-
onDismiss(true, leftOptions.onPressIconLeft);
|
|
192
|
-
}}
|
|
193
|
-
>
|
|
194
|
-
<Icon source={leftOptions.iconLeft ?? 'ic_back'} />
|
|
303
|
+
{showButtons && showBack ? (
|
|
304
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
|
|
305
|
+
<Icon source={iconBack} />
|
|
195
306
|
</TouchableOpacity>
|
|
196
307
|
) : null}
|
|
197
308
|
|
|
@@ -199,9 +310,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
199
310
|
<View style={Styles.flex}>{options.header}</View>
|
|
200
311
|
) : (
|
|
201
312
|
<>
|
|
202
|
-
{!leftOptions ?
|
|
313
|
+
{showButtons && !leftOptions ? (
|
|
314
|
+
<View style={styles.iconButton} />
|
|
315
|
+
) : null}
|
|
203
316
|
<Text
|
|
204
|
-
style={[
|
|
317
|
+
style={[
|
|
318
|
+
Styles.flex,
|
|
319
|
+
styles.title,
|
|
320
|
+
!showButtons && styles.titleInset,
|
|
321
|
+
]}
|
|
205
322
|
numberOfLines={1}
|
|
206
323
|
typography="header_default_bold"
|
|
207
324
|
>
|
|
@@ -209,28 +326,63 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
209
326
|
</Text>
|
|
210
327
|
</>
|
|
211
328
|
)}
|
|
212
|
-
|
|
213
|
-
style={styles.iconButton}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
}}
|
|
218
|
-
>
|
|
219
|
-
<Icon source="navigation_close" />
|
|
220
|
-
</TouchableOpacity>
|
|
329
|
+
{showButtons ? (
|
|
330
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
|
|
331
|
+
<Icon source="navigation_close" />
|
|
332
|
+
</TouchableOpacity>
|
|
333
|
+
) : null}
|
|
221
334
|
</View>
|
|
222
335
|
</View>
|
|
223
336
|
);
|
|
224
337
|
}, [
|
|
225
|
-
|
|
338
|
+
hasSideColumn,
|
|
339
|
+
iconBack,
|
|
226
340
|
leftOptions,
|
|
341
|
+
onPressBack,
|
|
342
|
+
onPressClose,
|
|
227
343
|
options.header,
|
|
228
344
|
options.title,
|
|
229
345
|
panResponder.panHandlers,
|
|
346
|
+
showBack,
|
|
230
347
|
theme.colors.border.default,
|
|
231
348
|
useDivider,
|
|
232
349
|
]);
|
|
233
350
|
|
|
351
|
+
/**
|
|
352
|
+
* iPhone Duo folded: back and close move to the side bar column, drawn on
|
|
353
|
+
* the part of the sheet under the side safe area. They line up with the
|
|
354
|
+
* header row, stay below the status bar cluster on a tall sheet and above a
|
|
355
|
+
* camera at the bottom end of the strip.
|
|
356
|
+
*/
|
|
357
|
+
const renderSideColumn = () => {
|
|
358
|
+
if (!sideColumn) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const lowestTop =
|
|
362
|
+
windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
|
|
363
|
+
const top = Math.min(
|
|
364
|
+
Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
|
|
365
|
+
lowestTop,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<View
|
|
370
|
+
pointerEvents={'box-none'}
|
|
371
|
+
style={[
|
|
372
|
+
styles.sideColumn,
|
|
373
|
+
sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
|
|
374
|
+
InnerEdgeStyles[sideColumn.edge],
|
|
375
|
+
{ top, width: sideColumnWidth },
|
|
376
|
+
]}
|
|
377
|
+
>
|
|
378
|
+
{showBack ? (
|
|
379
|
+
<SideBarButton icon={iconBack} onPress={onPressBack} />
|
|
380
|
+
) : null}
|
|
381
|
+
<SideBarButton icon="navigation_close" onPress={onPressClose} />
|
|
382
|
+
</View>
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
234
386
|
const animatedStyle = useAnimatedStyle(() => {
|
|
235
387
|
return {
|
|
236
388
|
transform: [{ translateY: translateY.value }],
|
|
@@ -248,6 +400,19 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
248
400
|
};
|
|
249
401
|
});
|
|
250
402
|
|
|
403
|
+
const sideInsetStyle = isDuoFloating && {
|
|
404
|
+
paddingLeft: Math.max(insets.left - Spacing.M, 0),
|
|
405
|
+
paddingRight: Math.max(insets.right - Spacing.M, 0),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const screen = (
|
|
409
|
+
<Screen
|
|
410
|
+
{...props}
|
|
411
|
+
{...props.route.params}
|
|
412
|
+
onRequestClose={onRequestClose}
|
|
413
|
+
/>
|
|
414
|
+
);
|
|
415
|
+
|
|
251
416
|
return (
|
|
252
417
|
<Container
|
|
253
418
|
transparent
|
|
@@ -257,6 +422,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
257
422
|
}}
|
|
258
423
|
style={StyleSheet.absoluteFillObject}
|
|
259
424
|
isModalKit={true}
|
|
425
|
+
supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
|
|
260
426
|
>
|
|
261
427
|
<KeyboardAvoidingView
|
|
262
428
|
behavior={Platform.select({
|
|
@@ -264,7 +430,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
264
430
|
android: undefined,
|
|
265
431
|
})}
|
|
266
432
|
keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
|
|
267
|
-
enabled={useKeyboardAvoidingView}
|
|
433
|
+
enabled={useKeyboardAvoidingView && !isDuo}
|
|
268
434
|
contentContainerStyle={styles.container}
|
|
269
435
|
style={styles.container}
|
|
270
436
|
>
|
|
@@ -279,23 +445,50 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
279
445
|
</Pressable>
|
|
280
446
|
|
|
281
447
|
<Animated.View
|
|
448
|
+
onLayout={
|
|
449
|
+
isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
|
|
450
|
+
}
|
|
282
451
|
style={[
|
|
283
452
|
animatedStyle,
|
|
284
453
|
{
|
|
285
454
|
backgroundColor,
|
|
286
|
-
borderTopLeftRadius: Radius.M,
|
|
287
|
-
borderTopRightRadius: Radius.M,
|
|
455
|
+
borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
|
|
456
|
+
borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
|
|
288
457
|
overflow: 'hidden',
|
|
289
458
|
},
|
|
459
|
+
isDuo && {
|
|
460
|
+
maxHeight: windowSize.height - topGap - keyboardLift,
|
|
461
|
+
marginBottom: keyboardLift,
|
|
462
|
+
},
|
|
463
|
+
isDuoFloating && { marginHorizontal: Spacing.M },
|
|
464
|
+
sideColumn && {
|
|
465
|
+
minHeight:
|
|
466
|
+
SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
|
|
467
|
+
},
|
|
468
|
+
isDuoCentered && {
|
|
469
|
+
// keeps the portrait width in landscape, like system sheets
|
|
470
|
+
width: Math.min(
|
|
471
|
+
Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
|
|
472
|
+
windowSize.width -
|
|
473
|
+
Math.max(insets.left, insets.right, Spacing.L) * 2,
|
|
474
|
+
),
|
|
475
|
+
alignSelf: 'center',
|
|
476
|
+
},
|
|
290
477
|
showBaseLineDebug && styles.debugBaseLine,
|
|
291
478
|
]}
|
|
292
479
|
>
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
480
|
+
{isDuo ? (
|
|
481
|
+
// content shrinks so a tall sheet keeps its header and footer
|
|
482
|
+
<View style={[styles.shrink, sideInsetStyle]}>
|
|
483
|
+
{renderHeader()}
|
|
484
|
+
<View style={styles.shrink}>{screen}</View>
|
|
485
|
+
</View>
|
|
486
|
+
) : (
|
|
487
|
+
<>
|
|
488
|
+
{renderHeader()}
|
|
489
|
+
{screen}
|
|
490
|
+
</>
|
|
491
|
+
)}
|
|
299
492
|
{footerComponent && (
|
|
300
493
|
<View
|
|
301
494
|
style={[
|
|
@@ -304,6 +497,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
304
497
|
paddingBottom: Spacing.S,
|
|
305
498
|
backgroundColor: theme.colors.background.surface,
|
|
306
499
|
},
|
|
500
|
+
sideInsetStyle,
|
|
307
501
|
]}
|
|
308
502
|
>
|
|
309
503
|
{footerComponent}
|
|
@@ -317,6 +511,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
317
511
|
}}
|
|
318
512
|
/>
|
|
319
513
|
)}
|
|
514
|
+
{renderSideColumn()}
|
|
320
515
|
</Animated.View>
|
|
321
516
|
</KeyboardAvoidingView>
|
|
322
517
|
</Container>
|
|
@@ -342,10 +537,12 @@ const styles = StyleSheet.create({
|
|
|
342
537
|
top: Spacing.S,
|
|
343
538
|
},
|
|
344
539
|
header: {
|
|
345
|
-
height:
|
|
540
|
+
height: HEADER_HEIGHT,
|
|
346
541
|
flexDirection: 'row',
|
|
347
542
|
alignItems: 'center',
|
|
348
543
|
},
|
|
544
|
+
title: { textAlign: 'center' },
|
|
545
|
+
titleInset: { paddingHorizontal: Spacing.M },
|
|
349
546
|
iconButton: {
|
|
350
547
|
width: 24,
|
|
351
548
|
height: 24,
|
|
@@ -353,6 +550,13 @@ const styles = StyleSheet.create({
|
|
|
353
550
|
justifyContent: 'center',
|
|
354
551
|
marginHorizontal: Spacing.M,
|
|
355
552
|
},
|
|
553
|
+
shrink: { flexShrink: 1 },
|
|
554
|
+
sideColumn: {
|
|
555
|
+
position: 'absolute',
|
|
556
|
+
gap: Spacing.M,
|
|
557
|
+
},
|
|
558
|
+
alignRight: { right: 0 },
|
|
559
|
+
alignLeft: { left: 0 },
|
|
356
560
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
357
561
|
});
|
|
358
562
|
|
|
@@ -58,11 +58,13 @@ export default function BottomTabBar({
|
|
|
58
58
|
const { theme } = useContext(ApplicationContext);
|
|
59
59
|
const { routes } = state;
|
|
60
60
|
const widthDevice = useWindowDimensions()?.width;
|
|
61
|
+
const insets = useSafeAreaInsets();
|
|
61
62
|
const useFloating = floatingButton && state.routes.length % 2 === 0;
|
|
62
|
-
const itemWidth =
|
|
63
|
+
const itemWidth =
|
|
64
|
+
(widthDevice - insets.left - insets.right) /
|
|
65
|
+
(state.routes.length + (useFloating ? 1 : 0));
|
|
63
66
|
// const buildLink = useLinkBuilder();
|
|
64
67
|
const indicatorAnimated = useSharedValue(0);
|
|
65
|
-
const insets = useSafeAreaInsets();
|
|
66
68
|
|
|
67
69
|
const indicatorStyle = useAnimatedStyle(() => ({
|
|
68
70
|
width: itemWidth,
|
|
@@ -246,7 +248,8 @@ export default function BottomTabBar({
|
|
|
246
248
|
},
|
|
247
249
|
{
|
|
248
250
|
paddingBottom: paddingBottom,
|
|
249
|
-
|
|
251
|
+
paddingLeft: insets.left,
|
|
252
|
+
paddingRight: insets.right,
|
|
250
253
|
},
|
|
251
254
|
style,
|
|
252
255
|
]}
|
|
@@ -116,17 +116,15 @@ const CustomBottomTabItem: React.FC<CustomBottomTabItemProps> = ({
|
|
|
116
116
|
});
|
|
117
117
|
};
|
|
118
118
|
|
|
119
|
+
const background = focused ? activeBackgroundColor : inactiveBackgroundColor;
|
|
120
|
+
|
|
119
121
|
return (
|
|
120
122
|
<TouchableOpacity
|
|
121
123
|
onPress={onPress}
|
|
122
124
|
onLongPress={onLongPress}
|
|
123
125
|
style={[
|
|
124
126
|
styles.container,
|
|
125
|
-
{
|
|
126
|
-
backgroundColor: focused
|
|
127
|
-
? activeBackgroundColor
|
|
128
|
-
: inactiveBackgroundColor,
|
|
129
|
-
},
|
|
127
|
+
{ backgroundColor: background },
|
|
130
128
|
style,
|
|
131
129
|
showBaseLineDebug && styles.debugBaseLine,
|
|
132
130
|
]}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { Spacing } from '../../Consts';
|
|
9
|
+
import { useScreenLayout } from '../../Layout';
|
|
10
|
+
import { DuoMetrics } from '../../Layout/utils';
|
|
11
|
+
|
|
12
|
+
export type DuoBarCompressionBehavior = 'toolbar' | 'tabBar';
|
|
13
|
+
|
|
14
|
+
type DuoBarCompression = {
|
|
15
|
+
toolbarCompressed: boolean;
|
|
16
|
+
tabBarCompressed: boolean;
|
|
17
|
+
// the open rail needs the toolbar button slot too: only Back stays
|
|
18
|
+
toolbarHidden?: boolean;
|
|
19
|
+
// tapping the compressed bar expands it and compresses the other one
|
|
20
|
+
expandToolbar?: () => void;
|
|
21
|
+
expandTabBar?: () => void;
|
|
22
|
+
// a rail opened from the compressed state closes once a tab is picked
|
|
23
|
+
onTabSelected?: () => void;
|
|
24
|
+
// header buttons report their full height so the tab bar knows what is left
|
|
25
|
+
setToolbarHeight?: (height: number) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RAIL_PADDING = 6;
|
|
29
|
+
const RAIL_GAP = Spacing.XXS;
|
|
30
|
+
// space kept between the top and the bottom groups (HIG Vertical controls)
|
|
31
|
+
const GROUP_GAP = Spacing.M;
|
|
32
|
+
// Back, then the right toolkit: help + home capsule
|
|
33
|
+
const DEFAULT_TOOLBAR_HEIGHT =
|
|
34
|
+
DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
|
|
35
|
+
// Back, then the single button the toolbar folds into
|
|
36
|
+
const COMPRESSED_TOOLBAR_HEIGHT = 2 * DuoMetrics.controlSize + Spacing.M;
|
|
37
|
+
|
|
38
|
+
const NO_COMPRESSION: DuoBarCompression = {
|
|
39
|
+
toolbarCompressed: false,
|
|
40
|
+
tabBarCompressed: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DuoBarCompressionContext =
|
|
44
|
+
createContext<DuoBarCompression>(NO_COMPRESSION);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* height of the vertical tab rail, plus the QR button under it
|
|
48
|
+
*/
|
|
49
|
+
const getRailHeight = (tabCount: number, hasQr: boolean) =>
|
|
50
|
+
2 * RAIL_PADDING +
|
|
51
|
+
tabCount * DuoMetrics.controlSize +
|
|
52
|
+
Math.max(tabCount - 1, 0) * RAIL_GAP +
|
|
53
|
+
(hasQr ? Spacing.M + DuoMetrics.controlSize : 0);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* keeps the side bar's toolbar and tab bar clear of each other (HIG, Designing for
|
|
57
|
+
* iPhone Duo: Vertical controls):
|
|
58
|
+
* - both fit: both stay whole
|
|
59
|
+
* - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
60
|
+
* - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
61
|
+
* again once a tab is picked)
|
|
62
|
+
*/
|
|
63
|
+
const DuoBarCompressionProvider: React.FC<{
|
|
64
|
+
behavior?: DuoBarCompressionBehavior;
|
|
65
|
+
tabCount: number;
|
|
66
|
+
hasQr: boolean;
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
}> = ({ behavior = 'toolbar', tabCount, hasQr, children }) => {
|
|
69
|
+
const { height, sideBar } = useScreenLayout();
|
|
70
|
+
const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_TOOLBAR_HEIGHT);
|
|
71
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
72
|
+
const [expanded, setExpanded] = useState<'toolbar' | 'tabBar'>(
|
|
73
|
+
behavior === 'toolbar' ? 'tabBar' : 'toolbar',
|
|
74
|
+
);
|
|
75
|
+
// bar opened for a moment while both rest compressed
|
|
76
|
+
const [opened, setOpened] = useState<'toolbar' | 'tabBar'>();
|
|
77
|
+
|
|
78
|
+
const value = useMemo(() => {
|
|
79
|
+
if (!sideBar) {
|
|
80
|
+
return NO_COMPRESSION;
|
|
81
|
+
}
|
|
82
|
+
const room = height - sideBar.bottom - sideBar.top - GROUP_GAP;
|
|
83
|
+
const tabBar = getRailHeight(tabCount, hasQr);
|
|
84
|
+
const compressedTabBar = getRailHeight(1, hasQr) - 2 * RAIL_PADDING;
|
|
85
|
+
const fits = (toolbar: number, tabs: number) => toolbar + tabs <= room;
|
|
86
|
+
|
|
87
|
+
if (fits(toolbarHeight, tabBar)) {
|
|
88
|
+
return { ...NO_COMPRESSION, setToolbarHeight };
|
|
89
|
+
}
|
|
90
|
+
const canSwap =
|
|
91
|
+
fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) &&
|
|
92
|
+
fits(toolbarHeight, compressedTabBar);
|
|
93
|
+
if (canSwap) {
|
|
94
|
+
return {
|
|
95
|
+
toolbarCompressed: expanded === 'tabBar',
|
|
96
|
+
tabBarCompressed: expanded === 'toolbar',
|
|
97
|
+
expandToolbar: () => setExpanded('toolbar'),
|
|
98
|
+
expandTabBar: () => setExpanded('tabBar'),
|
|
99
|
+
setToolbarHeight,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const open = {
|
|
104
|
+
expandToolbar: () => setOpened('toolbar'),
|
|
105
|
+
expandTabBar: () => setOpened('tabBar'),
|
|
106
|
+
setToolbarHeight,
|
|
107
|
+
};
|
|
108
|
+
// an opened rail always shows, even when only the gap between the groups is short:
|
|
109
|
+
// it closes again once a tab is picked
|
|
110
|
+
if (opened === 'tabBar') {
|
|
111
|
+
return {
|
|
112
|
+
...open,
|
|
113
|
+
toolbarCompressed: true,
|
|
114
|
+
toolbarHidden: true,
|
|
115
|
+
tabBarCompressed: false,
|
|
116
|
+
onTabSelected: () => setOpened(undefined),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...open,
|
|
121
|
+
toolbarCompressed: opened !== 'toolbar',
|
|
122
|
+
tabBarCompressed: true,
|
|
123
|
+
};
|
|
124
|
+
}, [expanded, opened, hasQr, height, sideBar, tabCount, toolbarHeight]);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<DuoBarCompressionContext.Provider value={value}>
|
|
128
|
+
{children}
|
|
129
|
+
</DuoBarCompressionContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const useDuoBarCompression = () => useContext(DuoBarCompressionContext);
|
|
134
|
+
|
|
135
|
+
export { DuoBarCompressionProvider, useDuoBarCompression };
|