@momo-kits/foundation 0.167.1-duo.3 → 0.167.1-duo.4

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.
@@ -0,0 +1,135 @@
1
+ import React, {
2
+ createContext,
3
+ ReactNode,
4
+ useContext,
5
+ useMemo,
6
+ useState,
7
+ } from 'react';
8
+ import { Spacing } from '../../Consts';
9
+ import { useScreenLayout } from '../../Layout';
10
+ import { DuoMetrics } from '../../Layout/utils';
11
+
12
+ export type DuoBarCompressionBehavior = 'toolbar' | 'tabBar';
13
+
14
+ type DuoBarCompression = {
15
+ toolbarCompressed: boolean;
16
+ tabBarCompressed: boolean;
17
+ // the open rail needs the toolbar button slot too: only Back stays
18
+ toolbarHidden?: boolean;
19
+ // tapping the compressed bar expands it and compresses the other one
20
+ expandToolbar?: () => void;
21
+ expandTabBar?: () => void;
22
+ // a rail opened from the compressed state closes once a tab is picked
23
+ onTabSelected?: () => void;
24
+ // header buttons report their full height so the tab bar knows what is left
25
+ setToolbarHeight?: (height: number) => void;
26
+ };
27
+
28
+ const RAIL_PADDING = 6;
29
+ const RAIL_GAP = Spacing.XXS;
30
+ // space kept between the top and the bottom groups (HIG Vertical controls)
31
+ const GROUP_GAP = Spacing.M;
32
+ // Back, then the right toolkit: help + home capsule
33
+ const DEFAULT_TOOLBAR_HEIGHT =
34
+ DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
35
+ // Back, then the single button the toolbar folds into
36
+ const COMPRESSED_TOOLBAR_HEIGHT = 2 * DuoMetrics.controlSize + Spacing.M;
37
+ // Back alone, while a rail opened from the compressed state takes the toolbar slot
38
+ const BACK_ONLY_HEIGHT = DuoMetrics.controlSize;
39
+
40
+ const NO_COMPRESSION: DuoBarCompression = {
41
+ toolbarCompressed: false,
42
+ tabBarCompressed: false,
43
+ };
44
+
45
+ const DuoBarCompressionContext =
46
+ createContext<DuoBarCompression>(NO_COMPRESSION);
47
+
48
+ /**
49
+ * height of the vertical tab rail, plus the QR button under it
50
+ */
51
+ const getRailHeight = (tabCount: number, hasQr: boolean) =>
52
+ 2 * RAIL_PADDING +
53
+ tabCount * DuoMetrics.controlSize +
54
+ Math.max(tabCount - 1, 0) * RAIL_GAP +
55
+ (hasQr ? Spacing.M + DuoMetrics.controlSize : 0);
56
+
57
+ /**
58
+ * keeps the side bar's toolbar and tab bar clear of each other (HIG, Designing for
59
+ * iPhone Duo: Vertical controls):
60
+ * - both fit: both stay whole
61
+ * - one fits whole beside the other compressed: one is compressed, tapping it swaps them
62
+ * - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
63
+ * again once a tab is picked)
64
+ */
65
+ const DuoBarCompressionProvider: React.FC<{
66
+ behavior?: DuoBarCompressionBehavior;
67
+ tabCount: number;
68
+ hasQr: boolean;
69
+ children: ReactNode;
70
+ }> = ({ behavior = 'toolbar', tabCount, hasQr, children }) => {
71
+ const { height, sideBar } = useScreenLayout();
72
+ const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_TOOLBAR_HEIGHT);
73
+ // 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
74
+ const [expanded, setExpanded] = useState<'toolbar' | 'tabBar'>(
75
+ behavior === 'toolbar' ? 'tabBar' : 'toolbar',
76
+ );
77
+ // bar opened for a moment while both rest compressed
78
+ const [opened, setOpened] = useState<'toolbar' | 'tabBar'>();
79
+
80
+ const value = useMemo(() => {
81
+ if (!sideBar) {
82
+ return NO_COMPRESSION;
83
+ }
84
+ const room = height - sideBar.bottom - sideBar.top - GROUP_GAP;
85
+ const tabBar = getRailHeight(tabCount, hasQr);
86
+ const compressedTabBar = getRailHeight(1, hasQr) - 2 * RAIL_PADDING;
87
+ const fits = (toolbar: number, tabs: number) => toolbar + tabs <= room;
88
+
89
+ if (fits(toolbarHeight, tabBar)) {
90
+ return { ...NO_COMPRESSION, setToolbarHeight };
91
+ }
92
+ const canSwap =
93
+ fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) &&
94
+ fits(toolbarHeight, compressedTabBar);
95
+ if (canSwap) {
96
+ return {
97
+ toolbarCompressed: expanded === 'tabBar',
98
+ tabBarCompressed: expanded === 'toolbar',
99
+ expandToolbar: () => setExpanded('toolbar'),
100
+ expandTabBar: () => setExpanded('tabBar'),
101
+ setToolbarHeight,
102
+ };
103
+ }
104
+
105
+ const open = {
106
+ expandToolbar: () => setOpened('toolbar'),
107
+ expandTabBar: () => setOpened('tabBar'),
108
+ setToolbarHeight,
109
+ };
110
+ if (opened === 'tabBar' && fits(BACK_ONLY_HEIGHT, tabBar)) {
111
+ return {
112
+ ...open,
113
+ toolbarCompressed: true,
114
+ toolbarHidden: true,
115
+ tabBarCompressed: false,
116
+ onTabSelected: () => setOpened(undefined),
117
+ };
118
+ }
119
+ return {
120
+ ...open,
121
+ toolbarCompressed: opened !== 'toolbar',
122
+ tabBarCompressed: true,
123
+ };
124
+ }, [expanded, opened, hasQr, height, sideBar, tabCount, toolbarHeight]);
125
+
126
+ return (
127
+ <DuoBarCompressionContext.Provider value={value}>
128
+ {children}
129
+ </DuoBarCompressionContext.Provider>
130
+ );
131
+ };
132
+
133
+ const useDuoBarCompression = () => useContext(DuoBarCompressionContext);
134
+
135
+ export { DuoBarCompressionProvider, useDuoBarCompression };
@@ -18,6 +18,7 @@ import { Icon } from '../../Icon';
18
18
  import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
19
19
  import { Text } from '../../Text';
20
20
  import { GlassView } from '../Components/OptionalGlassView';
21
+ import { useDuoBarCompression } from './DuoBarCompression';
21
22
  import { FloatingButtonProps } from '../types';
22
23
  import { TabBarIcon } from './TabBarIcon';
23
24
 
@@ -146,76 +147,121 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
146
147
  return <>{icon}</>;
147
148
  };
148
149
 
150
+ type TabItem = ReturnType<typeof getTabItems>[number];
151
+
149
152
  /**
150
- * vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E)
153
+ * glass column of tab items with the sliding selection bubble
151
154
  */
152
- const DuoRailTabBar: React.FC<Props> = props => {
155
+ const Rail: React.FC<{
156
+ items: TabItem[];
157
+ index: number;
158
+ onItemPress?: () => void;
159
+ }> = ({ items, index, onItemPress }) => {
153
160
  const { theme } = useContext(ApplicationContext);
154
- const { sideBar } = useScreenLayout();
155
- const { state, floatingButton } = props;
156
- const items = getTabItems(props);
157
- const bubbleOffset = useSharedValue(state.index * (ITEM_SIZE + RAIL_GAP));
161
+ const bubbleOffset = useSharedValue(index * (ITEM_SIZE + RAIL_GAP));
158
162
 
159
163
  useEffect(() => {
160
- bubbleOffset.value = withSpring(
161
- state.index * (ITEM_SIZE + RAIL_GAP),
162
- SPRING,
163
- );
164
- }, [state.index, bubbleOffset]);
164
+ bubbleOffset.value = withSpring(index * (ITEM_SIZE + RAIL_GAP), SPRING);
165
+ }, [index, bubbleOffset]);
165
166
 
166
167
  const bubbleStyle = useAnimatedStyle(() => ({
167
168
  transform: [{ translateY: bubbleOffset.value }],
168
169
  }));
169
170
 
171
+ return (
172
+ <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
173
+ <BarMaterial radius={Radius.XL} />
174
+ <Animated.View
175
+ pointerEvents={'none'}
176
+ style={[
177
+ styles.railBubble,
178
+ { backgroundColor: theme.colors.background.selected },
179
+ bubbleStyle,
180
+ ]}
181
+ />
182
+ {items.map(item => (
183
+ <TouchableOpacity
184
+ key={item.route.key}
185
+ accessibilityRole={'tab'}
186
+ accessibilityLabel={item.label}
187
+ accessibilityState={{ selected: item.focused }}
188
+ onPress={() => {
189
+ item.onPress();
190
+ onItemPress?.();
191
+ }}
192
+ onLongPress={item.onLongPress}
193
+ style={styles.railItem}
194
+ >
195
+ {item.icon}
196
+ </TouchableOpacity>
197
+ ))}
198
+ </View>
199
+ );
200
+ };
201
+
202
+ /**
203
+ * vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E).
204
+ * When the side bar is short, it can shrink to one button showing the current tab
205
+ * (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar
206
+ */
207
+ const DuoRailTabBar: React.FC<Props> = props => {
208
+ const { theme } = useContext(ApplicationContext);
209
+ const { sideBar } = useScreenLayout();
210
+ const { tabBarCompressed, expandTabBar, onTabSelected } =
211
+ useDuoBarCompression();
212
+ const { state, floatingButton } = props;
213
+ const items = getTabItems(props);
214
+ const current = items[state.index];
215
+
216
+ const columnStyle = sideBar && [
217
+ styles.railColumn,
218
+ {
219
+ [sideBar.edge]: 0,
220
+ width: sideBar.width,
221
+ bottom: sideBar.bottom,
222
+ },
223
+ sideBar.edge === 'right'
224
+ ? { alignItems: 'flex-start' as const, paddingLeft: Spacing.M }
225
+ : { alignItems: 'flex-end' as const, paddingRight: Spacing.M },
226
+ ];
227
+
228
+ const qr = floatingButton && (
229
+ <TouchableOpacity
230
+ accessibilityRole={'button'}
231
+ accessibilityLabel={floatingButton.label}
232
+ onPress={floatingButton.onPress}
233
+ style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
234
+ >
235
+ <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
236
+ </TouchableOpacity>
237
+ );
238
+
170
239
  if (!sideBar) {
171
240
  return null;
172
241
  }
173
242
 
243
+ if (!tabBarCompressed) {
244
+ return (
245
+ <View pointerEvents={'box-none'} style={columnStyle}>
246
+ <Rail items={items} index={state.index} onItemPress={onTabSelected} />
247
+ {qr}
248
+ </View>
249
+ );
250
+ }
251
+
174
252
  return (
175
- <View
176
- pointerEvents={'box-none'}
177
- style={[
178
- styles.railColumn,
179
- { [sideBar.edge]: 0, width: sideBar.width },
180
- sideBar.edge === 'right'
181
- ? { alignItems: 'flex-start', paddingLeft: Spacing.M }
182
- : { alignItems: 'flex-end', paddingRight: Spacing.M },
183
- ]}
184
- >
185
- <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
253
+ <View pointerEvents={'box-none'} style={columnStyle}>
254
+ <TouchableOpacity
255
+ accessibilityRole={'button'}
256
+ accessibilityLabel={current?.label}
257
+ accessibilityHint={'bottom_tab_bar'}
258
+ onPress={expandTabBar}
259
+ style={styles.railCompact}
260
+ >
186
261
  <BarMaterial radius={Radius.XL} />
187
- <Animated.View
188
- pointerEvents={'none'}
189
- style={[
190
- styles.railBubble,
191
- { backgroundColor: theme.colors.background.selected },
192
- bubbleStyle,
193
- ]}
194
- />
195
- {items.map(item => (
196
- <TouchableOpacity
197
- key={item.route.key}
198
- accessibilityRole={'tab'}
199
- accessibilityLabel={item.label}
200
- accessibilityState={{ selected: item.focused }}
201
- onPress={item.onPress}
202
- onLongPress={item.onLongPress}
203
- style={styles.railItem}
204
- >
205
- {item.icon}
206
- </TouchableOpacity>
207
- ))}
208
- </View>
209
- {floatingButton && (
210
- <TouchableOpacity
211
- accessibilityRole={'button'}
212
- accessibilityLabel={floatingButton.label}
213
- onPress={floatingButton.onPress}
214
- style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
215
- >
216
- <QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
217
- </TouchableOpacity>
218
- )}
262
+ {current?.icon}
263
+ </TouchableOpacity>
264
+ {qr}
219
265
  </View>
220
266
  );
221
267
  };
@@ -347,9 +393,15 @@ const styles = StyleSheet.create({
347
393
  },
348
394
  railColumn: {
349
395
  position: 'absolute',
350
- bottom: Spacing.XL,
351
396
  gap: Spacing.M,
352
397
  },
398
+ railCompact: {
399
+ width: ITEM_SIZE,
400
+ height: ITEM_SIZE,
401
+ borderRadius: ITEM_SIZE / 2,
402
+ alignItems: 'center',
403
+ justifyContent: 'center',
404
+ },
353
405
  rail: {
354
406
  width: ITEM_SIZE,
355
407
  paddingVertical: RAIL_PADDING,
@@ -28,6 +28,7 @@ import { BottomTabProps } from '../types';
28
28
  import { getOptions, getStackOptions, useAppState } from '../utils';
29
29
  import BottomTabBar from './BottomTabBar';
30
30
  import { DuoTabBar } from './DuoTabBar';
31
+ import { DuoBarCompressionProvider } from './DuoBarCompression';
31
32
  import { ApplicationContext, MiniAppContext } from '../../Context';
32
33
  import { useScreenLayout } from '../../Layout';
33
34
 
@@ -205,6 +206,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
205
206
  navigation,
206
207
  initialRouteName,
207
208
  floatingButton,
209
+ compressionBehavior,
208
210
  }) => {
209
211
  const { theme, navigator } = useContext(ApplicationContext);
210
212
  const { isIPhoneDuo } = useScreenLayout();
@@ -281,147 +283,156 @@ const BottomTab: React.FC<BottomTabProps> = ({
281
283
  };
282
284
 
283
285
  return (
284
- <Tab.Navigator
285
- screenOptions={{
286
- headerShown: false,
287
- headerTransparent: true,
288
- headerBackground: undefined,
289
- tabBarLabelPosition: 'below-icon',
290
- tabBarBackground: () => (
291
- <View
292
- style={[
293
- styles.container,
294
- {
295
- backgroundColor: theme.colors.background.surface,
296
- },
297
- ]}
298
- >
299
- <Animated.View style={[styles.indicatorContainer, indicatorStyle]}>
300
- <View
301
- style={[
302
- styles.indicator,
303
- { backgroundColor: theme.colors.primary },
304
- ]}
305
- />
306
- </Animated.View>
307
- </View>
308
- ),
309
- }}
310
- tabBar={props =>
311
- isIPhoneDuo ? (
312
- <DuoTabBar
313
- {...props}
314
- floatingButton={floatingButton}
315
- inactiveTintColor={theme.colors.text.default}
316
- activeTintColor={theme.colors.primary}
317
- />
318
- ) : (
319
- <BottomTabBar
320
- {...props}
321
- floatingButton={floatingButton}
322
- inactiveTintColor={theme.colors.text.hint}
323
- activeTintColor={theme.colors.primary}
324
- style={[
325
- {
326
- height:
327
- 64 +
328
- (Platform.OS === 'ios'
329
- ? Math.min(insets.bottom, 21)
330
- : insets.bottom),
331
- },
332
- ]}
333
- />
334
- )
335
- }
336
- initialRouteName={initialRouteName}
286
+ <DuoBarCompressionProvider
287
+ behavior={compressionBehavior}
288
+ tabCount={tabs.length}
289
+ hasQr={!!floatingButton}
337
290
  >
338
- {tabs.map((item, index) => {
339
- const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
340
- const isDot = typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
341
- const isNumZero = isNum && Number(item.badgeLabel) === 0;
342
-
343
- let badgeLabel = item?.badgeLabel;
344
- if (isNumZero) {
345
- badgeLabel = undefined;
291
+ <Tab.Navigator
292
+ screenOptions={{
293
+ headerShown: false,
294
+ headerTransparent: true,
295
+ headerBackground: undefined,
296
+ tabBarLabelPosition: 'below-icon',
297
+ tabBarBackground: () => (
298
+ <View
299
+ style={[
300
+ styles.container,
301
+ {
302
+ backgroundColor: theme.colors.background.surface,
303
+ },
304
+ ]}
305
+ >
306
+ <Animated.View
307
+ style={[styles.indicatorContainer, indicatorStyle]}
308
+ >
309
+ <View
310
+ style={[
311
+ styles.indicator,
312
+ { backgroundColor: theme.colors.primary },
313
+ ]}
314
+ />
315
+ </Animated.View>
316
+ </View>
317
+ ),
318
+ }}
319
+ tabBar={props =>
320
+ isIPhoneDuo ? (
321
+ <DuoTabBar
322
+ {...props}
323
+ floatingButton={floatingButton}
324
+ inactiveTintColor={theme.colors.text.default}
325
+ activeTintColor={theme.colors.primary}
326
+ />
327
+ ) : (
328
+ <BottomTabBar
329
+ {...props}
330
+ floatingButton={floatingButton}
331
+ inactiveTintColor={theme.colors.text.hint}
332
+ activeTintColor={theme.colors.primary}
333
+ style={[
334
+ {
335
+ height:
336
+ 64 +
337
+ (Platform.OS === 'ios'
338
+ ? Math.min(insets.bottom, 21)
339
+ : insets.bottom),
340
+ },
341
+ ]}
342
+ />
343
+ )
346
344
  }
345
+ initialRouteName={initialRouteName}
346
+ >
347
+ {tabs.map((item, index) => {
348
+ const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
349
+ const isDot =
350
+ typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
351
+ const isNumZero = isNum && Number(item.badgeLabel) === 0;
347
352
 
348
- let badgeStyle: {
349
- marginRight: number;
350
- top: number;
351
- backgroundColor: string;
352
- width?: number;
353
- } = {
354
- marginRight: -8,
355
- top: -4,
356
- backgroundColor: Colors.orange_03,
357
- };
353
+ let badgeLabel = item?.badgeLabel;
354
+ if (isNumZero) {
355
+ badgeLabel = undefined;
356
+ }
358
357
 
359
- if (isNum) {
360
- badgeStyle = {
361
- marginRight: -4,
358
+ let badgeStyle: {
359
+ marginRight: number;
360
+ top: number;
361
+ backgroundColor: string;
362
+ width?: number;
363
+ } = {
364
+ marginRight: -8,
362
365
  top: -4,
363
- backgroundColor: Colors.red_03,
364
- };
365
- }
366
- if (isDot) {
367
- badgeStyle = {
368
- marginRight: -2,
369
- top: -2,
370
366
  backgroundColor: Colors.orange_03,
371
- width: 12,
372
367
  };
373
- }
374
368
 
375
- return (
376
- <Tab.Screen
377
- key={`BottomTab-${item.name}`}
378
- name={item.name}
379
- component={TabScreen}
380
- initialParams={{ ...item, nested, index }}
381
- listeners={handler}
382
- options={{
383
- tabBarLabel: ({ focused, color }) => {
384
- return (
385
- <Text
386
- numberOfLines={1}
387
- typography={
388
- focused ? 'label_xs_medium' : 'description_xs_regular'
389
- }
390
- color={color}
391
- >
392
- {item.label}
393
- </Text>
394
- );
395
- },
396
- title: item.label,
397
- tabBarStyle: [
398
- {
399
- height: 64,
400
- },
401
- styles.tabStyle,
402
- ],
403
- tabBarBadge: badgeLabel,
404
- tabBarBadgeStyle: [
405
- {
406
- borderColor: 'white',
407
- borderWidth: 2,
408
- fontSize: 10,
409
- lineHeight: 14,
410
- fontWeight: 'bold',
411
- alignSelf: 'center',
412
- fontFamily: exportFontFamily('bold'),
369
+ if (isNum) {
370
+ badgeStyle = {
371
+ marginRight: -4,
372
+ top: -4,
373
+ backgroundColor: Colors.red_03,
374
+ };
375
+ }
376
+ if (isDot) {
377
+ badgeStyle = {
378
+ marginRight: -2,
379
+ top: -2,
380
+ backgroundColor: Colors.orange_03,
381
+ width: 12,
382
+ };
383
+ }
384
+
385
+ return (
386
+ <Tab.Screen
387
+ key={`BottomTab-${item.name}`}
388
+ name={item.name}
389
+ component={TabScreen}
390
+ initialParams={{ ...item, nested, index }}
391
+ listeners={handler}
392
+ options={{
393
+ tabBarLabel: ({ focused, color }) => {
394
+ return (
395
+ <Text
396
+ numberOfLines={1}
397
+ typography={
398
+ focused ? 'label_xs_medium' : 'description_xs_regular'
399
+ }
400
+ color={color}
401
+ >
402
+ {item.label}
403
+ </Text>
404
+ );
413
405
  },
414
- badgeStyle,
415
- ],
416
- tabBarIcon: ({ color }) => (
417
- <Icon source={item.icon} color={color} size={28} />
418
- ),
419
- ...item,
420
- }}
421
- />
422
- );
423
- })}
424
- </Tab.Navigator>
406
+ title: item.label,
407
+ tabBarStyle: [
408
+ {
409
+ height: 64,
410
+ },
411
+ styles.tabStyle,
412
+ ],
413
+ tabBarBadge: badgeLabel,
414
+ tabBarBadgeStyle: [
415
+ {
416
+ borderColor: 'white',
417
+ borderWidth: 2,
418
+ fontSize: 10,
419
+ lineHeight: 14,
420
+ fontWeight: 'bold',
421
+ alignSelf: 'center',
422
+ fontFamily: exportFontFamily('bold'),
423
+ },
424
+ badgeStyle,
425
+ ],
426
+ tabBarIcon: ({ color }) => (
427
+ <Icon source={item.icon} color={color} size={28} />
428
+ ),
429
+ ...item,
430
+ }}
431
+ />
432
+ );
433
+ })}
434
+ </Tab.Navigator>
435
+ </DuoBarCompressionProvider>
425
436
  );
426
437
  };
427
438
 
@@ -82,8 +82,8 @@ const DuoGlassBackground: React.FC<{
82
82
  };
83
83
 
84
84
  /**
85
- * pink header gradient drawn under the content: header-high for default headers
86
- * (scrolls away), 52 taller for extended ones (collapses and fades, as off Duo)
85
+ * pink header gradient drawn under the content, fading out on scroll as main's extended
86
+ * header does: header-high for default headers, 52 taller (collapsing) for extended
87
87
  */
88
88
  const DuoScrollGradient: React.FC<{
89
89
  animatedValue?: AnimatedCompatValue;
@@ -91,12 +91,15 @@ const DuoScrollGradient: React.FC<{
91
91
  headerBackground?: string;
92
92
  heightHeader: number;
93
93
  extended?: boolean;
94
+ // quick return header: the gradient leaves with the content instead of fading
95
+ followScroll?: boolean;
94
96
  }> = ({
95
97
  animatedValue,
96
98
  gradientColor,
97
99
  headerBackground,
98
100
  heightHeader,
99
101
  extended,
102
+ followScroll,
100
103
  }) => {
101
104
  const { theme } = useContext(ApplicationContext);
102
105
  const sharedValue = useNormalizedSharedValue(animatedValue);
@@ -104,19 +107,21 @@ const DuoScrollGradient: React.FC<{
104
107
  const image = headerBackground ?? theme.assets?.headerBackground;
105
108
 
106
109
  const scrollStyle = useAnimatedStyle(() => {
107
- if (!extended) {
110
+ if (followScroll) {
108
111
  return {
109
- height: heightHeader,
112
+ height: heightHeader + (extended ? EXTENDED_GRADIENT_EXTRA : 0),
110
113
  transform: [{ translateY: -Math.max(sharedValue.value, 0) }],
111
114
  };
112
115
  }
113
116
  return {
114
- height: interpolate(
115
- sharedValue.value,
116
- [0, EXTENDED_COLLAPSE_DISTANCE],
117
- [heightHeader + EXTENDED_GRADIENT_EXTRA, heightHeader],
118
- Extrapolation.CLAMP,
119
- ),
117
+ height: extended
118
+ ? interpolate(
119
+ sharedValue.value,
120
+ [0, EXTENDED_COLLAPSE_DISTANCE],
121
+ [heightHeader + EXTENDED_GRADIENT_EXTRA, heightHeader],
122
+ Extrapolation.CLAMP,
123
+ )
124
+ : heightHeader,
120
125
  opacity: interpolate(
121
126
  sharedValue.value,
122
127
  [0, EXTENDED_GRADIENT_EXTRA],
@@ -135,21 +140,18 @@ const DuoScrollGradient: React.FC<{
135
140
  pointerEvents={'none'}
136
141
  style={[styles.gradient, scrollStyle]}
137
142
  >
138
- {/* extended squeezes the whole fade into its box like main; default clips it */}
139
- <View style={extended ? StyleSheet.absoluteFill : styles.gradientFill}>
140
- <LinearGradient
141
- colors={[color, color + '00']}
142
- style={StyleSheet.absoluteFill}
143
- >
144
- {!!image && (
145
- <Image
146
- style={styles.gradientFill}
147
- source={{ uri: image }}
148
- loading={false}
149
- />
150
- )}
151
- </LinearGradient>
152
- </View>
143
+ <LinearGradient
144
+ colors={[color, color + '00']}
145
+ style={StyleSheet.absoluteFill}
146
+ >
147
+ {!!image && (
148
+ <Image
149
+ style={styles.gradientFill}
150
+ source={{ uri: image }}
151
+ loading={false}
152
+ />
153
+ )}
154
+ </LinearGradient>
153
155
  </Animated.View>
154
156
  );
155
157
  };
@@ -166,7 +168,7 @@ const styles = StyleSheet.create({
166
168
  right: 0,
167
169
  overflow: 'hidden',
168
170
  },
169
- // default header gradient keeps the off-Duo 375:154 shape, the header clips it
171
+ // banner image keeps its 375:154 shape, the gradient box clips it
170
172
  gradientFill: {
171
173
  width: '100%',
172
174
  position: 'absolute',
@@ -0,0 +1,42 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import { StackHeaderProps, Header } from '@react-navigation/stack';
3
+ import Animated, {
4
+ SharedValue,
5
+ useAnimatedStyle,
6
+ } from 'react-native-reanimated';
7
+
8
+ // how far the header is pushed above the top edge
9
+ const DuoHeaderOffsetContext = createContext<SharedValue<number> | undefined>(
10
+ undefined,
11
+ );
12
+
13
+ /**
14
+ * iPhone Duo header that leaves with the content and comes back when scrolling back
15
+ */
16
+ const DuoQuickReturnHeader: React.FC<
17
+ StackHeaderProps & { offset: SharedValue<number> }
18
+ > = ({ offset, ...props }) => {
19
+ const style = useAnimatedStyle(() => ({
20
+ transform: [{ translateY: -offset.value }],
21
+ }));
22
+
23
+ return (
24
+ <DuoHeaderOffsetContext.Provider value={offset}>
25
+ <Animated.View pointerEvents={'box-none'} style={style}>
26
+ <Header {...props} />
27
+ </Animated.View>
28
+ </DuoHeaderOffsetContext.Provider>
29
+ );
30
+ };
31
+
32
+ /**
33
+ * side bar controls render inside the header; this cancels its offset so they stay put
34
+ */
35
+ const useSideBarCounterStyle = () => {
36
+ const offset = useContext(DuoHeaderOffsetContext);
37
+ return useAnimatedStyle(() => ({
38
+ transform: [{ translateY: offset ? offset.value : 0 }],
39
+ }));
40
+ };
41
+
42
+ export { DuoQuickReturnHeader, useSideBarCounterStyle };
@@ -5,7 +5,9 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
5
5
  import { PopupNotify } from '../../Popup';
6
6
  import { NavigationButton } from './NavigationButton';
7
7
  import { SideBarButton } from './SideBarButton';
8
+ import Animated from 'react-native-reanimated';
8
9
  import { useSideBarItemStyle } from '../../Layout/utils';
10
+ import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
9
11
 
10
12
  /**
11
13
  * default header left used for nav
@@ -20,6 +22,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
20
22
  const context = useContext<any>(MiniAppContext);
21
23
  const { navigator } = useContext(ApplicationContext);
22
24
  const sideBarStyle = useSideBarItemStyle('left', 0);
25
+ const counterStyle = useSideBarCounterStyle();
23
26
 
24
27
  const goBack = useCallback(() => {
25
28
  const canGoBack = navigator?.ref?.current?.canGoBack?.();
@@ -78,14 +81,17 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
78
81
 
79
82
  if (floating && sideBarStyle) {
80
83
  return (
81
- <View style={sideBarStyle} pointerEvents={'box-none'}>
84
+ <Animated.View
85
+ style={[sideBarStyle, counterStyle]}
86
+ pointerEvents={'box-none'}
87
+ >
82
88
  <SideBarButton
83
89
  accessibilityLabel="btn_navigation_back"
84
90
  icon="arrow-back"
85
91
  tintColor={tintColor}
86
92
  onPress={goBackSafe}
87
93
  />
88
- </View>
94
+ </Animated.View>
89
95
  );
90
96
  }
91
97
 
@@ -13,8 +13,12 @@ import { PopupNotify } from '../../Popup';
13
13
  import { Tool, ToolGroup } from '../types';
14
14
  import { Icon } from '../../Icon';
15
15
  import { Text } from '../../Text';
16
+ import Animated from 'react-native-reanimated';
16
17
  import { useSideBarItemStyle } from '../../Layout/utils';
18
+ import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
17
19
  import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
20
+ import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
21
+ import { DuoMetrics } from '../../Layout/utils';
18
22
 
19
23
  const DID_SYNC_NEW_HOME = 'did_sync_new_home';
20
24
 
@@ -26,6 +30,7 @@ const HeaderRight: React.FC<any> = props => {
26
30
  const { headerRight = {}, floating = false } = props;
27
31
  const { useOnBoarding = false, buttonOnBoarding, onPress } = headerRight;
28
32
  const sideBarStyle = useSideBarItemStyle('right', 1);
33
+ const counterStyle = useSideBarCounterStyle();
29
34
 
30
35
  if (typeof props.headerRight === 'function') {
31
36
  return props.headerRight(props);
@@ -43,13 +48,16 @@ const HeaderRight: React.FC<any> = props => {
43
48
 
44
49
  if (floating && sideBarStyle) {
45
50
  return (
46
- <View style={sideBarStyle} pointerEvents={'box-none'}>
51
+ <Animated.View
52
+ style={[sideBarStyle, counterStyle]}
53
+ pointerEvents={'box-none'}
54
+ >
47
55
  <HeaderToolkitAction
48
56
  {...props.headerRight}
49
57
  tintColor={props.tintColor}
50
58
  vertical
51
59
  />
52
- </View>
60
+ </Animated.View>
53
61
  );
54
62
  }
55
63
 
@@ -75,6 +83,8 @@ const HeaderToolkitAction: React.FC<any> = ({
75
83
  }) => {
76
84
  const { navigator } = useContext(ApplicationContext);
77
85
  const context = useContext<any>(MiniAppContext);
86
+ const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
87
+ useDuoBarCompression();
78
88
 
79
89
  const [isFavorite, setIsFavorite] = useState<boolean>(false);
80
90
  const [isLoading, setIsLoading] = useState(false);
@@ -281,6 +291,41 @@ const HeaderToolkitAction: React.FC<any> = ({
281
291
  ? '16_navigation_close_circle'
282
292
  : '16_basic_home';
283
293
 
294
+ const groupCount = useHelpCenter ? 2 : 1;
295
+ // Back slot, the optional shortcut, then the help/home capsule
296
+ const fullToolbarHeight =
297
+ DuoMetrics.controlSize +
298
+ Spacing.M +
299
+ (useShortcut ? DuoMetrics.controlSize + Spacing.M : 0) +
300
+ (groupCount > 1
301
+ ? groupCount * DuoMetrics.itemPitch
302
+ : DuoMetrics.controlSize);
303
+
304
+ useEffect(() => {
305
+ if (vertical) {
306
+ setToolbarHeight?.(fullToolbarHeight);
307
+ }
308
+ }, [vertical, fullToolbarHeight, setToolbarHeight]);
309
+
310
+ // an open tab rail takes this slot for a moment
311
+ if (vertical && toolbarHidden) {
312
+ return null;
313
+ }
314
+
315
+ // HIG "Toolbar compressed": tools fold into one button; tapping it expands the
316
+ // toolbar and compresses the tab bar instead. Not the useMore icon, to tell them apart
317
+ if (vertical && toolbarCompressed) {
318
+ return (
319
+ <SideBarButton
320
+ accessibilityLabel={'btn_navigation_toolbar'}
321
+ icon={'navigation_menu'}
322
+ tintColor={tintColor}
323
+ showBadge={showBadge}
324
+ onPress={expandToolbar}
325
+ />
326
+ );
327
+ }
328
+
284
329
  if (vertical) {
285
330
  return (
286
331
  <View style={styles.sideBarColumn}>
@@ -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 { ReservedRegionsProvider } from '../Layout/ReservedRegions';
34
35
 
35
36
  const Stack = createStackNavigator();
36
37
 
@@ -76,17 +77,19 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
76
77
  frame: { x: 0, y: 0, width: 0, height: 0 },
77
78
  }}
78
79
  >
79
- <MiniAppContext.Provider value={mergedContext}>
80
- <Navigation
81
- screen={screen}
82
- theme={theme}
83
- options={options}
84
- maxApi={maxApi}
85
- setCurrentContext={setCurrentContext}
86
- initialParams={initialParams}
87
- localize={localize}
88
- />
89
- </MiniAppContext.Provider>
80
+ <ReservedRegionsProvider>
81
+ <MiniAppContext.Provider value={mergedContext}>
82
+ <Navigation
83
+ screen={screen}
84
+ theme={theme}
85
+ options={options}
86
+ maxApi={maxApi}
87
+ setCurrentContext={setCurrentContext}
88
+ initialParams={initialParams}
89
+ localize={localize}
90
+ />
91
+ </MiniAppContext.Provider>
92
+ </ReservedRegionsProvider>
90
93
  </SafeAreaProvider>
91
94
  );
92
95
  };
@@ -333,6 +333,11 @@ export type BottomTabProps = {
333
333
  blur?: (e: EventArg<'blur', false, unknown>) => void;
334
334
  };
335
335
  floatingButton?: FloatingButtonProps;
336
+ /**
337
+ * iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
338
+ * tapping the compressed one swaps them. Defaults to 'toolbar'
339
+ */
340
+ compressionBehavior?: 'toolbar' | 'tabBar';
336
341
  };
337
342
 
338
343
  export type FloatingButtonProps = {
@@ -0,0 +1,47 @@
1
+ import React, { createContext, ReactNode, useState } from 'react';
2
+ import { Platform, StyleSheet } from 'react-native';
3
+
4
+ export type ReservedRegion = {
5
+ x: number;
6
+ y: number;
7
+ width: number;
8
+ height: number;
9
+ };
10
+
11
+ // native probe is optional so apps without @momo-kits/reserved-regions still bundle
12
+ let ReservedRegionsView: React.ComponentType<any> | undefined;
13
+ try {
14
+ ReservedRegionsView =
15
+ require('@momo-kits/reserved-regions').ReservedRegionsView;
16
+ } catch {
17
+ ReservedRegionsView = undefined;
18
+ }
19
+
20
+ /**
21
+ * screen regions the system currently occludes, in window coordinates
22
+ */
23
+ const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
24
+
25
+ /**
26
+ * tracks the system occlusion regions (iOS 27.1+), e.g. the iPhone Duo status bar
27
+ * cluster growing for a call or a Live Activity
28
+ */
29
+ const ReservedRegionsProvider: React.FC<{ children: ReactNode }> = ({
30
+ children,
31
+ }) => {
32
+ const [regions, setRegions] = useState<ReservedRegion[]>([]);
33
+
34
+ return (
35
+ <ReservedRegionsContext.Provider value={regions}>
36
+ {children}
37
+ {Platform.OS === 'ios' && !!ReservedRegionsView && (
38
+ <ReservedRegionsView
39
+ style={StyleSheet.absoluteFill}
40
+ onRegionsChange={setRegions}
41
+ />
42
+ )}
43
+ </ReservedRegionsContext.Provider>
44
+ );
45
+ };
46
+
47
+ export { ReservedRegionsContext, ReservedRegionsProvider };
package/Layout/Screen.tsx CHANGED
@@ -9,6 +9,7 @@ import React, {
9
9
  useContext,
10
10
  useEffect,
11
11
  useImperativeHandle,
12
+ useMemo,
12
13
  useRef,
13
14
  } from 'react';
14
15
  import {
@@ -24,7 +25,11 @@ import {
24
25
  View,
25
26
  ViewProps,
26
27
  } from 'react-native';
27
- import { useSharedValue, withTiming } from 'react-native-reanimated';
28
+ import {
29
+ useAnimatedReaction,
30
+ useSharedValue,
31
+ withTiming,
32
+ } from 'react-native-reanimated';
28
33
  import {
29
34
  useSafeAreaFrame,
30
35
  useSafeAreaInsets,
@@ -56,6 +61,7 @@ import {
56
61
  DuoGlassBackground,
57
62
  DuoScrollGradient,
58
63
  } from '../Application/Components/DuoGlassBackground';
64
+ import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
59
65
  import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
60
66
 
61
67
  export interface ScreenProps extends ViewProps {
@@ -268,6 +274,41 @@ const Screen = forwardRef(
268
274
  !animatedHeader &&
269
275
  !inputSearchProps;
270
276
 
277
+ // iPhone Duo: the header leaves with the content and returns on a reverse scroll
278
+ const useQuickReturn =
279
+ hasSideBar && scrollable && (useDuoGlass || !!animatedHeader);
280
+ const headerHide = useSharedValue(0);
281
+ const lastScrollY = useSharedValue(0);
282
+
283
+ useAnimatedReaction(
284
+ () => sharedValue.value,
285
+ y => {
286
+ const delta = y - lastScrollY.value;
287
+ lastScrollY.value = y;
288
+ if (!useQuickReturn || y <= 0) {
289
+ headerHide.value = 0;
290
+ return;
291
+ }
292
+ headerHide.value = Math.min(
293
+ Math.max(headerHide.value + delta, 0),
294
+ heightHeader,
295
+ );
296
+ },
297
+ [useQuickReturn, heightHeader],
298
+ );
299
+
300
+ const quickReturnOptions: StackNavigationOptions = useMemo(
301
+ () =>
302
+ useQuickReturn
303
+ ? {
304
+ header: (props: any) => (
305
+ <DuoQuickReturnHeader {...props} offset={headerHide} />
306
+ ),
307
+ }
308
+ : { header: undefined },
309
+ [useQuickReturn, headerHide],
310
+ );
311
+
271
312
  let keyboardOffset = heightHeader - bottomInset;
272
313
  if (
273
314
  headerType === 'extended' ||
@@ -301,6 +342,7 @@ const Screen = forwardRef(
301
342
 
302
343
  case 'extended':
303
344
  options = {
345
+ ...quickReturnOptions,
304
346
  headerShown: true,
305
347
  headerTransparent: true,
306
348
  headerBackground: () =>
@@ -331,6 +373,7 @@ const Screen = forwardRef(
331
373
  default:
332
374
  if (hasSideBar && !inputSearchProps) {
333
375
  options = {
376
+ ...quickReturnOptions,
334
377
  headerTransparent: true,
335
378
  headerTintColor: Colors.black_17,
336
379
  headerShown: true,
@@ -375,7 +418,7 @@ const Screen = forwardRef(
375
418
  }
376
419
  }
377
420
 
378
- navigation?.instance?.setOptions(options);
421
+ navigation?.instance?.setOptions({ header: undefined, ...options });
379
422
  },
380
423
  [
381
424
  gradientColor,
@@ -385,6 +428,7 @@ const Screen = forwardRef(
385
428
  sharedValue,
386
429
  useShadowHeader,
387
430
  hasSideBar,
431
+ quickReturnOptions,
388
432
  ],
389
433
  );
390
434
 
@@ -400,6 +444,7 @@ const Screen = forwardRef(
400
444
  }
401
445
 
402
446
  options = {
447
+ ...quickReturnOptions,
403
448
  headerTintColor: currentTint.current ?? Colors.black_17,
404
449
  headerTransparent: true,
405
450
  headerBackground: (props: any) =>
@@ -442,6 +487,7 @@ const Screen = forwardRef(
442
487
  hasSideBar,
443
488
  layoutOffset,
444
489
  heightHeader,
490
+ quickReturnOptions,
445
491
  ],
446
492
  );
447
493
 
@@ -465,7 +511,7 @@ const Screen = forwardRef(
465
511
  ),
466
512
  };
467
513
 
468
- navigation?.instance?.setOptions(options);
514
+ navigation?.instance?.setOptions({ header: undefined, ...options });
469
515
  }, [navigation?.instance, sharedValue]);
470
516
 
471
517
  /**
@@ -593,8 +639,36 @@ const Screen = forwardRef(
593
639
  * handle scroll end
594
640
  * @param e
595
641
  */
642
+ /**
643
+ * a half-shown quick return header settles fully in or out
644
+ */
645
+ const snapQuickReturnHeader = (offsetY: number) => {
646
+ const hide = headerHide.value;
647
+ // above heightHeader the header still sits on the content
648
+ if (
649
+ !useQuickReturn ||
650
+ offsetY < heightHeader ||
651
+ hide <= 0 ||
652
+ hide >= heightHeader
653
+ ) {
654
+ return;
655
+ }
656
+ headerHide.value = withTiming(
657
+ hide > heightHeader / 2 ? heightHeader : 0,
658
+ { duration: 200 },
659
+ );
660
+ };
661
+
662
+ const handleMomentumScrollEnd = (
663
+ e: NativeSyntheticEvent<NativeScrollEvent>,
664
+ ) => {
665
+ snapQuickReturnHeader(e.nativeEvent.contentOffset.y);
666
+ scrollViewProps?.onMomentumScrollEnd?.(e);
667
+ };
668
+
596
669
  const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
597
670
  const offsetY = e.nativeEvent.contentOffset.y;
671
+ snapQuickReturnHeader(offsetY);
598
672
  if (inputSearchProps && offsetY < 100 && offsetY > 0) {
599
673
  Animated.timing(animatedValue.current, {
600
674
  toValue: 0,
@@ -722,6 +796,7 @@ const Screen = forwardRef(
722
796
  headerBackground={headerBackground}
723
797
  heightHeader={heightHeader}
724
798
  extended={headerType === 'extended'}
799
+ followScroll={useQuickReturn}
725
800
  />
726
801
  ) : (
727
802
  <HeaderExtendHeader
@@ -759,6 +834,7 @@ const Screen = forwardRef(
759
834
  showsVerticalScrollIndicator={false}
760
835
  onScroll={handleScroll}
761
836
  onScrollEndDrag={handleScrollEnd}
837
+ onMomentumScrollEnd={handleMomentumScrollEnd}
762
838
  scrollEventThrottle={16}
763
839
  style={[
764
840
  Styles.flex,
package/Layout/utils.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  SafeAreaInsetsContext,
6
6
  } from 'react-native-safe-area-context';
7
7
  import { Spacing } from '../Consts';
8
+ import { ReservedRegion, ReservedRegionsContext } from './ReservedRegions';
8
9
 
9
10
  /**
10
11
  * validate children type
@@ -70,6 +71,12 @@ const HeaderMetrics = {
70
71
  * - firstItemTop: with the status bar in the strip, the first control sits under the
71
72
  * camera, clock and connectivity cluster (cluster ends near y 151 on the outer display,
72
73
  * y 98 on the inner display in landscape)
74
+ * - outerHeaderHeight / outerCameraCenterY: on the outer display in portrait the header
75
+ * is 4pt taller than iPhoneMinHeight and its title lines up with the camera punchout
76
+ * (y 24–66 at the top of the strip)
77
+ * - edgeInset: on the outer display in landscape the strip holds no status bar, so its
78
+ * controls start this far from the top and stay this far above the bottom, unless a
79
+ * reserved region (the camera at either end) asks for more
73
80
  */
74
81
  const DuoMetrics = {
75
82
  maxAspectRatio: 1.6,
@@ -78,10 +85,40 @@ const DuoMetrics = {
78
85
  itemPitch: 50,
79
86
  firstItemTop: 170,
80
87
  firstItemTopLandscape: 120,
88
+ outerHeaderHeight: 76,
89
+ outerCameraCenterY: 45,
90
+ edgeInset: 24,
81
91
  };
82
92
 
83
93
  const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
84
94
 
95
+ /**
96
+ * how far system regions reach into a side strip from its top and from its bottom end
97
+ * (e.g. the camera, which sits at either end depending on the rotation)
98
+ */
99
+ const getStripOcclusion = (
100
+ regions: ReservedRegion[],
101
+ stripLeft: number,
102
+ stripRight: number,
103
+ windowHeight: number,
104
+ ) =>
105
+ regions.reduce(
106
+ (occlusion, region) => {
107
+ const overlapsStrip =
108
+ region.x < stripRight && region.x + region.width > stripLeft;
109
+ if (!overlapsStrip) {
110
+ return occlusion;
111
+ }
112
+ if (region.y + region.height / 2 < windowHeight / 2) {
113
+ occlusion.top = Math.max(occlusion.top, region.y + region.height);
114
+ } else {
115
+ occlusion.bottom = Math.max(occlusion.bottom, windowHeight - region.y);
116
+ }
117
+ return occlusion;
118
+ },
119
+ { top: 0, bottom: 0 },
120
+ );
121
+
85
122
  const isIPhone = () =>
86
123
  Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
87
124
 
@@ -120,6 +157,11 @@ export type SideBarLayout = {
120
157
  * Window y of the first side bar item, below the navigation bar and the side status bar.
121
158
  */
122
159
  top: number;
160
+
161
+ /**
162
+ * Room to keep free above the window bottom, clear of a camera at the bottom end.
163
+ */
164
+ bottom: number;
123
165
  };
124
166
 
125
167
  export type ScreenLayout = {
@@ -201,8 +243,9 @@ const isIPhoneDuoLayout = (
201
243
  const useScreenLayout = (): ScreenLayout => {
202
244
  const { width, height } = useWindowDimensions();
203
245
  const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
246
+ const reservedRegions = useContext(ReservedRegionsContext);
204
247
 
205
- const headerLayout = getHeaderLayout(insets.top);
248
+ let headerLayout = getHeaderLayout(insets.top);
206
249
  const isIPhoneDuo = isIPhoneDuoLayout(width, height, insets);
207
250
  if (!isIPhoneDuo) {
208
251
  return {
@@ -224,17 +267,42 @@ const useScreenLayout = (): ScreenLayout => {
224
267
  let top = insets.top + HeaderMetrics.barHeight + Spacing.M;
225
268
  if (insets.top < HeaderMetrics.statusBarMinInset) {
226
269
  // status bar lives in the strip: start under its cluster, as the native bar does
227
- const isInnerLandscape =
228
- width > height && height >= DuoMetrics.innerMinHeight;
229
- top = isInnerLandscape
230
- ? DuoMetrics.firstItemTopLandscape
231
- : DuoMetrics.firstItemTop;
270
+ const isLandscape = width > height;
271
+ if (!isLandscape) {
272
+ top = DuoMetrics.firstItemTop;
273
+ } else if (height >= DuoMetrics.innerMinHeight) {
274
+ top = DuoMetrics.firstItemTopLandscape;
275
+ } else {
276
+ // outer display in landscape: no status bar in the strip
277
+ top = DuoMetrics.edgeInset;
278
+ }
279
+
280
+ if (height > width && width < DuoMetrics.innerMinHeight) {
281
+ // outer display: the row starts below the top so the title centers on the camera
282
+ headerLayout = {
283
+ headerHeight: DuoMetrics.outerHeaderHeight,
284
+ headerStatusBarHeight:
285
+ DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight,
286
+ };
287
+ }
232
288
  }
233
289
 
290
+ const edge = insets.right >= insets.left ? 'right' : 'left';
291
+ const stripLeft = edge === 'right' ? width - sideInset : 0;
292
+ // keep controls clear of the camera and of the status bar cluster, which grows for
293
+ // calls and Live Activities. Region frames already include the system's margin
294
+ const occlusion = getStripOcclusion(
295
+ reservedRegions,
296
+ stripLeft,
297
+ stripLeft + sideInset,
298
+ height,
299
+ );
300
+
234
301
  sideBar = {
235
- edge: insets.right >= insets.left ? 'right' : 'left',
302
+ edge,
236
303
  width: sideInset,
237
- top,
304
+ top: Math.max(top, occlusion.top),
305
+ bottom: Math.max(DuoMetrics.edgeInset, occlusion.bottom),
238
306
  };
239
307
  }
240
308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duo.3",
3
+ "version": "0.167.1-duo.4",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},