@momo-kits/foundation 0.167.1-duotest.2 → 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.
@@ -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 = useWindowDimensions();
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 = useWindowDimensions()?.width;
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
- if (opened === 'tabBar' && fits(BACK_ONLY_HEIGHT, tabBar)) {
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 = 62;
40
+ const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
40
41
  const FLOATING_PADDING = Spacing.XS;
41
- const FLOATING_QR_SIZE = 44;
42
- // floating bar spans 6 of the 8 portrait grid columns, like the content
43
- const GRID_COLUMNS = 8;
44
- const GRID_SPAN = 6;
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 + Spacing.M + insets.bottom
64
+ ? FLOATING_HEIGHT + getFloatingTabBarBottom(insets.bottom)
54
65
  : 0;
55
66
  };
56
67
 
57
- const gridSpanWidth = (width: number) => {
58
- const columnWidth =
59
- (width - 2 * Spacing.M - (GRID_COLUMNS - 1) * Spacing.M) / GRID_COLUMNS;
60
- return columnWidth * GRID_SPAN + (GRID_SPAN - 1) * Spacing.M;
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 { theme } = useContext(ApplicationContext);
161
- const bubbleOffset = useSharedValue(index * (ITEM_SIZE + RAIL_GAP));
230
+ const bubbleOffset = useSharedValue(index * RAIL_PITCH);
231
+ const lens = useSharedValue(0);
162
232
 
163
233
  useEffect(() => {
164
- bubbleOffset.value = withSpring(index * (ITEM_SIZE + RAIL_GAP), SPRING);
234
+ bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
165
235
  }, [index, bubbleOffset]);
166
236
 
167
237
  const bubbleStyle = useAnimatedStyle(() => ({
168
- transform: [{ translateY: bubbleOffset.value }],
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={Radius.XL} />
247
+ <BarMaterial radius={ITEM_SIZE / 2} interactive />
174
248
  <Animated.View
175
249
  pointerEvents={'none'}
176
- style={[
177
- styles.railBubble,
178
- { backgroundColor: theme.colors.background.selected },
179
- bubbleStyle,
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 the middle cell (Figma MT1-M)
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 { width, insets } = useScreenLayout();
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 barWidth = gridSpanWidth(width);
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 = visualIndex * cellWidth;
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
- }, [visualIndex, cellWidth, bubbleOffset]);
371
+ }, [state.index, cellWidth, bubbleOffset]);
295
372
 
296
373
  const bubbleStyle = useAnimatedStyle(() => ({
297
- transform: [{ translateX: bubbleOffset.value }],
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
- const cells = items.map(item => (
304
- <TouchableOpacity
305
- key={item.route.key}
306
- accessibilityRole={'tab'}
307
- accessibilityLabel={item.label}
308
- accessibilityState={{ selected: item.focused }}
309
- onPress={item.onPress}
310
- onLongPress={item.onLongPress}
311
- style={styles.floatingItem}
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
- {item.icon}
314
- <Text
315
- numberOfLines={1}
316
- typography={'label_xs_medium'}
317
- color={item.focused ? activeTintColor : inactiveTintColor}
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
- {item.label}
320
- </Text>
321
- </TouchableOpacity>
322
- ));
323
-
324
- if (useQr && floatingButton) {
325
- cells.splice(
326
- middle,
327
- 0,
328
- <View key={'duo_tab_qr'} style={styles.floatingItem}>
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
- </View>,
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: FLOATING_QR_SIZE,
455
- height: FLOATING_QR_SIZE,
456
- borderRadius: FLOATING_QR_SIZE / 2,
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, {
@@ -35,7 +34,7 @@ import {
35
34
  ScreenContext,
36
35
  } from '../../Context';
37
36
  import { useScreenLayout } from '../../Layout';
38
- import { getHeaderRowOptions } from '../../Layout/utils';
37
+ import { getHeaderRowOptions, useWindowLayout } from '../../Layout/utils';
39
38
 
40
39
  const Tab = createBottomTabNavigator();
41
40
  const Stack = createStackNavigator();
@@ -216,7 +215,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
216
215
  screen?.syncHeader?.();
217
216
  // eslint-disable-next-line react-hooks/exhaustive-deps
218
217
  }, []);
219
- const dimensions = useWindowDimensions();
218
+ const dimensions = useWindowLayout();
220
219
  const insets = useSafeAreaInsets();
221
220
  const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
222
221
  const indicatorAnimated = useSharedValue(0);
@@ -327,7 +326,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
327
326
  <DuoTabBar
328
327
  {...props}
329
328
  floatingButton={floatingButton}
330
- inactiveTintColor={theme.colors.text.default}
329
+ inactiveTintColor={theme.colors.text.hint}
331
330
  activeTintColor={theme.colors.primary}
332
331
  />
333
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 = useWindowDimensions();
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/Layout/utils.ts CHANGED
@@ -1,5 +1,13 @@
1
- import React, { Fragment, ReactElement, useContext } from 'react';
2
- import { Platform, useWindowDimensions, ViewStyle } from 'react-native';
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, from useWindowDimensions.
243
+ * Window width, side strips included.
228
244
  */
229
245
  width: number;
230
246
 
231
247
  /**
232
- * Window height, from useWindowDimensions.
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 useWindowDimensions and the safe-area context.
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 } = useWindowDimensions();
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 { Dimensions, useWindowDimensions } from 'react-native';
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 = useWindowDimensions();
10
+ const windowSize = useWindowLayout();
11
11
  if (!isIPhoneDuo()) {
12
- return Dimensions.get('window').width - Spacing.XL * 2;
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 } = useWindowDimensions();
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 { Dimensions, PixelRatio, useWindowDimensions } from 'react-native';
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 = Dimensions.get('window').width / DEFAULT_SCREEN_SIZE;
43
+ const deviceScale = getWindowLayout().width / DEFAULT_SCREEN_SIZE;
43
44
 
44
45
  let fontSizeDeviceScale = size;
45
46
  let fontSizeOSScale = size;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duotest.2",
3
+ "version": "0.167.1-duotest.3",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},