@momo-kits/foundation 0.167.1-duotest.1 → 0.167.1-duotest.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 +2 -2
- package/Application/BottomTab/BottomTabBar.tsx +2 -2
- package/Application/BottomTab/DuoBarCompression.tsx +3 -3
- package/Application/BottomTab/DuoTabBar.tsx +197 -101
- package/Application/BottomTab/index.tsx +15 -5
- package/Application/ModalScreen.tsx +2 -2
- package/Application/NavigationContainer.tsx +2 -1
- package/Application/utils.tsx +1 -9
- package/Layout/utils.ts +103 -6
- package/Popup/utils.ts +4 -4
- package/Text/utils.ts +4 -3
- package/package.json +1 -1
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
Pressable,
|
|
19
19
|
StyleSheet,
|
|
20
20
|
TouchableOpacity,
|
|
21
|
-
useWindowDimensions,
|
|
22
21
|
View,
|
|
23
22
|
} from 'react-native';
|
|
24
23
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -40,6 +39,7 @@ import Animated, {
|
|
|
40
39
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
41
40
|
import layoutStyles from '../Layout/styles';
|
|
42
41
|
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
42
|
+
import { useWindowLayout } from '../Layout/utils';
|
|
43
43
|
|
|
44
44
|
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
45
45
|
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
@@ -47,7 +47,7 @@ const DUO_REGULAR_MIN_WIDTH = 600;
|
|
|
47
47
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
48
48
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
49
49
|
const context = useContext<any>(MiniAppContext);
|
|
50
|
-
const windowSize =
|
|
50
|
+
const windowSize = useWindowLayout();
|
|
51
51
|
const isDuo = isIPhoneDuo();
|
|
52
52
|
const isDuoCentered = isDuo && windowSize.width >= DUO_REGULAR_MIN_WIDTH;
|
|
53
53
|
const isPortrait = windowSize.height > windowSize.width;
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
Platform,
|
|
6
6
|
StyleSheet,
|
|
7
7
|
TouchableOpacity,
|
|
8
|
-
useWindowDimensions,
|
|
9
8
|
View,
|
|
10
9
|
} from 'react-native';
|
|
11
10
|
import Animated, {
|
|
@@ -19,6 +18,7 @@ import { Icon } from '../../Icon';
|
|
|
19
18
|
import { Text } from '../../Text';
|
|
20
19
|
import { FloatingButtonProps } from '../types';
|
|
21
20
|
import CustomBottomTabItem from './CustomBottomTabItem';
|
|
21
|
+
import { useWindowLayout } from '../../Layout/utils';
|
|
22
22
|
import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
23
23
|
|
|
24
24
|
type Props = BottomTabBarProps & {
|
|
@@ -57,7 +57,7 @@ export default function BottomTabBar({
|
|
|
57
57
|
}: Props) {
|
|
58
58
|
const { theme } = useContext(ApplicationContext);
|
|
59
59
|
const { routes } = state;
|
|
60
|
-
const widthDevice =
|
|
60
|
+
const widthDevice = useWindowLayout()?.width;
|
|
61
61
|
const insets = useSafeAreaInsets();
|
|
62
62
|
const useFloating = floatingButton && state.routes.length % 2 === 0;
|
|
63
63
|
const itemWidth =
|
|
@@ -34,8 +34,6 @@ const DEFAULT_TOOLBAR_HEIGHT =
|
|
|
34
34
|
DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
|
|
35
35
|
// Back, then the single button the toolbar folds into
|
|
36
36
|
const COMPRESSED_TOOLBAR_HEIGHT = 2 * DuoMetrics.controlSize + Spacing.M;
|
|
37
|
-
// Back alone, while a rail opened from the compressed state takes the toolbar slot
|
|
38
|
-
const BACK_ONLY_HEIGHT = DuoMetrics.controlSize;
|
|
39
37
|
|
|
40
38
|
const NO_COMPRESSION: DuoBarCompression = {
|
|
41
39
|
toolbarCompressed: false,
|
|
@@ -107,7 +105,9 @@ const DuoBarCompressionProvider: React.FC<{
|
|
|
107
105
|
expandTabBar: () => setOpened('tabBar'),
|
|
108
106
|
setToolbarHeight,
|
|
109
107
|
};
|
|
110
|
-
|
|
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
111
|
return {
|
|
112
112
|
...open,
|
|
113
113
|
toolbarCompressed: true,
|
|
@@ -13,7 +13,7 @@ import Animated, {
|
|
|
13
13
|
withSpring,
|
|
14
14
|
} from 'react-native-reanimated';
|
|
15
15
|
import { Colors, Radius, Spacing } from '../../Consts';
|
|
16
|
-
import { ApplicationContext } from '../../Context';
|
|
16
|
+
import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
17
17
|
import { Icon } from '../../Icon';
|
|
18
18
|
import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
|
|
19
19
|
import { Text } from '../../Text';
|
|
@@ -33,40 +33,49 @@ const ICON_SIZE = 28;
|
|
|
33
33
|
const QR_ICON_SIZE = 24;
|
|
34
34
|
const RAIL_PADDING = 6;
|
|
35
35
|
const RAIL_GAP = Spacing.XXS;
|
|
36
|
+
const RAIL_PITCH = ITEM_SIZE + RAIL_GAP;
|
|
36
37
|
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
37
38
|
const RAIL_BUBBLE_WIDTH = 44;
|
|
38
39
|
const RAIL_BUBBLE_HEIGHT = 58;
|
|
39
|
-
const FLOATING_HEIGHT =
|
|
40
|
+
const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
|
|
40
41
|
const FLOATING_PADDING = Spacing.XS;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
// the system bar sizes its pill to the tabs instead of stretching it across the display
|
|
43
|
+
const FLOATING_ITEM_WIDTH = 80;
|
|
44
|
+
// while pressed the selection bubble becomes the iOS 26 glass lens, growing past the
|
|
45
|
+
// pill's edges, wider and taller (80x54 to about 96x70)
|
|
46
|
+
const LENS_SCALE_X = 1.2;
|
|
47
|
+
const LENS_SCALE_Y = 1.3;
|
|
48
|
+
const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
|
|
45
49
|
const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* distance from the screen bottom to the floating tab bar, overlapping the home
|
|
53
|
+
* indicator inset like the system tab bar
|
|
54
|
+
*/
|
|
55
|
+
const getFloatingTabBarBottom = (bottomInset: number) =>
|
|
56
|
+
Math.max(bottomInset - DuoMetrics.floatingTabBarInsetOverlap, Spacing.S);
|
|
57
|
+
|
|
47
58
|
/**
|
|
48
59
|
* space tab content leaves under the Duo floating tab bar, 0 elsewhere
|
|
49
60
|
*/
|
|
50
61
|
const useDuoTabBarInset = () => {
|
|
51
62
|
const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
|
|
52
63
|
return isIPhoneDuo && !sideBar
|
|
53
|
-
? FLOATING_HEIGHT +
|
|
64
|
+
? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
|
|
54
65
|
: 0;
|
|
55
66
|
};
|
|
56
67
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
68
|
+
const BarMaterial: React.FC<{ radius: number; interactive?: boolean }> = ({
|
|
69
|
+
radius,
|
|
70
|
+
interactive,
|
|
71
|
+
}) => {
|
|
64
72
|
const { theme } = useContext(ApplicationContext);
|
|
65
73
|
if (GlassView) {
|
|
66
74
|
return (
|
|
67
75
|
<GlassView
|
|
68
76
|
pointerEvents={'none'}
|
|
69
77
|
effect={'glass'}
|
|
78
|
+
interactive={interactive}
|
|
70
79
|
cornerRadius={radius}
|
|
71
80
|
useShadow
|
|
72
81
|
style={StyleSheet.absoluteFill}
|
|
@@ -89,6 +98,67 @@ const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
|
89
98
|
);
|
|
90
99
|
};
|
|
91
100
|
|
|
101
|
+
// Figma Home-2026 tab bar "Selection": brand pink at 8% over glass
|
|
102
|
+
const SELECTION_TINT_ALPHA = '14';
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* translucent glass capsule under the selected floating tab with the Figma pink tint on
|
|
106
|
+
* top; the tint is a separate layer since a glass tint color renders opaque
|
|
107
|
+
*/
|
|
108
|
+
const SelectionMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
109
|
+
const { theme } = useContext(ApplicationContext);
|
|
110
|
+
return (
|
|
111
|
+
<>
|
|
112
|
+
{GlassView && (
|
|
113
|
+
<GlassView
|
|
114
|
+
pointerEvents={'none'}
|
|
115
|
+
effect={'glass'}
|
|
116
|
+
cornerRadius={radius}
|
|
117
|
+
style={StyleSheet.absoluteFill}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
<View
|
|
121
|
+
pointerEvents={'none'}
|
|
122
|
+
style={[
|
|
123
|
+
StyleSheet.absoluteFill,
|
|
124
|
+
{
|
|
125
|
+
borderRadius: radius,
|
|
126
|
+
backgroundColor: theme.colors.primary + SELECTION_TINT_ALPHA,
|
|
127
|
+
},
|
|
128
|
+
]}
|
|
129
|
+
/>
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* tab label at its design size: like the system tab bar it doesn't grow with the wide
|
|
136
|
+
* Duo displays, which Text otherwise scales up to 5pt
|
|
137
|
+
*/
|
|
138
|
+
const TabLabel: React.FC<{
|
|
139
|
+
label: string;
|
|
140
|
+
color: string;
|
|
141
|
+
typography: 'label_xs_medium';
|
|
142
|
+
}> = ({ label, color, typography }) => {
|
|
143
|
+
const context = useContext<any>(MiniAppContext);
|
|
144
|
+
return (
|
|
145
|
+
<MiniAppContext.Provider
|
|
146
|
+
value={{
|
|
147
|
+
...context,
|
|
148
|
+
fontScale: { useOSScaleRate: false, userScaleRate: 1 },
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<Text
|
|
152
|
+
numberOfLines={1}
|
|
153
|
+
typography={typography}
|
|
154
|
+
color={color}
|
|
155
|
+
>
|
|
156
|
+
{label}
|
|
157
|
+
</Text>
|
|
158
|
+
</MiniAppContext.Provider>
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
92
162
|
const getTabItems = ({
|
|
93
163
|
state,
|
|
94
164
|
descriptors,
|
|
@@ -150,41 +220,52 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
|
|
|
150
220
|
type TabItem = ReturnType<typeof getTabItems>[number];
|
|
151
221
|
|
|
152
222
|
/**
|
|
153
|
-
* glass column of tab items with the sliding selection bubble
|
|
223
|
+
* glass column of icon-only tab items with the sliding selection bubble
|
|
154
224
|
*/
|
|
155
225
|
const Rail: React.FC<{
|
|
156
226
|
items: TabItem[];
|
|
157
227
|
index: number;
|
|
158
228
|
onItemPress?: () => void;
|
|
159
229
|
}> = ({ items, index, onItemPress }) => {
|
|
160
|
-
const
|
|
161
|
-
const
|
|
230
|
+
const bubbleOffset = useSharedValue(index * RAIL_PITCH);
|
|
231
|
+
const lens = useSharedValue(0);
|
|
162
232
|
|
|
163
233
|
useEffect(() => {
|
|
164
|
-
bubbleOffset.value = withSpring(index *
|
|
234
|
+
bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
|
|
165
235
|
}, [index, bubbleOffset]);
|
|
166
236
|
|
|
167
237
|
const bubbleStyle = useAnimatedStyle(() => ({
|
|
168
|
-
transform: [
|
|
238
|
+
transform: [
|
|
239
|
+
{ translateY: bubbleOffset.value },
|
|
240
|
+
{ scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
|
|
241
|
+
{ scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
|
|
242
|
+
],
|
|
169
243
|
}));
|
|
170
244
|
|
|
171
245
|
return (
|
|
172
246
|
<View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
|
|
173
|
-
<BarMaterial radius={
|
|
247
|
+
<BarMaterial radius={ITEM_SIZE / 2} interactive />
|
|
174
248
|
<Animated.View
|
|
175
249
|
pointerEvents={'none'}
|
|
176
|
-
style={[
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
/>
|
|
182
|
-
{items.map(item => (
|
|
250
|
+
style={[styles.railBubble, bubbleStyle]}
|
|
251
|
+
>
|
|
252
|
+
<SelectionMaterial radius={RAIL_BUBBLE_WIDTH / 2} />
|
|
253
|
+
</Animated.View>
|
|
254
|
+
{items.map((item, itemIndex) => (
|
|
183
255
|
<TouchableOpacity
|
|
184
256
|
key={item.route.key}
|
|
185
257
|
accessibilityRole={'tab'}
|
|
186
258
|
accessibilityLabel={item.label}
|
|
187
259
|
accessibilityState={{ selected: item.focused }}
|
|
260
|
+
activeOpacity={1}
|
|
261
|
+
onPressIn={() => {
|
|
262
|
+
bubbleOffset.value = withSpring(itemIndex * RAIL_PITCH, SPRING);
|
|
263
|
+
lens.value = withSpring(1, SPRING);
|
|
264
|
+
}}
|
|
265
|
+
onPressOut={() => {
|
|
266
|
+
bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
|
|
267
|
+
lens.value = withSpring(0, SPRING);
|
|
268
|
+
}}
|
|
188
269
|
onPress={() => {
|
|
189
270
|
item.onPress();
|
|
190
271
|
onItemPress?.();
|
|
@@ -267,65 +348,103 @@ const DuoRailTabBar: React.FC<Props> = props => {
|
|
|
267
348
|
};
|
|
268
349
|
|
|
269
350
|
/**
|
|
270
|
-
* floating labelled bar for the unfolded portrait display, QR in
|
|
351
|
+
* floating labelled glass bar for the unfolded portrait display, QR pinned in its own
|
|
352
|
+
* platter on the trailing side, like the host app's iOS 26 system tab bar
|
|
271
353
|
*/
|
|
272
354
|
const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
273
355
|
const { theme } = useContext(ApplicationContext);
|
|
274
|
-
const {
|
|
356
|
+
const { insets } = useScreenLayout();
|
|
275
357
|
const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
|
|
276
358
|
const items = getTabItems(props);
|
|
277
359
|
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;
|
|
360
|
+
const cellWidth = rowWidth / state.routes.length;
|
|
285
361
|
|
|
286
362
|
const bubbleOffset = useSharedValue(0);
|
|
363
|
+
const lens = useSharedValue(0);
|
|
287
364
|
const measured = useRef(false);
|
|
288
365
|
|
|
289
366
|
useEffect(() => {
|
|
290
|
-
const target =
|
|
367
|
+
const target = state.index * cellWidth;
|
|
291
368
|
// snap on first layout so the bubble doesn't slide in from the left edge
|
|
292
369
|
bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
|
|
293
370
|
measured.current = cellWidth > 0;
|
|
294
|
-
}, [
|
|
371
|
+
}, [state.index, cellWidth, bubbleOffset]);
|
|
295
372
|
|
|
296
373
|
const bubbleStyle = useAnimatedStyle(() => ({
|
|
297
|
-
transform: [
|
|
374
|
+
transform: [
|
|
375
|
+
{ translateX: bubbleOffset.value },
|
|
376
|
+
{ scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
|
|
377
|
+
{ scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
|
|
378
|
+
],
|
|
298
379
|
}));
|
|
299
380
|
|
|
300
381
|
const onRowLayout = (e: LayoutChangeEvent) =>
|
|
301
382
|
setRowWidth(e.nativeEvent.layout.width);
|
|
302
383
|
|
|
303
|
-
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
384
|
+
return (
|
|
385
|
+
<View
|
|
386
|
+
pointerEvents={'box-none'}
|
|
387
|
+
style={[
|
|
388
|
+
styles.floating,
|
|
389
|
+
{
|
|
390
|
+
left: insets.left + DuoMetrics.floatingTabBarMargin,
|
|
391
|
+
right: insets.right + DuoMetrics.floatingTabBarMargin,
|
|
392
|
+
bottom: getFloatingTabBarBottom(insets.bottom),
|
|
393
|
+
},
|
|
394
|
+
]}
|
|
312
395
|
>
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
396
|
+
<View
|
|
397
|
+
style={[
|
|
398
|
+
styles.floatingBar,
|
|
399
|
+
{
|
|
400
|
+
width:
|
|
401
|
+
state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
|
|
402
|
+
},
|
|
403
|
+
]}
|
|
404
|
+
accessibilityLabel={'bottom_tab_bar'}
|
|
318
405
|
>
|
|
319
|
-
{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
406
|
+
<BarMaterial radius={FLOATING_HEIGHT / 2} interactive />
|
|
407
|
+
<View style={styles.floatingRow} onLayout={onRowLayout}>
|
|
408
|
+
{rowWidth > 0 && (
|
|
409
|
+
<Animated.View
|
|
410
|
+
pointerEvents={'none'}
|
|
411
|
+
style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
|
|
412
|
+
>
|
|
413
|
+
<SelectionMaterial radius={FLOATING_BUBBLE_RADIUS} />
|
|
414
|
+
</Animated.View>
|
|
415
|
+
)}
|
|
416
|
+
{items.map((item, index) => (
|
|
417
|
+
<TouchableOpacity
|
|
418
|
+
key={item.route.key}
|
|
419
|
+
accessibilityRole={'tab'}
|
|
420
|
+
accessibilityLabel={item.label}
|
|
421
|
+
accessibilityState={{ selected: item.focused }}
|
|
422
|
+
activeOpacity={1}
|
|
423
|
+
onPressIn={() => {
|
|
424
|
+
// the lens jumps to the pressed tab, not only the selected one
|
|
425
|
+
bubbleOffset.value = withSpring(index * cellWidth, SPRING);
|
|
426
|
+
lens.value = withSpring(1, SPRING);
|
|
427
|
+
}}
|
|
428
|
+
onPressOut={() => {
|
|
429
|
+
// onPress follows synchronously, a tab switch re-targets the bubble
|
|
430
|
+
bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
|
|
431
|
+
lens.value = withSpring(0, SPRING);
|
|
432
|
+
}}
|
|
433
|
+
onPress={item.onPress}
|
|
434
|
+
onLongPress={item.onLongPress}
|
|
435
|
+
style={styles.floatingItem}
|
|
436
|
+
>
|
|
437
|
+
{item.icon}
|
|
438
|
+
<TabLabel
|
|
439
|
+
label={item.label}
|
|
440
|
+
typography={'label_xs_medium'}
|
|
441
|
+
color={item.focused ? activeTintColor : inactiveTintColor}
|
|
442
|
+
/>
|
|
443
|
+
</TouchableOpacity>
|
|
444
|
+
))}
|
|
445
|
+
</View>
|
|
446
|
+
</View>
|
|
447
|
+
{floatingButton && (
|
|
329
448
|
<TouchableOpacity
|
|
330
449
|
accessibilityRole={'button'}
|
|
331
450
|
accessibilityLabel={floatingButton.label}
|
|
@@ -334,39 +453,7 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
|
334
453
|
>
|
|
335
454
|
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
336
455
|
</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>
|
|
456
|
+
)}
|
|
370
457
|
</View>
|
|
371
458
|
);
|
|
372
459
|
};
|
|
@@ -413,7 +500,6 @@ const styles = StyleSheet.create({
|
|
|
413
500
|
left: (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
414
501
|
width: RAIL_BUBBLE_WIDTH,
|
|
415
502
|
height: RAIL_BUBBLE_HEIGHT,
|
|
416
|
-
borderRadius: RAIL_BUBBLE_WIDTH / 2,
|
|
417
503
|
},
|
|
418
504
|
railItem: {
|
|
419
505
|
width: ITEM_SIZE,
|
|
@@ -430,6 +516,12 @@ const styles = StyleSheet.create({
|
|
|
430
516
|
},
|
|
431
517
|
floating: {
|
|
432
518
|
position: 'absolute',
|
|
519
|
+
flexDirection: 'row',
|
|
520
|
+
justifyContent: 'center',
|
|
521
|
+
gap: Spacing.S,
|
|
522
|
+
},
|
|
523
|
+
floatingBar: {
|
|
524
|
+
flexShrink: 1,
|
|
433
525
|
height: FLOATING_HEIGHT,
|
|
434
526
|
padding: FLOATING_PADDING,
|
|
435
527
|
},
|
|
@@ -442,7 +534,6 @@ const styles = StyleSheet.create({
|
|
|
442
534
|
top: 0,
|
|
443
535
|
bottom: 0,
|
|
444
536
|
left: 0,
|
|
445
|
-
borderRadius: (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2,
|
|
446
537
|
},
|
|
447
538
|
floatingItem: {
|
|
448
539
|
flex: 1,
|
|
@@ -450,12 +541,17 @@ const styles = StyleSheet.create({
|
|
|
450
541
|
justifyContent: 'center',
|
|
451
542
|
gap: Spacing.XXS,
|
|
452
543
|
},
|
|
544
|
+
// pink fills the whole pinned platter like the host app, shadow matches the glass bar
|
|
453
545
|
floatingQr: {
|
|
454
|
-
width:
|
|
455
|
-
height:
|
|
456
|
-
borderRadius:
|
|
546
|
+
width: FLOATING_HEIGHT,
|
|
547
|
+
height: FLOATING_HEIGHT,
|
|
548
|
+
borderRadius: FLOATING_HEIGHT / 2,
|
|
457
549
|
alignItems: 'center',
|
|
458
550
|
justifyContent: 'center',
|
|
551
|
+
shadowColor: Colors.black_20,
|
|
552
|
+
shadowOffset: { width: 0, height: 6 },
|
|
553
|
+
shadowOpacity: 0.16,
|
|
554
|
+
shadowRadius: 16,
|
|
459
555
|
},
|
|
460
556
|
});
|
|
461
557
|
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
Platform,
|
|
13
13
|
StatusBar,
|
|
14
14
|
StyleSheet,
|
|
15
|
-
useWindowDimensions,
|
|
16
15
|
View,
|
|
17
16
|
} from 'react-native';
|
|
18
17
|
import Animated, {
|
|
@@ -29,9 +28,13 @@ import { getOptions, getStackOptions, useAppState } from '../utils';
|
|
|
29
28
|
import BottomTabBar from './BottomTabBar';
|
|
30
29
|
import { DuoTabBar } from './DuoTabBar';
|
|
31
30
|
import { DuoBarCompressionProvider } from './DuoBarCompression';
|
|
32
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
ApplicationContext,
|
|
33
|
+
MiniAppContext,
|
|
34
|
+
ScreenContext,
|
|
35
|
+
} from '../../Context';
|
|
33
36
|
import { useScreenLayout } from '../../Layout';
|
|
34
|
-
import { getHeaderRowOptions } from '../../Layout/utils';
|
|
37
|
+
import { getHeaderRowOptions, useWindowLayout } from '../../Layout/utils';
|
|
35
38
|
|
|
36
39
|
const Tab = createBottomTabNavigator();
|
|
37
40
|
const Stack = createStackNavigator();
|
|
@@ -204,8 +207,15 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
204
207
|
compressionBehavior,
|
|
205
208
|
}) => {
|
|
206
209
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
210
|
+
const screen: any = useContext(ScreenContext);
|
|
207
211
|
const { isIPhoneDuo } = useScreenLayout();
|
|
208
|
-
|
|
212
|
+
// each tab lays itself out, the hosting stack screen must not add a header or insets
|
|
213
|
+
screen?.claimHeader?.();
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
screen?.syncHeader?.();
|
|
216
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
217
|
+
}, []);
|
|
218
|
+
const dimensions = useWindowLayout();
|
|
209
219
|
const insets = useSafeAreaInsets();
|
|
210
220
|
const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
|
|
211
221
|
const indicatorAnimated = useSharedValue(0);
|
|
@@ -316,7 +326,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
316
326
|
<DuoTabBar
|
|
317
327
|
{...props}
|
|
318
328
|
floatingButton={floatingButton}
|
|
319
|
-
inactiveTintColor={theme.colors.text.
|
|
329
|
+
inactiveTintColor={theme.colors.text.hint}
|
|
320
330
|
activeTintColor={theme.colors.primary}
|
|
321
331
|
/>
|
|
322
332
|
) : (
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
Platform,
|
|
9
9
|
Pressable,
|
|
10
10
|
StyleSheet,
|
|
11
|
-
useWindowDimensions,
|
|
12
11
|
View,
|
|
13
12
|
} from 'react-native';
|
|
14
13
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -19,6 +18,7 @@ import Navigation from './Navigation';
|
|
|
19
18
|
import { ModalParams } from './types';
|
|
20
19
|
import BottomSheet from './BottomSheet';
|
|
21
20
|
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
21
|
+
import { useWindowLayout } from '../Layout/utils';
|
|
22
22
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
23
23
|
|
|
24
24
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -41,7 +41,7 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
41
41
|
} = props.route.params;
|
|
42
42
|
const Component = useRef(screen).current;
|
|
43
43
|
const insets = useSafeAreaInsets();
|
|
44
|
-
const windowSize =
|
|
44
|
+
const windowSize = useWindowLayout();
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
47
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -31,7 +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
|
+
import { getHeaderRowOptions, useSideInsetsSync } from '../Layout/utils';
|
|
35
35
|
import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
|
|
36
36
|
|
|
37
37
|
const Stack = createStackNavigator();
|
|
@@ -108,6 +108,7 @@ const Navigation: React.FC<any> = ({
|
|
|
108
108
|
const navigationRef = useRef<any>(null);
|
|
109
109
|
const isReady = useRef(false);
|
|
110
110
|
const navigator = useRef(new Navigator(navigationRef, isReady));
|
|
111
|
+
useSideInsetsSync();
|
|
111
112
|
const headerRowOptions = getHeaderRowOptions(useScreenLayout());
|
|
112
113
|
const syncCanGoBack = useNativeCanGoBack();
|
|
113
114
|
const isWhiteList = isWhiteListDesignSystem(context);
|
package/Application/utils.tsx
CHANGED
|
@@ -24,12 +24,7 @@ import type {
|
|
|
24
24
|
} from './types';
|
|
25
25
|
import { ApplicationContext } from '../Context';
|
|
26
26
|
import { Colors, Spacing } from '../Consts';
|
|
27
|
-
import {
|
|
28
|
-
Animated,
|
|
29
|
-
AppState,
|
|
30
|
-
NativeModules,
|
|
31
|
-
Platform,
|
|
32
|
-
} from 'react-native';
|
|
27
|
+
import { Animated, AppState, NativeModules, Platform } from 'react-native';
|
|
33
28
|
import { useHeaderHeight } from '@react-navigation/elements';
|
|
34
29
|
import type { SharedValue } from 'react-native-reanimated';
|
|
35
30
|
import {
|
|
@@ -619,7 +614,6 @@ const useHeaderScrollState = (
|
|
|
619
614
|
...quickReturnOptions,
|
|
620
615
|
headerTransparent: true,
|
|
621
616
|
headerTintColor: Colors.black_17,
|
|
622
|
-
headerShown: true,
|
|
623
617
|
headerBackground: () => (
|
|
624
618
|
<DuoGlassBackground animatedValue={scrollY} />
|
|
625
619
|
),
|
|
@@ -629,7 +623,6 @@ const useHeaderScrollState = (
|
|
|
629
623
|
header: undefined,
|
|
630
624
|
headerTransparent: false,
|
|
631
625
|
headerTintColor: Colors.black_17,
|
|
632
|
-
headerShown: true,
|
|
633
626
|
headerBackground: (props: any) => (
|
|
634
627
|
<HeaderBackground
|
|
635
628
|
{...props}
|
|
@@ -719,7 +712,6 @@ const useHeaderScrollState = (
|
|
|
719
712
|
return { scrollY, onScrollChange, setScrollOffset, background, contentStyle };
|
|
720
713
|
};
|
|
721
714
|
|
|
722
|
-
|
|
723
715
|
export {
|
|
724
716
|
getStackOptions,
|
|
725
717
|
getModalOptions,
|
package/Layout/utils.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
|
|
1
|
+
import React, {
|
|
2
|
+
Fragment,
|
|
3
|
+
ReactElement,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useState,
|
|
8
|
+
useSyncExternalStore,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { Dimensions, Platform, ScaledSize, ViewStyle } from 'react-native';
|
|
3
11
|
import {
|
|
4
12
|
EdgeInsets,
|
|
5
13
|
SafeAreaInsetsContext,
|
|
@@ -80,6 +88,11 @@ const HeaderMetrics = {
|
|
|
80
88
|
* - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
|
|
81
89
|
* clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
|
|
82
90
|
* digits centered on y 47); its reserved region, when reported, is taller than the glyphs
|
|
91
|
+
* - floatingTabBarHeight / floatingTabBarMargin / floatingTabBarInsetOverlap: the host
|
|
92
|
+
* app's iOS 26 system tab bar on the inner display in portrait, a 62pt Liquid Glass pill
|
|
93
|
+
* sized to its tabs with the QR as a pinned 62pt platter beside it, centered and at
|
|
94
|
+
* least 20pt from the side edges; it overlaps the home indicator inset by 13pt (21pt
|
|
95
|
+
* above the screen bottom with a 34pt inset)
|
|
83
96
|
*/
|
|
84
97
|
const DuoMetrics = {
|
|
85
98
|
maxAspectRatio: 1.6,
|
|
@@ -93,6 +106,9 @@ const DuoMetrics = {
|
|
|
93
106
|
edgeInset: 24,
|
|
94
107
|
innerStatusBarCenterY: 47,
|
|
95
108
|
innerStatusBarWidth: 120,
|
|
109
|
+
floatingTabBarHeight: 62,
|
|
110
|
+
floatingTabBarMargin: 20,
|
|
111
|
+
floatingTabBarInsetOverlap: 13,
|
|
96
112
|
};
|
|
97
113
|
|
|
98
114
|
const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
@@ -224,12 +240,12 @@ export type SideBarLayout = {
|
|
|
224
240
|
|
|
225
241
|
export type ScreenLayout = {
|
|
226
242
|
/**
|
|
227
|
-
* Window width,
|
|
243
|
+
* Window width, side strips included.
|
|
228
244
|
*/
|
|
229
245
|
width: number;
|
|
230
246
|
|
|
231
247
|
/**
|
|
232
|
-
* Window height
|
|
248
|
+
* Window height.
|
|
233
249
|
*/
|
|
234
250
|
height: number;
|
|
235
251
|
|
|
@@ -304,13 +320,91 @@ const isIPhoneDuoLayout = (
|
|
|
304
320
|
return isSquare || insets.left !== insets.right;
|
|
305
321
|
};
|
|
306
322
|
|
|
323
|
+
/**
|
|
324
|
+
* On iPhone Duo the side strips belong to the side bar, so the 'window' every screen
|
|
325
|
+
* reads (Dimensions, useWindowDimensions) is narrowed to the content width. Mini-apps
|
|
326
|
+
* that size views from the window then fit without code changes. Kits parts that span
|
|
327
|
+
* the real window (side bar, header, sheets, popups) read useWindowLayout instead.
|
|
328
|
+
*/
|
|
329
|
+
let realWindow: ScaledSize = Dimensions.get('window');
|
|
330
|
+
let sideInsets = { left: 0, right: 0 };
|
|
331
|
+
const windowListeners = new Set<() => void>();
|
|
332
|
+
let sideInsetsOwner: object | undefined;
|
|
333
|
+
|
|
334
|
+
const toContentWindow = (window: ScaledSize): ScaledSize => {
|
|
335
|
+
const isIPhoneDuo = isIPhoneDuoLayout(window.width, window.height, {
|
|
336
|
+
...NO_INSETS,
|
|
337
|
+
...sideInsets,
|
|
338
|
+
});
|
|
339
|
+
return isIPhoneDuo
|
|
340
|
+
? { ...window, width: window.width - sideInsets.left - sideInsets.right }
|
|
341
|
+
: window;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (Platform.OS === 'ios') {
|
|
345
|
+
const setDimensions = Dimensions.set;
|
|
346
|
+
// native rotation / fold updates come through Dimensions.set
|
|
347
|
+
Dimensions.set = (dims: { window: ScaledSize; screen: ScaledSize }) => {
|
|
348
|
+
if (dims.window !== realWindow) {
|
|
349
|
+
realWindow = dims.window;
|
|
350
|
+
windowListeners.forEach(listener => listener());
|
|
351
|
+
}
|
|
352
|
+
setDimensions({ ...dims, window: toContentWindow(dims.window) });
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// the outermost NavigationContainer reports the side insets, they follow the window
|
|
357
|
+
const useSideInsetsSync = () => {
|
|
358
|
+
const insets = useContext(SafeAreaInsetsContext);
|
|
359
|
+
const [owner] = useState({});
|
|
360
|
+
const left = insets?.left ?? 0;
|
|
361
|
+
const right = insets?.right ?? 0;
|
|
362
|
+
|
|
363
|
+
useLayoutEffect(() => {
|
|
364
|
+
sideInsetsOwner = sideInsetsOwner ?? owner;
|
|
365
|
+
if (
|
|
366
|
+
Platform.OS !== 'ios' ||
|
|
367
|
+
sideInsetsOwner !== owner ||
|
|
368
|
+
(sideInsets.left === left && sideInsets.right === right)
|
|
369
|
+
) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
sideInsets = { left, right };
|
|
373
|
+
Dimensions.set({ window: realWindow, screen: Dimensions.get('screen') });
|
|
374
|
+
}, [owner, left, right]);
|
|
375
|
+
|
|
376
|
+
useEffect(
|
|
377
|
+
() => () => {
|
|
378
|
+
if (sideInsetsOwner === owner) {
|
|
379
|
+
sideInsetsOwner = undefined;
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
[owner],
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const subscribeWindow = (listener: () => void) => {
|
|
387
|
+
windowListeners.add(listener);
|
|
388
|
+
return () => {
|
|
389
|
+
windowListeners.delete(listener);
|
|
390
|
+
};
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const getWindowLayout = () => realWindow;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The real window, side strips included. useWindowDimensions is narrowed on iPhone Duo.
|
|
397
|
+
*/
|
|
398
|
+
const useWindowLayout = () =>
|
|
399
|
+
useSyncExternalStore(subscribeWindow, getWindowLayout);
|
|
400
|
+
|
|
307
401
|
/**
|
|
308
402
|
* Screen layout for the current window: iPhone Duo detection, horizontal content insets,
|
|
309
403
|
* content width and the side bar. Follows the window through fold, unfold, rotation and
|
|
310
|
-
* Split View since it reads
|
|
404
|
+
* Split View since it reads the real window and the safe-area context.
|
|
311
405
|
*/
|
|
312
406
|
const useScreenLayout = (): ScreenLayout => {
|
|
313
|
-
const { width, height } =
|
|
407
|
+
const { width, height } = useWindowLayout();
|
|
314
408
|
const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
|
|
315
409
|
const reservedRegions = useContext(ReservedRegionsContext);
|
|
316
410
|
|
|
@@ -500,8 +594,11 @@ export {
|
|
|
500
594
|
HeaderMetrics,
|
|
501
595
|
getHeaderMargin,
|
|
502
596
|
getHeaderRowOptions,
|
|
597
|
+
getWindowLayout,
|
|
503
598
|
useGridSystem,
|
|
504
599
|
useScreenLayout,
|
|
600
|
+
useSideInsetsSync,
|
|
505
601
|
useSideBarItemStyle,
|
|
602
|
+
useWindowLayout,
|
|
506
603
|
validateChildren,
|
|
507
604
|
};
|
package/Popup/utils.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getWindowLayout, useWindowLayout } from '../Layout/utils';
|
|
2
2
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
3
|
import { Spacing } from '../Consts';
|
|
4
4
|
import { getDuoTopInset, isIPhoneDuo } from '../Application/device';
|
|
@@ -7,9 +7,9 @@ import { getDuoTopInset, isIPhoneDuo } from '../Application/device';
|
|
|
7
7
|
const DUO_POPUP_MAX_WIDTH = 440;
|
|
8
8
|
|
|
9
9
|
const usePopupWidth = () => {
|
|
10
|
-
const windowSize =
|
|
10
|
+
const windowSize = useWindowLayout();
|
|
11
11
|
if (!isIPhoneDuo()) {
|
|
12
|
-
return
|
|
12
|
+
return getWindowLayout().width - Spacing.XL * 2;
|
|
13
13
|
}
|
|
14
14
|
return Math.min(windowSize.width - Spacing.XL * 2, DUO_POPUP_MAX_WIDTH);
|
|
15
15
|
};
|
|
@@ -17,7 +17,7 @@ const usePopupWidth = () => {
|
|
|
17
17
|
// room left for a popup on iPhone Duo, where landscape is too short for it
|
|
18
18
|
const usePopupMaxHeight = () => {
|
|
19
19
|
const insets = useSafeAreaInsets();
|
|
20
|
-
const { width, height } =
|
|
20
|
+
const { width, height } = useWindowLayout();
|
|
21
21
|
if (!isIPhoneDuo()) {
|
|
22
22
|
return undefined;
|
|
23
23
|
}
|
package/Text/utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PixelRatio } from 'react-native';
|
|
2
2
|
import { useContext } from 'react';
|
|
3
3
|
import { MiniAppContext } from '../Context';
|
|
4
|
+
import { getWindowLayout, useWindowLayout } from '../Layout/utils';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_SCREEN_SIZE = 375;
|
|
6
7
|
const MAX_FONT_SCALE = 1.5;
|
|
@@ -8,11 +9,11 @@ const MAX_DEVICE_SCALE = 5;
|
|
|
8
9
|
|
|
9
10
|
const useScaleSize = (size: number, scaleRate?: number) => {
|
|
10
11
|
const context = useContext<any>(MiniAppContext);
|
|
12
|
+
const { width } = useWindowLayout();
|
|
11
13
|
if (context?.fontScale?.useOSScaleRate === false) {
|
|
12
14
|
return size * (context.fontScale.userScaleRate ?? 1);
|
|
13
15
|
}
|
|
14
16
|
const fontScale = PixelRatio.getFontScale();
|
|
15
|
-
const { width } = useWindowDimensions();
|
|
16
17
|
const deviceScale = width / DEFAULT_SCREEN_SIZE;
|
|
17
18
|
|
|
18
19
|
const maxScaleRate = scaleRate ?? MAX_FONT_SCALE;
|
|
@@ -39,7 +40,7 @@ const useScaleSize = (size: number, scaleRate?: number) => {
|
|
|
39
40
|
|
|
40
41
|
const scaleSize = (size: number) => {
|
|
41
42
|
const fontScale = PixelRatio.getFontScale();
|
|
42
|
-
const deviceScale =
|
|
43
|
+
const deviceScale = getWindowLayout().width / DEFAULT_SCREEN_SIZE;
|
|
43
44
|
|
|
44
45
|
let fontSizeDeviceScale = size;
|
|
45
46
|
let fontSizeOSScale = size;
|