@momo-kits/foundation 0.167.1-duo.8 → 0.167.1-duotest.2

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.
@@ -29,8 +29,13 @@ import { getOptions, getStackOptions, useAppState } from '../utils';
29
29
  import BottomTabBar from './BottomTabBar';
30
30
  import { DuoTabBar } from './DuoTabBar';
31
31
  import { DuoBarCompressionProvider } from './DuoBarCompression';
32
- import { ApplicationContext, MiniAppContext } from '../../Context';
32
+ import {
33
+ ApplicationContext,
34
+ MiniAppContext,
35
+ ScreenContext,
36
+ } from '../../Context';
33
37
  import { useScreenLayout } from '../../Layout';
38
+ import { getHeaderRowOptions } from '../../Layout/utils';
34
39
 
35
40
  const Tab = createBottomTabNavigator();
36
41
  const Stack = createStackNavigator();
@@ -39,13 +44,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
39
44
  const { navigator } = useContext(ApplicationContext);
40
45
  const context = useContext<any>(MiniAppContext);
41
46
  const { nested, options, screen, initialParams } = route?.params;
42
- const { headerHeight, headerStatusBarHeight } = useScreenLayout();
43
- const headerStyleOptions = {
44
- headerStyle: {
45
- height: headerHeight,
46
- },
47
- headerStatusBarHeight,
48
- };
47
+ const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
49
48
 
50
49
  const opacityValue = 0.3;
51
50
  const scaleValue = 0.97;
@@ -209,7 +208,14 @@ const BottomTab: React.FC<BottomTabProps> = ({
209
208
  compressionBehavior,
210
209
  }) => {
211
210
  const { theme, navigator } = useContext(ApplicationContext);
211
+ const screen: any = useContext(ScreenContext);
212
212
  const { isIPhoneDuo } = useScreenLayout();
213
+ // each tab lays itself out, the hosting stack screen must not add a header or insets
214
+ screen?.claimHeader?.();
215
+ useEffect(() => {
216
+ screen?.syncHeader?.();
217
+ // eslint-disable-next-line react-hooks/exhaustive-deps
218
+ }, []);
213
219
  const dimensions = useWindowDimensions();
214
220
  const insets = useSafeAreaInsets();
215
221
  const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
@@ -16,6 +16,7 @@ import { SearchHeaderProps } from '../types';
16
16
  import BackgroundImageView from './BackgroundImageView';
17
17
  import { AnimatedCompatValue, useNormalizedSharedValue } from '../utils';
18
18
  import { useScreenLayout } from '../../Layout';
19
+ import { DuoMetrics, HeaderMetrics } from '../../Layout/utils';
19
20
 
20
21
  const SCREEN_PADDING = 12;
21
22
  const BACK_WIDTH = 28;
@@ -50,17 +51,33 @@ const HeaderExtendHeader: React.FC<{
50
51
  const headerBackground = customBackground ?? theme.assets?.headerBackground;
51
52
  const layout = useScreenLayout();
52
53
 
54
+ // the iPhone Duo status bar row keeps side bar sized back and toolkit
55
+ const backWidth = layout.statusBarClearance
56
+ ? DuoMetrics.controlSize
57
+ : BACK_WIDTH;
58
+ const toolkitWidth = layout.statusBarClearance
59
+ ? DuoMetrics.itemPitch * 2 + SCREEN_PADDING
60
+ : TOOLKIT_WIDTH;
53
61
  const leftPosition =
54
62
  inputSearchProps?.leftPosition ||
55
- (layout.sideBar ? SCREEN_PADDING : BACK_WIDTH + 20);
63
+ (layout.sideBar ? SCREEN_PADDING : backWidth + 20);
56
64
  const headerRightWidth =
57
- customHeaderRightWidth ?? (layout.sideBar ? 0 : TOOLKIT_WIDTH);
65
+ customHeaderRightWidth ?? (layout.sideBar ? 0 : toolkitWidth);
58
66
  const contentLeft = layout.insetLeft + SCREEN_PADDING;
59
67
  const contentRight = layout.insetLeft + layout.contentWidth;
60
- const collapsedLeft = layout.insetLeft + leftPosition;
68
+ // collapsed on the status bar row, the search stays between its clusters
69
+ const collapsedLeft =
70
+ layout.insetLeft + leftPosition + (layout.statusBarClearance?.left ?? 0);
71
+ const collapsedRight = contentRight - (layout.statusBarClearance?.right ?? 0);
61
72
  const searchWidth = layout.contentWidth - SCREEN_PADDING * 2;
62
73
  const collapsedSearchWidth =
63
- contentRight - collapsedLeft - SCREEN_PADDING - headerRightWidth;
74
+ collapsedRight - collapsedLeft - SCREEN_PADDING - headerRightWidth;
75
+ // a row taller than barHeight (iPhone Duo) centers the collapsed search on the title
76
+ const collapsedRaise =
77
+ (layout.headerHeight -
78
+ layout.headerStatusBarHeight -
79
+ HeaderMetrics.barHeight) /
80
+ 2;
64
81
 
65
82
  let useShadowHeader = useShadowHeaderProp;
66
83
  if (inputSearchProps && Platform.OS === 'android') {
@@ -97,6 +114,14 @@ const HeaderExtendHeader: React.FC<{
97
114
  Extrapolation.CLAMP,
98
115
  ),
99
116
  },
117
+ {
118
+ translateY: interpolate(
119
+ sharedValue.value,
120
+ [0, 100],
121
+ [0, -collapsedRaise],
122
+ Extrapolation.CLAMP,
123
+ ),
124
+ },
100
125
  ],
101
126
  width: interpolate(
102
127
  sharedValue.value,
@@ -143,7 +168,12 @@ const HeaderExtendHeader: React.FC<{
143
168
  >
144
169
  {!!gradientColor && (
145
170
  <Animated.View
146
- style={[styles.extendedHeader, gradientOpacityStyle]}
171
+ style={[
172
+ styles.extendedHeader,
173
+ // fade out within the header box, as DuoScrollGradient does
174
+ !!layout.sideBar && styles.fillHeaderBox,
175
+ gradientOpacityStyle,
176
+ ]}
147
177
  >
148
178
  <LinearGradient
149
179
  colors={[gradientColor, gradientColor + '00']}
@@ -240,6 +270,10 @@ const styles = StyleSheet.create({
240
270
  position: 'absolute',
241
271
  width: '100%',
242
272
  },
273
+ fillHeaderBox: {
274
+ aspectRatio: undefined,
275
+ height: '100%',
276
+ },
243
277
  headerBox: {
244
278
  width: '100%',
245
279
  position: 'absolute',
@@ -6,7 +6,7 @@ import { PopupNotify } from '../../Popup';
6
6
  import { NavigationButton } from './NavigationButton';
7
7
  import { SideBarButton } from './SideBarButton';
8
8
  import Animated from 'react-native-reanimated';
9
- import { useSideBarItemStyle } from '../../Layout/utils';
9
+ import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
10
10
  import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
11
11
 
12
12
  /**
@@ -23,6 +23,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
23
23
  const { navigator } = useContext(ApplicationContext);
24
24
  const sideBarStyle = useSideBarItemStyle('left', 0);
25
25
  const counterStyle = useSideBarCounterStyle();
26
+ const { statusBarClearance } = useScreenLayout();
26
27
 
27
28
  const goBack = useCallback(() => {
28
29
  const canGoBack = navigator?.ref?.current?.canGoBack?.();
@@ -95,6 +96,20 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
95
96
  );
96
97
  }
97
98
 
99
+ // on the status bar row the controls keep the side bar size
100
+ if (statusBarClearance) {
101
+ return (
102
+ <View style={styles.headerLeft}>
103
+ <SideBarButton
104
+ accessibilityLabel="btn_navigation_back"
105
+ icon="arrow-back"
106
+ tintColor={tintColor}
107
+ onPress={goBackSafe}
108
+ />
109
+ </View>
110
+ );
111
+ }
112
+
98
113
  return (
99
114
  <View style={styles.headerLeft}>
100
115
  <NavigationButton
@@ -14,7 +14,7 @@ import { Tool, ToolGroup } from '../types';
14
14
  import { Icon } from '../../Icon';
15
15
  import { Text } from '../../Text';
16
16
  import Animated from 'react-native-reanimated';
17
- import { useSideBarItemStyle } from '../../Layout/utils';
17
+ import { useScreenLayout, useSideBarItemStyle } from '../../Layout/utils';
18
18
  import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
19
19
  import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
20
20
  import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
@@ -85,6 +85,7 @@ const HeaderToolkitAction: React.FC<any> = ({
85
85
  const context = useContext<any>(MiniAppContext);
86
86
  const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
87
87
  useDuoBarCompression();
88
+ const { statusBarClearance } = useScreenLayout();
88
89
 
89
90
  const [isFavorite, setIsFavorite] = useState<boolean>(false);
90
91
  const [isLoading, setIsLoading] = useState(false);
@@ -358,6 +359,39 @@ const HeaderToolkitAction: React.FC<any> = ({
358
359
  );
359
360
  }
360
361
 
362
+ // on the status bar row the controls keep the side bar size
363
+ if (statusBarClearance) {
364
+ return (
365
+ <View style={styles.sideBarRow}>
366
+ {useShortcut && (
367
+ <SideBarButton
368
+ disabled={isLoading}
369
+ icon={iconShortcut}
370
+ onPress={shortcutOnPress}
371
+ tintColor={tintColor}
372
+ showBadge={showBadge}
373
+ />
374
+ )}
375
+ <SideBarGroup tintColor={tintColor} horizontal>
376
+ {useHelpCenter && (
377
+ <SideBarGroupItem
378
+ accessibilityLabel={'btn_navigation_help_center'}
379
+ icon={'help_center'}
380
+ tintColor={tintColor}
381
+ onPress={onPressHelpCenter}
382
+ />
383
+ )}
384
+ <SideBarGroupItem
385
+ accessibilityLabel={'btn_navigation_close'}
386
+ icon={closeIcon}
387
+ tintColor={tintColor}
388
+ onPress={onClose}
389
+ />
390
+ </SideBarGroup>
391
+ </View>
392
+ );
393
+ }
394
+
361
395
  return (
362
396
  <View style={Styles.row}>
363
397
  {useShortcut && (
@@ -471,6 +505,10 @@ const styles = StyleSheet.create({
471
505
  sideBarColumn: {
472
506
  gap: Spacing.M,
473
507
  },
508
+ sideBarRow: {
509
+ flexDirection: 'row',
510
+ gap: Spacing.M,
511
+ },
474
512
  headerLeft: {
475
513
  marginLeft: 12,
476
514
  },
@@ -33,15 +33,29 @@ const useHeaderFontFamily = (): string => {
33
33
  */
34
34
  const HEADER_ACTIONS_WIDTH = 172;
35
35
 
36
+ /**
37
+ * extra width the 48pt back and toolkit take on the iPhone Duo status bar row
38
+ */
39
+ const STATUS_BAR_ROW_ACTIONS_EXTRA = 55;
40
+
36
41
  /**
37
42
  * max title width, the iPhone Duo side bar takes back and toolkit out of the bar
38
43
  */
39
44
  const useTitleMaxWidth = () => {
40
- const { width, contentWidth, sideBar } = useScreenLayout();
45
+ const { width, contentWidth, sideBar, statusBarClearance } =
46
+ useScreenLayout();
47
+ const clearance = statusBarClearance
48
+ ? statusBarClearance.left + statusBarClearance.right
49
+ : 0;
41
50
  if (sideBar) {
42
- return contentWidth - Spacing.M * 2;
51
+ return contentWidth - Spacing.M * 2 - clearance;
43
52
  }
44
- return width - HEADER_ACTIONS_WIDTH;
53
+ return (
54
+ width -
55
+ HEADER_ACTIONS_WIDTH -
56
+ (statusBarClearance ? STATUS_BAR_ROW_ACTIONS_EXTRA : 0) -
57
+ clearance
58
+ );
45
59
  };
46
60
 
47
61
  type HeaderTitleInterpolate = {
@@ -0,0 +1,12 @@
1
+ import type React from 'react';
2
+
3
+ // native observer is optional so apps without it in @momo-kits/reserved-regions still bundle
4
+ let ScrollObserverView: React.ComponentType<any> | undefined;
5
+ try {
6
+ ScrollObserverView =
7
+ require('@momo-kits/reserved-regions').ScrollObserverView;
8
+ } catch {
9
+ ScrollObserverView = undefined;
10
+ }
11
+
12
+ export { ScrollObserverView };
@@ -8,6 +8,7 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
8
8
  import { Text } from '../../Text';
9
9
  import { useNormalizedSharedValue } from '../utils';
10
10
  import { useScreenLayout } from '../../Layout';
11
+ import { DuoMetrics } from '../../Layout/utils';
11
12
 
12
13
  const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
13
14
  (
@@ -22,8 +23,9 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
22
23
  ) => {
23
24
  const { theme, navigator } = useContext(ApplicationContext);
24
25
  const context = useContext<any>(MiniAppContext);
25
- const BACK_WIDTH = 28;
26
26
  const layout = useScreenLayout();
27
+ // the iPhone Duo status bar row keeps a side bar sized back button
28
+ const BACK_WIDTH = layout.statusBarClearance ? DuoMetrics.controlSize : 28;
27
29
 
28
30
  const sharedValue = useNormalizedSharedValue(animatedValue);
29
31
  const leftPosition = props?.leftPosition ?? BACK_WIDTH + 20;
@@ -34,6 +36,10 @@ const SearchHeader = React.forwardRef<InputRef, SearchHeaderProps>(
34
36
  if (layout.sideBar) {
35
37
  searchMaxWidth = layout.contentWidth - Spacing.M * 2 - headerRightWidth;
36
38
  }
39
+ if (layout.statusBarClearance) {
40
+ searchMaxWidth -=
41
+ layout.statusBarClearance.left + layout.statusBarClearance.right;
42
+ }
37
43
 
38
44
  const backgroundStyle = useAnimatedStyle(() => {
39
45
  const t = Math.min(Math.max(sharedValue.value / 100, 0), 1);
@@ -54,19 +54,30 @@ const SideBarButton: React.FC<NavigationButtonProps> = ({
54
54
  };
55
55
 
56
56
  /**
57
- * 48pt wide glass capsule grouping related controls, one item per 50pt
57
+ * 48pt glass capsule grouping related controls, one item per 50pt, laid out vertically
58
+ * in the side bar or horizontally on a header row
58
59
  */
59
60
  const SideBarGroup: React.FC<{
60
61
  tintColor?: string;
62
+ horizontal?: boolean;
61
63
  style?: StyleProp<ViewStyle>;
62
64
  children: ReactNode;
63
- }> = ({ tintColor, style, children }) => {
65
+ }> = ({ tintColor, horizontal = false, style, children }) => {
64
66
  const count = React.Children.toArray(children).length;
65
- const height =
67
+ const length =
66
68
  count > 1 ? count * DuoMetrics.itemPitch : DuoMetrics.controlSize;
67
69
 
68
70
  return (
69
- <View style={[styles.control, getGlassStyle(tintColor), { height }, style]}>
71
+ <View
72
+ style={[
73
+ styles.control,
74
+ getGlassStyle(tintColor),
75
+ horizontal
76
+ ? { width: length, flexDirection: 'row' }
77
+ : { height: length },
78
+ style,
79
+ ]}
80
+ >
70
81
  {children}
71
82
  </View>
72
83
  );
@@ -31,6 +31,7 @@ import { HeaderTitle } from './Components/HeaderTitle';
31
31
  import { HeaderBackground } from './Components/HeaderBackground';
32
32
  import { type EdgeInsets } from 'react-native-safe-area-context';
33
33
  import { useScreenLayout } from '../Layout';
34
+ import { getHeaderRowOptions } from '../Layout/utils';
34
35
  import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
35
36
 
36
37
  const Stack = createStackNavigator();
@@ -107,7 +108,7 @@ const Navigation: React.FC<any> = ({
107
108
  const navigationRef = useRef<any>(null);
108
109
  const isReady = useRef(false);
109
110
  const navigator = useRef(new Navigator(navigationRef, isReady));
110
- const { headerHeight, headerStatusBarHeight } = useScreenLayout();
111
+ const headerRowOptions = getHeaderRowOptions(useScreenLayout());
111
112
  const syncCanGoBack = useNativeCanGoBack();
112
113
  const isWhiteList = isWhiteListDesignSystem(context);
113
114
  const fontFamily = isWhiteList ? 'MomoTrustSans' : 'SFProText';
@@ -204,10 +205,7 @@ const Navigation: React.FC<any> = ({
204
205
  gestureEnabled: false,
205
206
  headerTitleAlign: 'center',
206
207
  headerTintColor: theme.colors.text.default,
207
- headerStyle: {
208
- height: headerHeight,
209
- },
210
- headerStatusBarHeight,
208
+ ...headerRowOptions,
211
209
  }}
212
210
  >
213
211
  <Stack.Screen
@@ -1,11 +1,27 @@
1
- import React, { useContext, useEffect, useLayoutEffect, useRef } from 'react';
2
- import { Alert, InteractionManager } from 'react-native';
1
+ import React, {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import {
10
+ Alert,
11
+ InteractionManager,
12
+ Platform,
13
+ StyleSheet,
14
+ View,
15
+ } from 'react-native';
16
+ import Animated from 'react-native-reanimated';
3
17
  import { useHeaderHeight } from '@react-navigation/elements';
4
18
  import { ScreenTrackingParams } from './types';
5
19
  import Navigation from './Navigation';
6
20
  import { ApplicationContext, MiniAppContext, ScreenContext } from '../Context';
7
21
  import { version } from '../package.json';
8
- import { SwipeBackHaptic, useAppState } from './utils';
22
+ import { SwipeBackHaptic, useAppState, useHeaderScrollState } from './utils';
23
+ import { ScrollObserverView } from './Components/OptionalScrollObserver';
24
+ import { useScreenLayout } from '../Layout/utils';
9
25
  import { TooltipPortalHost, TooltipPortalProvider } from './TooltipPortal';
10
26
 
11
27
  const runAfterInteractions = InteractionManager.runAfterInteractions;
@@ -51,6 +67,24 @@ const StackScreen: React.FC<any> = props => {
51
67
  const navigation = useRef(new Navigation(props.navigation, context)).current;
52
68
  const heightHeader = useHeaderHeight();
53
69
  const { isBackgroundToForeground } = useAppState();
70
+ const layout = useScreenLayout();
71
+
72
+ /**
73
+ * iPhone Duo: a screen without <Screen> keeps clear of the side bar and, with the
74
+ * native scroll observer, gets the quick return header, so mini apps need no change
75
+ */
76
+ const canAutoLayout = Platform.OS === 'ios' && !bottomTab;
77
+ const Observer: React.ComponentType<any> = ScrollObserverView ?? View;
78
+ const headerClaimed = useRef(false);
79
+ const [autoHeader, setAutoHeader] = useState(false);
80
+ const syncHeader = useCallback(() => {
81
+ setAutoHeader(
82
+ canAutoLayout && layout.isIPhoneDuo && !headerClaimed.current,
83
+ );
84
+ }, [canAutoLayout, layout.isIPhoneDuo]);
85
+ // parent effects run after the screen's, so a claim made on mount is already in
86
+ useEffect(syncHeader, [syncHeader]);
87
+ const headerScroll = useHeaderScrollState({ navigation }, autoHeader);
54
88
 
55
89
  const data = {
56
90
  ...initialParams,
@@ -407,10 +441,29 @@ const StackScreen: React.FC<any> = props => {
407
441
  onSetParams: (item: ScreenTrackingParams) => {
408
442
  tracking.current.params = item;
409
443
  },
444
+ claimHeader: () => {
445
+ headerClaimed.current = true;
446
+ },
447
+ syncHeader,
410
448
  }}
411
449
  >
412
450
  <TooltipPortalProvider>
413
- <Component heightHeader={heightHeader} {...data} />
451
+ {canAutoLayout ? (
452
+ // the tree stays the same whether auto layout is on, so the screen never remounts
453
+ <Observer
454
+ enabled={autoHeader}
455
+ pointerEvents={'box-none'}
456
+ style={styles.flex}
457
+ onScrollChange={headerScroll.onScrollChange}
458
+ >
459
+ {headerScroll.background}
460
+ <Animated.View style={[styles.flex, headerScroll.contentStyle]}>
461
+ <Component heightHeader={heightHeader} {...data} />
462
+ </Animated.View>
463
+ </Observer>
464
+ ) : (
465
+ <Component heightHeader={heightHeader} {...data} />
466
+ )}
414
467
  <TooltipPortalHost />
415
468
  <SwipeBackHaptic />
416
469
  </TooltipPortalProvider>
@@ -418,4 +471,8 @@ const StackScreen: React.FC<any> = props => {
418
471
  );
419
472
  };
420
473
 
474
+ const styles = StyleSheet.create({
475
+ flex: { flex: 1 },
476
+ });
477
+
421
478
  export default StackScreen;
@@ -361,3 +361,20 @@ export interface SearchHeaderProps extends InputSearchProps {
361
361
  renderButtons?: () => ReactNode;
362
362
  hiddenBack?: boolean;
363
363
  }
364
+
365
+ export type HeaderScrollOptions = {
366
+ /**
367
+ * Navigation of the screen whose header follows the scroll.
368
+ */
369
+ navigation?: Navigation;
370
+ useShadowHeader?: boolean;
371
+ gradientColor?: string;
372
+ headerBackground?: string;
373
+ };
374
+
375
+ export type ScrollChange = {
376
+ y: number;
377
+ contentHeight: number;
378
+ viewportHeight: number;
379
+ phase: 'scroll' | 'dragEnd' | 'momentumEnd' | 'takeOver';
380
+ };
@@ -16,12 +16,23 @@ import type {
16
16
  StackCardInterpolationProps,
17
17
  } from '@react-navigation/stack';
18
18
  import type { NavigationState, PartialState } from '@react-navigation/native';
19
- import type { HeaderTitleProps, NavigationOptions } from './types';
19
+ import type {
20
+ HeaderScrollOptions,
21
+ ScrollChange,
22
+ HeaderTitleProps,
23
+ NavigationOptions,
24
+ } from './types';
20
25
  import { ApplicationContext } from '../Context';
21
26
  import { Colors, Spacing } from '../Consts';
22
27
  import { Animated, AppState, NativeModules, Platform } from 'react-native';
28
+ import { useHeaderHeight } from '@react-navigation/elements';
23
29
  import type { SharedValue } from 'react-native-reanimated';
24
- import { useSharedValue } from 'react-native-reanimated';
30
+ import {
31
+ useAnimatedReaction,
32
+ useAnimatedStyle,
33
+ useSharedValue,
34
+ withTiming,
35
+ } from 'react-native-reanimated';
25
36
  import {
26
37
  MiniAppContext,
27
38
  ScreenContext,
@@ -31,6 +42,12 @@ import { HeaderTitle, TitleCustom } from './Components/HeaderTitle';
31
42
  import { HeaderBackground } from './Components/HeaderBackground';
32
43
  import { HeaderLeft } from './Components/HeaderLeft';
33
44
  import { HeaderRight } from './Components/HeaderRight';
45
+ import {
46
+ DuoGlassBackground,
47
+ DuoScrollGradient,
48
+ } from './Components/DuoGlassBackground';
49
+ import { DuoQuickReturnHeader } from './Components/DuoQuickReturnHeader';
50
+ import { useScreenLayout } from '../Layout/utils';
34
51
 
35
52
  const forStackTransitionWithOverlay = (
36
53
  props: StackCardInterpolationProps,
@@ -404,7 +421,9 @@ const getSwipeGestureValue = (
404
421
  };
405
422
 
406
423
  const readAnimatedValue = (node: unknown, fallback: number): number => {
407
- const value = (node as { __getValue?: () => unknown } | undefined)?.__getValue?.();
424
+ const value = (
425
+ node as { __getValue?: () => unknown } | undefined
426
+ )?.__getValue?.();
408
427
  return typeof value === 'number' ? value : fallback;
409
428
  };
410
429
 
@@ -481,6 +500,218 @@ const SwipeBackHaptic = () => {
481
500
  const isWhiteListDesignSystem = (context: any): boolean =>
482
501
  context?.designSystemConfig?.isWhiteListDesignSystem === true;
483
502
 
503
+ /**
504
+ * iPhone Duo: the header leaves with the content and returns on a reverse scroll
505
+ */
506
+ const useDuoQuickReturn = (
507
+ scrollY: SharedValue<number>,
508
+ enabled: boolean,
509
+ heightHeader: number,
510
+ ) => {
511
+ const headerHide = useSharedValue(0);
512
+ const lastScrollY = useSharedValue(0);
513
+
514
+ useAnimatedReaction(
515
+ () => scrollY.value,
516
+ y => {
517
+ const delta = y - lastScrollY.value;
518
+ lastScrollY.value = y;
519
+ if (!enabled || y <= 0) {
520
+ headerHide.value = 0;
521
+ return;
522
+ }
523
+ headerHide.value = Math.min(
524
+ Math.max(headerHide.value + delta, 0),
525
+ heightHeader,
526
+ );
527
+ },
528
+ [enabled, heightHeader],
529
+ );
530
+
531
+ const quickReturnOptions: StackNavigationOptions = useMemo(
532
+ () =>
533
+ enabled
534
+ ? {
535
+ header: (props: any) => (
536
+ <DuoQuickReturnHeader {...props} offset={headerHide} />
537
+ ),
538
+ }
539
+ : { header: undefined },
540
+ [enabled, headerHide],
541
+ );
542
+
543
+ /**
544
+ * a half-shown quick return header settles fully in or out
545
+ */
546
+ const snap = useCallback(
547
+ (offsetY: number) => {
548
+ const hide = headerHide.value;
549
+ // above heightHeader the header still sits on the content
550
+ if (
551
+ !enabled ||
552
+ offsetY < heightHeader ||
553
+ hide <= 0 ||
554
+ hide >= heightHeader
555
+ ) {
556
+ return;
557
+ }
558
+ headerHide.value = withTiming(
559
+ hide > heightHeader / 2 ? heightHeader : 0,
560
+ { duration: 200 },
561
+ );
562
+ },
563
+ [enabled, heightHeader, headerHide],
564
+ );
565
+
566
+ /**
567
+ * moves the reference point without counting it as a scroll
568
+ */
569
+ const resync = useCallback(
570
+ (y: number) => {
571
+ lastScrollY.value = y;
572
+ },
573
+ [lastScrollY],
574
+ );
575
+
576
+ return { headerHide, quickReturnOptions, snap, resync };
577
+ };
578
+
579
+ /**
580
+ * Header scroll state for screens that render their own scroll views instead of
581
+ * `<Screen scrollable>`, such as one ScrollView per TabView page.
582
+ */
583
+ /**
584
+ * header scroll state fed by any scroll source; off, it leaves the header and content alone
585
+ */
586
+ const useHeaderScrollState = (
587
+ {
588
+ navigation,
589
+ useShadowHeader = true,
590
+ gradientColor,
591
+ headerBackground,
592
+ }: HeaderScrollOptions,
593
+ enabled: boolean,
594
+ ) => {
595
+ const layout = useScreenLayout();
596
+ const heightHeader = useHeaderHeight();
597
+ const scrollY = useSharedValue(0);
598
+ const hasSideBar = !!layout.sideBar;
599
+ const duo = enabled && hasSideBar;
600
+
601
+ const viewportHeight = useRef<number | undefined>(undefined);
602
+ const { headerHide, quickReturnOptions, snap, resync } = useDuoQuickReturn(
603
+ scrollY,
604
+ duo,
605
+ heightHeader,
606
+ );
607
+
608
+ useEffect(() => {
609
+ if (!enabled) {
610
+ return;
611
+ }
612
+ const options: StackNavigationOptions = hasSideBar
613
+ ? {
614
+ ...quickReturnOptions,
615
+ headerTransparent: true,
616
+ headerTintColor: Colors.black_17,
617
+ headerBackground: () => (
618
+ <DuoGlassBackground animatedValue={scrollY} />
619
+ ),
620
+ headerTitle: (props: any) => <HeaderTitle {...props} />,
621
+ }
622
+ : {
623
+ header: undefined,
624
+ headerTransparent: false,
625
+ headerTintColor: Colors.black_17,
626
+ headerBackground: (props: any) => (
627
+ <HeaderBackground
628
+ {...props}
629
+ animatedValue={scrollY}
630
+ useShadowHeader={useShadowHeader}
631
+ headerBackground={headerBackground}
632
+ gradientColor={gradientColor}
633
+ />
634
+ ),
635
+ headerTitle: (props: any) => <HeaderTitle {...props} />,
636
+ };
637
+
638
+ navigation?.instance?.setOptions(options);
639
+ }, [
640
+ enabled,
641
+ navigation?.instance,
642
+ hasSideBar,
643
+ quickReturnOptions,
644
+ scrollY,
645
+ useShadowHeader,
646
+ headerBackground,
647
+ gradientColor,
648
+ ]);
649
+
650
+ const setScrollOffset = useCallback(
651
+ (y: number) => {
652
+ resync(y);
653
+ scrollY.value = y;
654
+ },
655
+ [scrollY, resync],
656
+ );
657
+
658
+ const onScrollChange = useCallback(
659
+ ({
660
+ y: offsetY,
661
+ contentHeight,
662
+ viewportHeight: height,
663
+ phase,
664
+ }: ScrollChange) => {
665
+ // the bottom bounce is not a reverse scroll
666
+ const maxY = Math.max(contentHeight - height, 0);
667
+ const y = Math.min(offsetY, maxY);
668
+ if (phase === 'takeOver') {
669
+ viewportHeight.current = height;
670
+ setScrollOffset(y);
671
+ return;
672
+ }
673
+ if (phase !== 'scroll') {
674
+ snap(offsetY);
675
+ return;
676
+ }
677
+ // the quick return resizes the viewport; pinned at the bottom, the offset
678
+ // iOS clamps to the new size is not a scroll
679
+ const resized = height !== viewportHeight.current;
680
+ viewportHeight.current = height;
681
+ if (resized && offsetY >= maxY - 1) {
682
+ resync(y);
683
+ }
684
+ scrollY.value = y;
685
+ },
686
+ [scrollY, snap, resync, setScrollOffset],
687
+ );
688
+
689
+ const { insetLeft, insetRight } = layout;
690
+ const contentStyle = useAnimatedStyle(
691
+ () =>
692
+ enabled
693
+ ? {
694
+ paddingLeft: insetLeft,
695
+ paddingRight: insetRight,
696
+ paddingTop: hasSideBar ? heightHeader - headerHide.value : 0,
697
+ }
698
+ : { paddingLeft: 0, paddingRight: 0, paddingTop: 0 },
699
+ [enabled, hasSideBar, heightHeader, insetLeft, insetRight],
700
+ );
701
+
702
+ const background = duo ? (
703
+ <DuoScrollGradient
704
+ animatedValue={scrollY}
705
+ gradientColor={gradientColor}
706
+ headerBackground={headerBackground}
707
+ heightHeader={heightHeader}
708
+ followScroll
709
+ />
710
+ ) : null;
711
+
712
+ return { scrollY, onScrollChange, setScrollOffset, background, contentStyle };
713
+ };
714
+
484
715
  export {
485
716
  getStackOptions,
486
717
  getModalOptions,
@@ -492,6 +723,8 @@ export {
492
723
  useAppState,
493
724
  useNativeCanGoBack,
494
725
  useNormalizedSharedValue,
726
+ useDuoQuickReturn,
727
+ useHeaderScrollState,
495
728
  SwipeBackHaptic,
496
729
  isWhiteListDesignSystem,
497
730
  };
package/Layout/Screen.tsx CHANGED
@@ -9,7 +9,6 @@ import React, {
9
9
  useContext,
10
10
  useEffect,
11
11
  useImperativeHandle,
12
- useMemo,
13
12
  useRef,
14
13
  } from 'react';
15
14
  import {
@@ -25,11 +24,7 @@ import {
25
24
  View,
26
25
  ViewProps,
27
26
  } from 'react-native';
28
- import {
29
- useAnimatedReaction,
30
- useSharedValue,
31
- withTiming,
32
- } from 'react-native-reanimated';
27
+ import { useSharedValue, withTiming } from 'react-native-reanimated';
33
28
  import {
34
29
  useSafeAreaFrame,
35
30
  useSafeAreaInsets,
@@ -47,7 +42,11 @@ import { FloatingButton, FloatingButtonProps } from './FloatingButton';
47
42
  import styles from './styles';
48
43
  import { HeaderType } from './types';
49
44
  import { InputRef } from '../Input';
50
- import { exportHeaderTitle, getOptions } from '../Application/utils';
45
+ import {
46
+ exportHeaderTitle,
47
+ getOptions,
48
+ useDuoQuickReturn,
49
+ } from '../Application/utils';
51
50
  import { getHeaderMargin, useScreenLayout, validateChildren } from './utils';
52
51
  import Section from './Section';
53
52
  import {
@@ -61,7 +60,6 @@ import {
61
60
  DuoGlassBackground,
62
61
  DuoScrollGradient,
63
62
  } from '../Application/Components/DuoGlassBackground';
64
- import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
65
63
  import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
66
64
 
67
65
  export interface ScreenProps extends ViewProps {
@@ -277,37 +275,8 @@ const Screen = forwardRef(
277
275
  // iPhone Duo: the header leaves with the content and returns on a reverse scroll
278
276
  const useQuickReturn =
279
277
  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
- );
278
+ const { quickReturnOptions, snap: snapQuickReturnHeader } =
279
+ useDuoQuickReturn(sharedValue, useQuickReturn, heightHeader);
311
280
 
312
281
  let keyboardOffset = heightHeader - bottomInset;
313
282
  if (
@@ -323,6 +292,12 @@ const Screen = forwardRef(
323
292
  * inject params for screen tracking
324
293
  */
325
294
  screen?.onSetParams?.(trackingParams);
295
+ // the screen drives its header, the stack screen's scroll observer stays off
296
+ screen?.claimHeader?.();
297
+ useEffect(() => {
298
+ screen?.syncHeader?.();
299
+ // eslint-disable-next-line react-hooks/exhaustive-deps
300
+ }, []);
326
301
 
327
302
  /**
328
303
  * export options for screen
@@ -525,6 +500,10 @@ const Screen = forwardRef(
525
500
  searchWidth = layout.contentWidth - Spacing.M * 2;
526
501
  searchLeft = Spacing.M - getHeaderMargin(layout.width);
527
502
  }
503
+ if (layout.statusBarClearance) {
504
+ searchWidth -=
505
+ layout.statusBarClearance.left + layout.statusBarClearance.right;
506
+ }
528
507
  const options: StackNavigationOptions = {
529
508
  headerRight: undefined,
530
509
  headerTitleContainerStyle: {
@@ -635,30 +614,6 @@ const Screen = forwardRef(
635
614
  );
636
615
  }
637
616
 
638
- /**
639
- * handle scroll end
640
- * @param e
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
617
  const handleMomentumScrollEnd = (
663
618
  e: NativeSyntheticEvent<NativeScrollEvent>,
664
619
  ) => {
@@ -880,22 +835,32 @@ const Screen = forwardRef(
880
835
  )}
881
836
 
882
837
  {Footer && (
838
+ // with side insets, the clip keeps the shadow off the side bar, only the top one shows
883
839
  <View
840
+ pointerEvents={'box-none'}
884
841
  style={[
885
- styles.shadow,
842
+ hasContentInset && styles.footerClip,
886
843
  {
887
- paddingBottom:
888
- (isTab && duoTabInset ? duoTabInset : bottomInset) +
889
- Spacing.S,
890
- paddingLeft: layout.insetLeft,
891
- paddingRight: layout.insetRight,
892
- backgroundColor: theme.colors.background.surface,
844
+ marginLeft: layout.insetLeft,
845
+ marginRight: layout.insetRight,
893
846
  },
894
847
  ]}
895
848
  >
896
- {/* The footer inset is a caller decision, not a platform one — a footer
897
- that carries its own horizontal padding passes `marginFooter={false}`. */}
898
- <Section useMargin={marginFooter}>{Footer}</Section>
849
+ <View
850
+ style={[
851
+ styles.shadow,
852
+ {
853
+ paddingBottom:
854
+ (isTab && duoTabInset ? duoTabInset : bottomInset) +
855
+ Spacing.S,
856
+ backgroundColor: theme.colors.background.surface,
857
+ },
858
+ ]}
859
+ >
860
+ {/* The footer inset is a caller decision, not a platform one — a footer
861
+ that carries its own horizontal padding passes `marginFooter={false}`. */}
862
+ <Section useMargin={marginFooter}>{Footer}</Section>
863
+ </View>
899
864
  </View>
900
865
  )}
901
866
  </KeyboardAvoidingView>
package/Layout/styles.ts CHANGED
@@ -91,5 +91,10 @@ export default StyleSheet.create({
91
91
  },
92
92
  }),
93
93
  },
94
+ footerClip: {
95
+ marginTop: -Spacing.XL,
96
+ paddingTop: Spacing.XL,
97
+ overflow: 'hidden',
98
+ },
94
99
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
95
100
  });
package/Layout/utils.ts CHANGED
@@ -77,6 +77,9 @@ const HeaderMetrics = {
77
77
  * - edgeInset: on the outer display in landscape the strip holds no status bar, so its
78
78
  * controls start this far from the top and stay this far above the bottom, unless a
79
79
  * reserved region (the camera at either end) asks for more
80
+ * - innerStatusBarCenterY / innerStatusBarWidth: on the inner display in portrait the
81
+ * clock and connectivity cluster sits at the top right corner (x 550–643 of 669, clock
82
+ * digits centered on y 47); its reserved region, when reported, is taller than the glyphs
80
83
  */
81
84
  const DuoMetrics = {
82
85
  maxAspectRatio: 1.6,
@@ -88,13 +91,16 @@ const DuoMetrics = {
88
91
  outerHeaderHeight: 76,
89
92
  outerCameraCenterY: 45,
90
93
  edgeInset: 24,
94
+ innerStatusBarCenterY: 47,
95
+ innerStatusBarWidth: 120,
91
96
  };
92
97
 
93
98
  const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
94
99
 
95
100
  /**
96
101
  * 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)
102
+ * (e.g. the camera, which sits at either end depending on the rotation), and the center
103
+ * y of the region reaching furthest from the top
98
104
  */
99
105
  const getStripOcclusion = (
100
106
  regions: ReservedRegion[],
@@ -109,16 +115,53 @@ const getStripOcclusion = (
109
115
  if (!overlapsStrip) {
110
116
  return occlusion;
111
117
  }
112
- if (region.y + region.height / 2 < windowHeight / 2) {
113
- occlusion.top = Math.max(occlusion.top, region.y + region.height);
118
+ const centerY = region.y + region.height / 2;
119
+ if (centerY < windowHeight / 2) {
120
+ if (region.y + region.height > occlusion.top) {
121
+ occlusion.top = region.y + region.height;
122
+ occlusion.topCenter = centerY;
123
+ }
114
124
  } else {
115
125
  occlusion.bottom = Math.max(occlusion.bottom, windowHeight - region.y);
116
126
  }
117
127
  return occlusion;
118
128
  },
119
- { top: 0, bottom: 0 },
129
+ { top: 0, topCenter: 0, bottom: 0 },
120
130
  );
121
131
 
132
+ /**
133
+ * how far the clock and battery clusters of a status bar on the top edge reach in from
134
+ * each side. `undefined` without reserved regions there
135
+ */
136
+ const getStatusBarClusters = (
137
+ regions: ReservedRegion[],
138
+ windowWidth: number,
139
+ topInset: number,
140
+ ) => {
141
+ const midX = windowWidth / 2;
142
+ const clusters = regions.filter(
143
+ region =>
144
+ region.y < topInset &&
145
+ region.y + region.height <= topInset &&
146
+ (region.x + region.width <= midX || region.x >= midX),
147
+ );
148
+ if (!clusters.length) {
149
+ return undefined;
150
+ }
151
+
152
+ return clusters.reduce(
153
+ (result, region) => {
154
+ if (region.x < midX) {
155
+ result.left = Math.max(result.left, region.x + region.width);
156
+ } else {
157
+ result.right = Math.max(result.right, windowWidth - region.x);
158
+ }
159
+ return result;
160
+ },
161
+ { left: 0, right: 0 },
162
+ );
163
+ };
164
+
122
165
  const isIPhone = () =>
123
166
  Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
124
167
 
@@ -140,6 +183,21 @@ const getHeaderLayout = (topInset: number) => {
140
183
  };
141
184
  };
142
185
 
186
+ /**
187
+ * Header whose bar row is centered on a window y, at least iPhoneMinHeight tall and
188
+ * reaching 8pt below a side bar control centered there
189
+ */
190
+ const getCenteredHeaderLayout = (centerY: number) => {
191
+ const headerHeight = Math.max(
192
+ HeaderMetrics.iPhoneMinHeight,
193
+ centerY + DuoMetrics.controlSize / 2 + Spacing.S,
194
+ );
195
+ return {
196
+ headerHeight,
197
+ headerStatusBarHeight: Math.max(0, centerY * 2 - headerHeight),
198
+ };
199
+ };
200
+
143
201
  export type SideBarEdge = 'left' | 'right';
144
202
 
145
203
  export type SideBarLayout = {
@@ -212,6 +270,17 @@ export type ScreenLayout = {
212
270
  * bars to the side. `undefined` while the bars stay horizontal.
213
271
  */
214
272
  sideBar?: SideBarLayout;
273
+
274
+ /**
275
+ * Room the header row keeps from each side for the clock and battery clusters, when
276
+ * iPhone Duo puts the row on the status bar (inner display in portrait).
277
+ */
278
+ statusBarClearance?: StatusBarClearance;
279
+ };
280
+
281
+ export type StatusBarClearance = {
282
+ left: number;
283
+ right: number;
215
284
  };
216
285
 
217
286
  /**
@@ -260,11 +329,41 @@ const useScreenLayout = (): ScreenLayout => {
260
329
  };
261
330
  }
262
331
 
332
+ let statusBarClearance: StatusBarClearance | undefined;
333
+ const isInnerPortrait = height > width && width >= DuoMetrics.innerMinHeight;
334
+ if (isInnerPortrait && insets.top >= HeaderMetrics.statusBarMinInset) {
335
+ // the row shares the status bar band, between its clusters
336
+ const clusters = getStatusBarClusters(
337
+ reservedRegions,
338
+ width,
339
+ insets.top,
340
+ ) ?? { left: 0, right: DuoMetrics.innerStatusBarWidth + Spacing.S };
341
+ // center on the clock digits, the region center sits above them
342
+ headerLayout = {
343
+ headerHeight: DuoMetrics.innerStatusBarCenterY * 2,
344
+ headerStatusBarHeight: 0,
345
+ };
346
+ statusBarClearance = clusters;
347
+ }
348
+
263
349
  const sideInset = Math.max(insets.left, insets.right);
264
350
  let sideBar: SideBarLayout | undefined;
265
351
  // narrower strips cannot host a control 12pt from the inner edge
266
352
  if (sideInset >= DuoMetrics.controlSize + Spacing.M) {
267
- let top = insets.top + HeaderMetrics.barHeight + Spacing.M;
353
+ const edge = insets.right >= insets.left ? 'right' : 'left';
354
+ const stripLeft = edge === 'right' ? width - sideInset : 0;
355
+ // keep controls clear of the camera and of the status bar cluster, which grows for
356
+ // calls and Live Activities. Region frames already include the system's margin
357
+ const occlusion = getStripOcclusion(
358
+ reservedRegions,
359
+ stripLeft,
360
+ stripLeft + sideInset,
361
+ height,
362
+ );
363
+
364
+ let top = statusBarClearance
365
+ ? headerLayout.headerHeight + Spacing.M
366
+ : insets.top + HeaderMetrics.barHeight + Spacing.M;
268
367
  if (insets.top < HeaderMetrics.statusBarMinInset) {
269
368
  // status bar lives in the strip: start under its cluster, as the native bar does
270
369
  const isLandscape = width > height;
@@ -273,8 +372,14 @@ const useScreenLayout = (): ScreenLayout => {
273
372
  } else if (height >= DuoMetrics.innerMinHeight) {
274
373
  top = DuoMetrics.firstItemTopLandscape;
275
374
  } else {
276
- // outer display in landscape: no status bar in the strip
375
+ // outer display in landscape: no status bar in the strip, the title row centers
376
+ // on the camera when it sits at the top end, otherwise on the back button
277
377
  top = DuoMetrics.edgeInset;
378
+ const titleCenterY =
379
+ occlusion.top > top
380
+ ? occlusion.topCenter
381
+ : top + DuoMetrics.controlSize / 2;
382
+ headerLayout = getCenteredHeaderLayout(titleCenterY);
278
383
  }
279
384
 
280
385
  if (height > width && width < DuoMetrics.innerMinHeight) {
@@ -287,17 +392,6 @@ const useScreenLayout = (): ScreenLayout => {
287
392
  }
288
393
  }
289
394
 
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
-
301
395
  sideBar = {
302
396
  edge,
303
397
  width: sideInset,
@@ -316,9 +410,30 @@ const useScreenLayout = (): ScreenLayout => {
316
410
  contentWidth: width - insets.left - insets.right,
317
411
  ...headerLayout,
318
412
  sideBar,
413
+ statusBarClearance,
319
414
  };
320
415
  };
321
416
 
417
+ /**
418
+ * Header row options for react-navigation: its height, the room for the status bar, and
419
+ * on iPhone Duo inner portrait the buttons kept clear of the clock and battery clusters
420
+ */
421
+ const getHeaderRowOptions = ({
422
+ headerHeight,
423
+ headerStatusBarHeight,
424
+ statusBarClearance,
425
+ sideBar,
426
+ }: ScreenLayout) => ({
427
+ headerStyle: { height: headerHeight },
428
+ headerStatusBarHeight,
429
+ // side bar controls are absolute within the containers, padding would not move them
430
+ ...(statusBarClearance &&
431
+ !sideBar && {
432
+ headerLeftContainerStyle: { paddingLeft: statusBarClearance.left },
433
+ headerRightContainerStyle: { paddingRight: statusBarClearance.right },
434
+ }),
435
+ });
436
+
322
437
  /**
323
438
  * Side margin @react-navigation/elements Header adds to its content row
324
439
  */
@@ -382,7 +497,9 @@ const useGridSystem = () => {
382
497
 
383
498
  export {
384
499
  DuoMetrics,
500
+ HeaderMetrics,
385
501
  getHeaderMargin,
502
+ getHeaderRowOptions,
386
503
  useGridSystem,
387
504
  useScreenLayout,
388
505
  useSideBarItemStyle,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duo.8",
3
+ "version": "0.167.1-duotest.2",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},