@momo-kits/foundation 0.167.1-duo.12 → 0.167.1-duo.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/BottomTab/DuoTabBar.tsx +147 -86
- package/Application/BottomTab/index.tsx +3 -8
- package/Application/Components/HeaderExtendHeader.tsx +15 -5
- package/Application/Components/HeaderLeft.tsx +16 -1
- package/Application/Components/HeaderRight.tsx +39 -1
- package/Application/Components/HeaderTitle.tsx +17 -3
- package/Application/Components/SearchHeader.tsx +7 -1
- package/Application/Components/SideBarButton.tsx +15 -4
- package/Application/NavigationContainer.tsx +3 -5
- package/Layout/Screen.tsx +4 -0
- package/Layout/utils.ts +99 -1
- package/package.json +1 -1
|
@@ -36,37 +36,45 @@ const RAIL_GAP = Spacing.XXS;
|
|
|
36
36
|
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
37
37
|
const RAIL_BUBBLE_WIDTH = 44;
|
|
38
38
|
const RAIL_BUBBLE_HEIGHT = 58;
|
|
39
|
-
const FLOATING_HEIGHT =
|
|
39
|
+
const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
|
|
40
40
|
const FLOATING_PADDING = Spacing.XS;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// the system bar sizes its pill to the tabs instead of stretching it across the display
|
|
42
|
+
const FLOATING_ITEM_WIDTH = 80;
|
|
43
|
+
// while pressed the selection bubble becomes the iOS 26 glass lens, growing past the
|
|
44
|
+
// pill's edges, wider and taller (80x54 to about 96x70)
|
|
45
|
+
const LENS_SCALE_X = 1.2;
|
|
46
|
+
const LENS_SCALE_Y = 1.3;
|
|
47
|
+
const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
|
|
45
48
|
const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* distance from the screen bottom to the floating tab bar, overlapping the home
|
|
52
|
+
* indicator inset like the system tab bar
|
|
53
|
+
*/
|
|
54
|
+
const getFloatingTabBarBottom = (bottomInset: number) =>
|
|
55
|
+
Math.max(bottomInset - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S);
|
|
56
|
+
|
|
47
57
|
/**
|
|
48
58
|
* space tab content leaves under the Duo floating tab bar, 0 elsewhere
|
|
49
59
|
*/
|
|
50
60
|
const useDuoTabBarInset = () => {
|
|
51
61
|
const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
|
|
52
62
|
return isIPhoneDuo && !sideBar
|
|
53
|
-
? FLOATING_HEIGHT +
|
|
63
|
+
? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
|
|
54
64
|
: 0;
|
|
55
65
|
};
|
|
56
66
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
67
|
+
const BarMaterial: React.FC<{ radius: number; interactive?: boolean }> = ({
|
|
68
|
+
radius,
|
|
69
|
+
interactive,
|
|
70
|
+
}) => {
|
|
64
71
|
const { theme } = useContext(ApplicationContext);
|
|
65
72
|
if (GlassView) {
|
|
66
73
|
return (
|
|
67
74
|
<GlassView
|
|
68
75
|
pointerEvents={'none'}
|
|
69
76
|
effect={'glass'}
|
|
77
|
+
interactive={interactive}
|
|
70
78
|
cornerRadius={radius}
|
|
71
79
|
useShadow
|
|
72
80
|
style={StyleSheet.absoluteFill}
|
|
@@ -89,6 +97,37 @@ const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
|
89
97
|
);
|
|
90
98
|
};
|
|
91
99
|
|
|
100
|
+
// Figma Home-2026 tab bar "Selection": brand pink at 8% over glass
|
|
101
|
+
const SELECTION_TINT_ALPHA = '14';
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* translucent glass capsule under the selected floating tab with the Figma pink tint on
|
|
105
|
+
* top; the tint is a separate layer since a glass tint color renders opaque
|
|
106
|
+
*/
|
|
107
|
+
const SelectionMaterial: React.FC = () => {
|
|
108
|
+
const { theme } = useContext(ApplicationContext);
|
|
109
|
+
return (
|
|
110
|
+
<>
|
|
111
|
+
{GlassView && (
|
|
112
|
+
<GlassView
|
|
113
|
+
pointerEvents={'none'}
|
|
114
|
+
effect={'glass'}
|
|
115
|
+
cornerRadius={FLOATING_BUBBLE_RADIUS}
|
|
116
|
+
style={StyleSheet.absoluteFill}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
<View
|
|
120
|
+
pointerEvents={'none'}
|
|
121
|
+
style={[
|
|
122
|
+
StyleSheet.absoluteFill,
|
|
123
|
+
styles.floatingBubbleFill,
|
|
124
|
+
{ backgroundColor: theme.colors.primary + SELECTION_TINT_ALPHA },
|
|
125
|
+
]}
|
|
126
|
+
/>
|
|
127
|
+
</>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
92
131
|
const getTabItems = ({
|
|
93
132
|
state,
|
|
94
133
|
descriptors,
|
|
@@ -267,65 +306,106 @@ const DuoRailTabBar: React.FC<Props> = props => {
|
|
|
267
306
|
};
|
|
268
307
|
|
|
269
308
|
/**
|
|
270
|
-
* floating labelled bar for the unfolded portrait display, QR in
|
|
309
|
+
* floating labelled glass bar for the unfolded portrait display, QR pinned in its own
|
|
310
|
+
* platter on the trailing side, like the host app's iOS 26 system tab bar
|
|
271
311
|
*/
|
|
272
312
|
const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
273
313
|
const { theme } = useContext(ApplicationContext);
|
|
274
|
-
const {
|
|
314
|
+
const { insets } = useScreenLayout();
|
|
275
315
|
const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
|
|
276
316
|
const items = getTabItems(props);
|
|
277
317
|
const [rowWidth, setRowWidth] = useState(0);
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
const useQr = !!floatingButton && state.routes.length % 2 === 0;
|
|
281
|
-
const middle = state.routes.length / 2;
|
|
282
|
-
const cellWidth = rowWidth / (state.routes.length + (useQr ? 1 : 0));
|
|
283
|
-
const visualIndex =
|
|
284
|
-
useQr && state.index >= middle ? state.index + 1 : state.index;
|
|
318
|
+
const cellWidth = rowWidth / state.routes.length;
|
|
285
319
|
|
|
286
320
|
const bubbleOffset = useSharedValue(0);
|
|
321
|
+
const lens = useSharedValue(0);
|
|
287
322
|
const measured = useRef(false);
|
|
288
323
|
|
|
289
324
|
useEffect(() => {
|
|
290
|
-
const target =
|
|
325
|
+
const target = state.index * cellWidth;
|
|
291
326
|
// snap on first layout so the bubble doesn't slide in from the left edge
|
|
292
327
|
bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
|
|
293
328
|
measured.current = cellWidth > 0;
|
|
294
|
-
}, [
|
|
329
|
+
}, [state.index, cellWidth, bubbleOffset]);
|
|
295
330
|
|
|
296
331
|
const bubbleStyle = useAnimatedStyle(() => ({
|
|
297
|
-
transform: [
|
|
332
|
+
transform: [
|
|
333
|
+
{ translateX: bubbleOffset.value },
|
|
334
|
+
{ scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
|
|
335
|
+
{ scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
|
|
336
|
+
],
|
|
298
337
|
}));
|
|
299
338
|
|
|
300
339
|
const onRowLayout = (e: LayoutChangeEvent) =>
|
|
301
340
|
setRowWidth(e.nativeEvent.layout.width);
|
|
302
341
|
|
|
303
|
-
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
342
|
+
return (
|
|
343
|
+
<View
|
|
344
|
+
pointerEvents={'box-none'}
|
|
345
|
+
style={[
|
|
346
|
+
styles.floating,
|
|
347
|
+
{
|
|
348
|
+
left: insets.left + DuoMetrics.floatingTabBarMargin,
|
|
349
|
+
right: insets.right + DuoMetrics.floatingTabBarMargin,
|
|
350
|
+
bottom: getFloatingTabBarBottom(insets.bottom),
|
|
351
|
+
},
|
|
352
|
+
]}
|
|
312
353
|
>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
354
|
+
<View
|
|
355
|
+
style={[
|
|
356
|
+
styles.floatingBar,
|
|
357
|
+
{
|
|
358
|
+
width:
|
|
359
|
+
state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
|
|
360
|
+
},
|
|
361
|
+
]}
|
|
362
|
+
accessibilityLabel={'bottom_tab_bar'}
|
|
318
363
|
>
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
364
|
+
<BarMaterial radius={FLOATING_HEIGHT / 2} interactive />
|
|
365
|
+
<View style={styles.floatingRow} onLayout={onRowLayout}>
|
|
366
|
+
{rowWidth > 0 && (
|
|
367
|
+
<Animated.View
|
|
368
|
+
pointerEvents={'none'}
|
|
369
|
+
style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
|
|
370
|
+
>
|
|
371
|
+
<SelectionMaterial />
|
|
372
|
+
</Animated.View>
|
|
373
|
+
)}
|
|
374
|
+
{items.map((item, index) => (
|
|
375
|
+
<TouchableOpacity
|
|
376
|
+
key={item.route.key}
|
|
377
|
+
accessibilityRole={'tab'}
|
|
378
|
+
accessibilityLabel={item.label}
|
|
379
|
+
accessibilityState={{ selected: item.focused }}
|
|
380
|
+
activeOpacity={1}
|
|
381
|
+
onPressIn={() => {
|
|
382
|
+
// the lens jumps to the pressed tab, not only the selected one
|
|
383
|
+
bubbleOffset.value = withSpring(index * cellWidth, SPRING);
|
|
384
|
+
lens.value = withSpring(1, SPRING);
|
|
385
|
+
}}
|
|
386
|
+
onPressOut={() => {
|
|
387
|
+
// onPress follows synchronously, a tab switch re-targets the bubble
|
|
388
|
+
bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
|
|
389
|
+
lens.value = withSpring(0, SPRING);
|
|
390
|
+
}}
|
|
391
|
+
onPress={item.onPress}
|
|
392
|
+
onLongPress={item.onLongPress}
|
|
393
|
+
style={styles.floatingItem}
|
|
394
|
+
>
|
|
395
|
+
{item.icon}
|
|
396
|
+
{/* Text scales with the 669pt wide display, the smaller token lands near the system bar's label */}
|
|
397
|
+
<Text
|
|
398
|
+
numberOfLines={1}
|
|
399
|
+
typography={'label_xxs'}
|
|
400
|
+
color={item.focused ? activeTintColor : inactiveTintColor}
|
|
401
|
+
>
|
|
402
|
+
{item.label}
|
|
403
|
+
</Text>
|
|
404
|
+
</TouchableOpacity>
|
|
405
|
+
))}
|
|
406
|
+
</View>
|
|
407
|
+
</View>
|
|
408
|
+
{floatingButton && (
|
|
329
409
|
<TouchableOpacity
|
|
330
410
|
accessibilityRole={'button'}
|
|
331
411
|
accessibilityLabel={floatingButton.label}
|
|
@@ -334,39 +414,7 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
|
334
414
|
>
|
|
335
415
|
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
336
416
|
</TouchableOpacity>
|
|
337
|
-
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return (
|
|
342
|
-
<View
|
|
343
|
-
style={[
|
|
344
|
-
styles.floating,
|
|
345
|
-
{
|
|
346
|
-
width: barWidth,
|
|
347
|
-
left: (width - barWidth) / 2,
|
|
348
|
-
bottom: insets.bottom + Spacing.M,
|
|
349
|
-
},
|
|
350
|
-
]}
|
|
351
|
-
accessibilityLabel={'bottom_tab_bar'}
|
|
352
|
-
>
|
|
353
|
-
<BarMaterial radius={FLOATING_HEIGHT / 2} />
|
|
354
|
-
<View style={styles.floatingRow} onLayout={onRowLayout}>
|
|
355
|
-
{rowWidth > 0 && (
|
|
356
|
-
<Animated.View
|
|
357
|
-
pointerEvents={'none'}
|
|
358
|
-
style={[
|
|
359
|
-
styles.floatingBubble,
|
|
360
|
-
{
|
|
361
|
-
width: cellWidth,
|
|
362
|
-
backgroundColor: theme.colors.background.selected,
|
|
363
|
-
},
|
|
364
|
-
bubbleStyle,
|
|
365
|
-
]}
|
|
366
|
-
/>
|
|
367
|
-
)}
|
|
368
|
-
{cells}
|
|
369
|
-
</View>
|
|
417
|
+
)}
|
|
370
418
|
</View>
|
|
371
419
|
);
|
|
372
420
|
};
|
|
@@ -430,6 +478,12 @@ const styles = StyleSheet.create({
|
|
|
430
478
|
},
|
|
431
479
|
floating: {
|
|
432
480
|
position: 'absolute',
|
|
481
|
+
flexDirection: 'row',
|
|
482
|
+
justifyContent: 'center',
|
|
483
|
+
gap: Spacing.S,
|
|
484
|
+
},
|
|
485
|
+
floatingBar: {
|
|
486
|
+
flexShrink: 1,
|
|
433
487
|
height: FLOATING_HEIGHT,
|
|
434
488
|
padding: FLOATING_PADDING,
|
|
435
489
|
},
|
|
@@ -442,7 +496,9 @@ const styles = StyleSheet.create({
|
|
|
442
496
|
top: 0,
|
|
443
497
|
bottom: 0,
|
|
444
498
|
left: 0,
|
|
445
|
-
|
|
499
|
+
},
|
|
500
|
+
floatingBubbleFill: {
|
|
501
|
+
borderRadius: FLOATING_BUBBLE_RADIUS,
|
|
446
502
|
},
|
|
447
503
|
floatingItem: {
|
|
448
504
|
flex: 1,
|
|
@@ -450,12 +506,17 @@ const styles = StyleSheet.create({
|
|
|
450
506
|
justifyContent: 'center',
|
|
451
507
|
gap: Spacing.XXS,
|
|
452
508
|
},
|
|
509
|
+
// pink fills the whole pinned platter like the host app, shadow matches the glass bar
|
|
453
510
|
floatingQr: {
|
|
454
|
-
width:
|
|
455
|
-
height:
|
|
456
|
-
borderRadius:
|
|
511
|
+
width: FLOATING_HEIGHT,
|
|
512
|
+
height: FLOATING_HEIGHT,
|
|
513
|
+
borderRadius: FLOATING_HEIGHT / 2,
|
|
457
514
|
alignItems: 'center',
|
|
458
515
|
justifyContent: 'center',
|
|
516
|
+
shadowColor: Colors.black_20,
|
|
517
|
+
shadowOffset: { width: 0, height: 6 },
|
|
518
|
+
shadowOpacity: 0.16,
|
|
519
|
+
shadowRadius: 16,
|
|
459
520
|
},
|
|
460
521
|
});
|
|
461
522
|
|
|
@@ -31,6 +31,7 @@ import { DuoTabBar } from './DuoTabBar';
|
|
|
31
31
|
import { DuoBarCompressionProvider } from './DuoBarCompression';
|
|
32
32
|
import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
33
33
|
import { useScreenLayout } from '../../Layout';
|
|
34
|
+
import { getHeaderRowOptions } from '../../Layout/utils';
|
|
34
35
|
|
|
35
36
|
const Tab = createBottomTabNavigator();
|
|
36
37
|
const Stack = createStackNavigator();
|
|
@@ -39,13 +40,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
39
40
|
const { navigator } = useContext(ApplicationContext);
|
|
40
41
|
const context = useContext<any>(MiniAppContext);
|
|
41
42
|
const { nested, options, screen, initialParams } = route?.params;
|
|
42
|
-
const
|
|
43
|
-
const headerStyleOptions = {
|
|
44
|
-
headerStyle: {
|
|
45
|
-
height: headerHeight,
|
|
46
|
-
},
|
|
47
|
-
headerStatusBarHeight,
|
|
48
|
-
};
|
|
43
|
+
const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
|
|
49
44
|
|
|
50
45
|
const opacityValue = 0.3;
|
|
51
46
|
const scaleValue = 0.97;
|
|
@@ -321,7 +316,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
321
316
|
<DuoTabBar
|
|
322
317
|
{...props}
|
|
323
318
|
floatingButton={floatingButton}
|
|
324
|
-
inactiveTintColor={theme.colors.text.
|
|
319
|
+
inactiveTintColor={theme.colors.text.secondary}
|
|
325
320
|
activeTintColor={theme.colors.primary}
|
|
326
321
|
/>
|
|
327
322
|
) : (
|
|
@@ -16,7 +16,7 @@ import { SearchHeaderProps } from '../types';
|
|
|
16
16
|
import BackgroundImageView from './BackgroundImageView';
|
|
17
17
|
import { AnimatedCompatValue, useNormalizedSharedValue } from '../utils';
|
|
18
18
|
import { useScreenLayout } from '../../Layout';
|
|
19
|
-
import { HeaderMetrics } from '../../Layout/utils';
|
|
19
|
+
import { DuoMetrics, HeaderMetrics } from '../../Layout/utils';
|
|
20
20
|
|
|
21
21
|
const SCREEN_PADDING = 12;
|
|
22
22
|
const BACK_WIDTH = 28;
|
|
@@ -51,17 +51,27 @@ const HeaderExtendHeader: React.FC<{
|
|
|
51
51
|
const headerBackground = customBackground ?? theme.assets?.headerBackground;
|
|
52
52
|
const layout = useScreenLayout();
|
|
53
53
|
|
|
54
|
+
// the iPhone Duo status bar row keeps side bar sized back and toolkit
|
|
55
|
+
const backWidth = layout.statusBarClearance
|
|
56
|
+
? DuoMetrics.controlSize
|
|
57
|
+
: BACK_WIDTH;
|
|
58
|
+
const toolkitWidth = layout.statusBarClearance
|
|
59
|
+
? DuoMetrics.itemPitch * 2 + SCREEN_PADDING
|
|
60
|
+
: TOOLKIT_WIDTH;
|
|
54
61
|
const leftPosition =
|
|
55
62
|
inputSearchProps?.leftPosition ||
|
|
56
|
-
(layout.sideBar ? SCREEN_PADDING :
|
|
63
|
+
(layout.sideBar ? SCREEN_PADDING : backWidth + 20);
|
|
57
64
|
const headerRightWidth =
|
|
58
|
-
customHeaderRightWidth ?? (layout.sideBar ? 0 :
|
|
65
|
+
customHeaderRightWidth ?? (layout.sideBar ? 0 : toolkitWidth);
|
|
59
66
|
const contentLeft = layout.insetLeft + SCREEN_PADDING;
|
|
60
67
|
const contentRight = layout.insetLeft + layout.contentWidth;
|
|
61
|
-
|
|
68
|
+
// collapsed on the status bar row, the search stays between its clusters
|
|
69
|
+
const collapsedLeft =
|
|
70
|
+
layout.insetLeft + leftPosition + (layout.statusBarClearance?.left ?? 0);
|
|
71
|
+
const collapsedRight = contentRight - (layout.statusBarClearance?.right ?? 0);
|
|
62
72
|
const searchWidth = layout.contentWidth - SCREEN_PADDING * 2;
|
|
63
73
|
const collapsedSearchWidth =
|
|
64
|
-
|
|
74
|
+
collapsedRight - collapsedLeft - SCREEN_PADDING - headerRightWidth;
|
|
65
75
|
// a row taller than barHeight (iPhone Duo) centers the collapsed search on the title
|
|
66
76
|
const collapsedRaise =
|
|
67
77
|
(layout.headerHeight -
|
|
@@ -6,7 +6,7 @@ import { PopupNotify } from '../../Popup';
|
|
|
6
6
|
import { NavigationButton } from './NavigationButton';
|
|
7
7
|
import { SideBarButton } from './SideBarButton';
|
|
8
8
|
import Animated from 'react-native-reanimated';
|
|
9
|
-
import { useSideBarItemStyle } from '../../Layout/utils';
|
|
9
|
+
import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
|
|
10
10
|
import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -23,6 +23,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
|
|
|
23
23
|
const { navigator } = useContext(ApplicationContext);
|
|
24
24
|
const sideBarStyle = useSideBarItemStyle('left', 0);
|
|
25
25
|
const counterStyle = useSideBarCounterStyle();
|
|
26
|
+
const { statusBarClearance } = useScreenLayout();
|
|
26
27
|
|
|
27
28
|
const goBack = useCallback(() => {
|
|
28
29
|
const canGoBack = navigator?.ref?.current?.canGoBack?.();
|
|
@@ -95,6 +96,20 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
|
|
|
95
96
|
);
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
// on the status bar row the controls keep the side bar size
|
|
100
|
+
if (statusBarClearance) {
|
|
101
|
+
return (
|
|
102
|
+
<View style={styles.headerLeft}>
|
|
103
|
+
<SideBarButton
|
|
104
|
+
accessibilityLabel="btn_navigation_back"
|
|
105
|
+
icon="arrow-back"
|
|
106
|
+
tintColor={tintColor}
|
|
107
|
+
onPress={goBackSafe}
|
|
108
|
+
/>
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
98
113
|
return (
|
|
99
114
|
<View style={styles.headerLeft}>
|
|
100
115
|
<NavigationButton
|
|
@@ -14,7 +14,7 @@ import { Tool, ToolGroup } from '../types';
|
|
|
14
14
|
import { Icon } from '../../Icon';
|
|
15
15
|
import { Text } from '../../Text';
|
|
16
16
|
import Animated from 'react-native-reanimated';
|
|
17
|
-
import { useSideBarItemStyle } from '../../Layout/utils';
|
|
17
|
+
import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
|
|
18
18
|
import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
|
|
19
19
|
import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
|
|
20
20
|
import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
|
|
@@ -85,6 +85,7 @@ const HeaderToolkitAction: React.FC<any> = ({
|
|
|
85
85
|
const context = useContext<any>(MiniAppContext);
|
|
86
86
|
const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
|
|
87
87
|
useDuoBarCompression();
|
|
88
|
+
const { statusBarClearance } = useScreenLayout();
|
|
88
89
|
|
|
89
90
|
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
|
90
91
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -358,6 +359,39 @@ const HeaderToolkitAction: React.FC<any> = ({
|
|
|
358
359
|
);
|
|
359
360
|
}
|
|
360
361
|
|
|
362
|
+
// on the status bar row the controls keep the side bar size
|
|
363
|
+
if (statusBarClearance) {
|
|
364
|
+
return (
|
|
365
|
+
<View style={styles.sideBarRow}>
|
|
366
|
+
{useShortcut && (
|
|
367
|
+
<SideBarButton
|
|
368
|
+
disabled={isLoading}
|
|
369
|
+
icon={iconShortcut}
|
|
370
|
+
onPress={shortcutOnPress}
|
|
371
|
+
tintColor={tintColor}
|
|
372
|
+
showBadge={showBadge}
|
|
373
|
+
/>
|
|
374
|
+
)}
|
|
375
|
+
<SideBarGroup tintColor={tintColor} horizontal>
|
|
376
|
+
{useHelpCenter && (
|
|
377
|
+
<SideBarGroupItem
|
|
378
|
+
accessibilityLabel={'btn_navigation_help_center'}
|
|
379
|
+
icon={'help_center'}
|
|
380
|
+
tintColor={tintColor}
|
|
381
|
+
onPress={onPressHelpCenter}
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
384
|
+
<SideBarGroupItem
|
|
385
|
+
accessibilityLabel={'btn_navigation_close'}
|
|
386
|
+
icon={closeIcon}
|
|
387
|
+
tintColor={tintColor}
|
|
388
|
+
onPress={onClose}
|
|
389
|
+
/>
|
|
390
|
+
</SideBarGroup>
|
|
391
|
+
</View>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
361
395
|
return (
|
|
362
396
|
<View style={Styles.row}>
|
|
363
397
|
{useShortcut && (
|
|
@@ -471,6 +505,10 @@ const styles = StyleSheet.create({
|
|
|
471
505
|
sideBarColumn: {
|
|
472
506
|
gap: Spacing.M,
|
|
473
507
|
},
|
|
508
|
+
sideBarRow: {
|
|
509
|
+
flexDirection: 'row',
|
|
510
|
+
gap: Spacing.M,
|
|
511
|
+
},
|
|
474
512
|
headerLeft: {
|
|
475
513
|
marginLeft: 12,
|
|
476
514
|
},
|
|
@@ -33,15 +33,29 @@ const useHeaderFontFamily = (): string => {
|
|
|
33
33
|
*/
|
|
34
34
|
const HEADER_ACTIONS_WIDTH = 172;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* extra width the 48pt back and toolkit take on the iPhone Duo status bar row
|
|
38
|
+
*/
|
|
39
|
+
const STATUS_BAR_ROW_ACTIONS_EXTRA = 55;
|
|
40
|
+
|
|
36
41
|
/**
|
|
37
42
|
* max title width, the iPhone Duo side bar takes back and toolkit out of the bar
|
|
38
43
|
*/
|
|
39
44
|
const useTitleMaxWidth = () => {
|
|
40
|
-
const { width, contentWidth, sideBar } =
|
|
45
|
+
const { width, contentWidth, sideBar, statusBarClearance } =
|
|
46
|
+
useScreenLayout();
|
|
47
|
+
const clearance = statusBarClearance
|
|
48
|
+
? statusBarClearance.left + statusBarClearance.right
|
|
49
|
+
: 0;
|
|
41
50
|
if (sideBar) {
|
|
42
|
-
return contentWidth - Spacing.M * 2;
|
|
51
|
+
return contentWidth - Spacing.M * 2 - clearance;
|
|
43
52
|
}
|
|
44
|
-
return
|
|
53
|
+
return (
|
|
54
|
+
width -
|
|
55
|
+
HEADER_ACTIONS_WIDTH -
|
|
56
|
+
(statusBarClearance ? STATUS_BAR_ROW_ACTIONS_EXTRA : 0) -
|
|
57
|
+
clearance
|
|
58
|
+
);
|
|
45
59
|
};
|
|
46
60
|
|
|
47
61
|
type HeaderTitleInterpolate = {
|
|
@@ -8,6 +8,7 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
|
8
8
|
import { Text } from '../../Text';
|
|
9
9
|
import { useNormalizedSharedValue } from '../utils';
|
|
10
10
|
import { useScreenLayout } from '../../Layout';
|
|
11
|
+
import { DuoMetrics } from '../../Layout/utils';
|
|
11
12
|
|
|
12
13
|
const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
|
|
13
14
|
(
|
|
@@ -22,8 +23,9 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
|
|
|
22
23
|
) => {
|
|
23
24
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
24
25
|
const context = useContext<any>(MiniAppContext);
|
|
25
|
-
const BACK_WIDTH = 28;
|
|
26
26
|
const layout = useScreenLayout();
|
|
27
|
+
// the iPhone Duo status bar row keeps a side bar sized back button
|
|
28
|
+
const BACK_WIDTH = layout.statusBarClearance ? DuoMetrics.controlSize : 28;
|
|
27
29
|
|
|
28
30
|
const sharedValue = useNormalizedSharedValue(animatedValue);
|
|
29
31
|
const leftPosition = props?.leftPosition ?? BACK_WIDTH + 20;
|
|
@@ -34,6 +36,10 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
|
|
|
34
36
|
if (layout.sideBar) {
|
|
35
37
|
searchMaxWidth = layout.contentWidth - Spacing.M * 2 - headerRightWidth;
|
|
36
38
|
}
|
|
39
|
+
if (layout.statusBarClearance) {
|
|
40
|
+
searchMaxWidth -=
|
|
41
|
+
layout.statusBarClearance.left + layout.statusBarClearance.right;
|
|
42
|
+
}
|
|
37
43
|
|
|
38
44
|
const backgroundStyle = useAnimatedStyle(() => {
|
|
39
45
|
const t = Math.min(Math.max(sharedValue.value / 100, 0), 1);
|
|
@@ -54,19 +54,30 @@ const SideBarButton: React.FC<NavigationButtonProps> = ({
|
|
|
54
54
|
};
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* 48pt
|
|
57
|
+
* 48pt glass capsule grouping related controls, one item per 50pt, laid out vertically
|
|
58
|
+
* in the side bar or horizontally on a header row
|
|
58
59
|
*/
|
|
59
60
|
const SideBarGroup: React.FC<{
|
|
60
61
|
tintColor?: string;
|
|
62
|
+
horizontal?: boolean;
|
|
61
63
|
style?: StyleProp<ViewStyle>;
|
|
62
64
|
children: ReactNode;
|
|
63
|
-
}> = ({ tintColor, style, children }) => {
|
|
65
|
+
}> = ({ tintColor, horizontal = false, style, children }) => {
|
|
64
66
|
const count = React.Children.toArray(children).length;
|
|
65
|
-
const
|
|
67
|
+
const length =
|
|
66
68
|
count > 1 ? count * DuoMetrics.itemPitch : DuoMetrics.controlSize;
|
|
67
69
|
|
|
68
70
|
return (
|
|
69
|
-
<View
|
|
71
|
+
<View
|
|
72
|
+
style={[
|
|
73
|
+
styles.control,
|
|
74
|
+
getGlassStyle(tintColor),
|
|
75
|
+
horizontal
|
|
76
|
+
? { width: length, flexDirection: 'row' }
|
|
77
|
+
: { height: length },
|
|
78
|
+
style,
|
|
79
|
+
]}
|
|
80
|
+
>
|
|
70
81
|
{children}
|
|
71
82
|
</View>
|
|
72
83
|
);
|
|
@@ -31,6 +31,7 @@ import { HeaderTitle } from './Components/HeaderTitle';
|
|
|
31
31
|
import { HeaderBackground } from './Components/HeaderBackground';
|
|
32
32
|
import { type EdgeInsets } from 'react-native-safe-area-context';
|
|
33
33
|
import { useScreenLayout } from '../Layout';
|
|
34
|
+
import { getHeaderRowOptions } from '../Layout/utils';
|
|
34
35
|
import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
|
|
35
36
|
|
|
36
37
|
const Stack = createStackNavigator();
|
|
@@ -107,7 +108,7 @@ const Navigation: React.FC<any> = ({
|
|
|
107
108
|
const navigationRef = useRef<any>(null);
|
|
108
109
|
const isReady = useRef(false);
|
|
109
110
|
const navigator = useRef(new Navigator(navigationRef, isReady));
|
|
110
|
-
const
|
|
111
|
+
const headerRowOptions = getHeaderRowOptions(useScreenLayout());
|
|
111
112
|
const syncCanGoBack = useNativeCanGoBack();
|
|
112
113
|
const isWhiteList = isWhiteListDesignSystem(context);
|
|
113
114
|
const fontFamily = isWhiteList ? 'MomoTrustSans' : 'SFProText';
|
|
@@ -204,10 +205,7 @@ const Navigation: React.FC<any> = ({
|
|
|
204
205
|
gestureEnabled: false,
|
|
205
206
|
headerTitleAlign: 'center',
|
|
206
207
|
headerTintColor: theme.colors.text.default,
|
|
207
|
-
|
|
208
|
-
height: headerHeight,
|
|
209
|
-
},
|
|
210
|
-
headerStatusBarHeight,
|
|
208
|
+
...headerRowOptions,
|
|
211
209
|
}}
|
|
212
210
|
>
|
|
213
211
|
<Stack.Screen
|
package/Layout/Screen.tsx
CHANGED
|
@@ -525,6 +525,10 @@ const Screen = forwardRef(
|
|
|
525
525
|
searchWidth = layout.contentWidth - Spacing.M * 2;
|
|
526
526
|
searchLeft = Spacing.M - getHeaderMargin(layout.width);
|
|
527
527
|
}
|
|
528
|
+
if (layout.statusBarClearance) {
|
|
529
|
+
searchWidth -=
|
|
530
|
+
layout.statusBarClearance.left + layout.statusBarClearance.right;
|
|
531
|
+
}
|
|
528
532
|
const options: StackNavigationOptions = {
|
|
529
533
|
headerRight: undefined,
|
|
530
534
|
headerTitleContainerStyle: {
|
package/Layout/utils.ts
CHANGED
|
@@ -77,6 +77,14 @@ const HeaderMetrics = {
|
|
|
77
77
|
* - edgeInset: on the outer display in landscape the strip holds no status bar, so its
|
|
78
78
|
* controls start this far from the top and stay this far above the bottom, unless a
|
|
79
79
|
* reserved region (the camera at either end) asks for more
|
|
80
|
+
* - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
|
|
81
|
+
* clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
|
|
82
|
+
* digits centered on y 47); its reserved region, when reported, is taller than the glyphs
|
|
83
|
+
* - floatingTabBarHeight / floatingTabBarMargin / floatingTabBarInsetOverlap: the host
|
|
84
|
+
* app's iOS 26 system tab bar on the inner display in portrait, a 62pt Liquid Glass pill
|
|
85
|
+
* sized to its tabs with the QR as a pinned 62pt platter beside it, centered and at
|
|
86
|
+
* least 20pt from the side edges; it overlaps the home indicator inset by 13pt (21pt
|
|
87
|
+
* above the screen bottom with a 34pt inset)
|
|
80
88
|
*/
|
|
81
89
|
const DuoMetrics = {
|
|
82
90
|
maxAspectRatio: 1.6,
|
|
@@ -88,6 +96,11 @@ const DuoMetrics = {
|
|
|
88
96
|
outerHeaderHeight: 76,
|
|
89
97
|
outerCameraCenterY: 45,
|
|
90
98
|
edgeInset: 24,
|
|
99
|
+
innerStatusBarCenterY: 47,
|
|
100
|
+
innerStatusBarWidth: 120,
|
|
101
|
+
floatingTabBarHeight: 62,
|
|
102
|
+
floatingTabBarMargin: 20,
|
|
103
|
+
floatingTabBarInsetOverlap: 13,
|
|
91
104
|
};
|
|
92
105
|
|
|
93
106
|
const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
@@ -124,6 +137,39 @@ const getStripOcclusion = (
|
|
|
124
137
|
{ top: 0, topCenter: 0, bottom: 0 },
|
|
125
138
|
);
|
|
126
139
|
|
|
140
|
+
/**
|
|
141
|
+
* how far the clock and battery clusters of a status bar on the top edge reach in from
|
|
142
|
+
* each side. `undefined` without reserved regions there
|
|
143
|
+
*/
|
|
144
|
+
const getStatusBarClusters = (
|
|
145
|
+
regions: ReservedRegion[],
|
|
146
|
+
windowWidth: number,
|
|
147
|
+
topInset: number,
|
|
148
|
+
) => {
|
|
149
|
+
const midX = windowWidth / 2;
|
|
150
|
+
const clusters = regions.filter(
|
|
151
|
+
region =>
|
|
152
|
+
region.y < topInset &&
|
|
153
|
+
region.y + region.height <= topInset &&
|
|
154
|
+
(region.x + region.width <= midX || region.x >= midX),
|
|
155
|
+
);
|
|
156
|
+
if (!clusters.length) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return clusters.reduce(
|
|
161
|
+
(result, region) => {
|
|
162
|
+
if (region.x < midX) {
|
|
163
|
+
result.left = Math.max(result.left, region.x + region.width);
|
|
164
|
+
} else {
|
|
165
|
+
result.right = Math.max(result.right, windowWidth - region.x);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
},
|
|
169
|
+
{ left: 0, right: 0 },
|
|
170
|
+
);
|
|
171
|
+
};
|
|
172
|
+
|
|
127
173
|
const isIPhone = () =>
|
|
128
174
|
Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
|
|
129
175
|
|
|
@@ -232,6 +278,17 @@ export type ScreenLayout = {
|
|
|
232
278
|
* bars to the side. `undefined` while the bars stay horizontal.
|
|
233
279
|
*/
|
|
234
280
|
sideBar?: SideBarLayout;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Room the header row keeps from each side for the clock and battery clusters, when
|
|
284
|
+
* iPhone Duo puts the row on the status bar (inner display in portrait).
|
|
285
|
+
*/
|
|
286
|
+
statusBarClearance?: StatusBarClearance;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export type StatusBarClearance = {
|
|
290
|
+
left: number;
|
|
291
|
+
right: number;
|
|
235
292
|
};
|
|
236
293
|
|
|
237
294
|
/**
|
|
@@ -280,6 +337,23 @@ const useScreenLayout = (): ScreenLayout => {
|
|
|
280
337
|
};
|
|
281
338
|
}
|
|
282
339
|
|
|
340
|
+
let statusBarClearance: StatusBarClearance | undefined;
|
|
341
|
+
const isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight;
|
|
342
|
+
if (isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset) {
|
|
343
|
+
// the row shares the status bar band, between its clusters
|
|
344
|
+
const clusters = getStatusBarClusters(
|
|
345
|
+
reservedRegions,
|
|
346
|
+
width,
|
|
347
|
+
insets.top,
|
|
348
|
+
) ?? { left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S };
|
|
349
|
+
// center on the clock digits, the region center sits above them
|
|
350
|
+
headerLayout = {
|
|
351
|
+
headerHeight: DuoMetrics.innerStatusBarCenterY * 2,
|
|
352
|
+
headerStatusBarHeight: 0,
|
|
353
|
+
};
|
|
354
|
+
statusBarClearance = clusters;
|
|
355
|
+
}
|
|
356
|
+
|
|
283
357
|
const sideInset = Math.max(insets.left, insets.right);
|
|
284
358
|
let sideBar: SideBarLayout | undefined;
|
|
285
359
|
// narrower strips cannot host a control 12pt from the inner edge
|
|
@@ -295,7 +369,9 @@ const useScreenLayout = (): ScreenLayout => {
|
|
|
295
369
|
height,
|
|
296
370
|
);
|
|
297
371
|
|
|
298
|
-
let top =
|
|
372
|
+
let top = statusBarClearance
|
|
373
|
+
? headerLayout.headerHeight + Spacing.M
|
|
374
|
+
: insets.top + HeaderMetrics.barHeight + Spacing.M;
|
|
299
375
|
if (insets.top < HeaderMetrics.statusBarMinInset) {
|
|
300
376
|
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
301
377
|
const isLandscape = width > height;
|
|
@@ -342,9 +418,30 @@ const useScreenLayout = (): ScreenLayout => {
|
|
|
342
418
|
contentWidth: width - insets.left - insets.right,
|
|
343
419
|
...headerLayout,
|
|
344
420
|
sideBar,
|
|
421
|
+
statusBarClearance,
|
|
345
422
|
};
|
|
346
423
|
};
|
|
347
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Header row options for react-navigation: its height, the room for the status bar, and
|
|
427
|
+
* on iPhone Duo inner portrait the buttons kept clear of the clock and battery clusters
|
|
428
|
+
*/
|
|
429
|
+
const getHeaderRowOptions = ({
|
|
430
|
+
headerHeight,
|
|
431
|
+
headerStatusBarHeight,
|
|
432
|
+
statusBarClearance,
|
|
433
|
+
sideBar,
|
|
434
|
+
}: ScreenLayout) => ({
|
|
435
|
+
headerStyle: { height: headerHeight },
|
|
436
|
+
headerStatusBarHeight,
|
|
437
|
+
// side bar controls are absolute within the containers, padding would not move them
|
|
438
|
+
...(statusBarClearance &&
|
|
439
|
+
!sideBar && {
|
|
440
|
+
headerLeftContainerStyle: { paddingLeft: statusBarClearance.left },
|
|
441
|
+
headerRightContainerStyle: { paddingRight: statusBarClearance.right },
|
|
442
|
+
}),
|
|
443
|
+
});
|
|
444
|
+
|
|
348
445
|
/**
|
|
349
446
|
* Side margin @react-navigation/elements Header adds to its content row
|
|
350
447
|
*/
|
|
@@ -410,6 +507,7 @@ export {
|
|
|
410
507
|
DuoMetrics,
|
|
411
508
|
HeaderMetrics,
|
|
412
509
|
getHeaderMargin,
|
|
510
|
+
getHeaderRowOptions,
|
|
413
511
|
useGridSystem,
|
|
414
512
|
useScreenLayout,
|
|
415
513
|
useSideBarItemStyle,
|