@momo-kits/foundation 0.169.1 → 0.169.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.
Files changed (38) hide show
  1. package/Application/BottomSheet.hooks.ts +17 -37
  2. package/Application/BottomSheet.parts.tsx +81 -165
  3. package/Application/BottomSheet.styles.ts +7 -23
  4. package/Application/BottomSheet.tsx +15 -67
  5. package/Application/BottomTab/ActiveIndicator.tsx +90 -0
  6. package/Application/BottomTab/BottomTabBar.styles.ts +48 -43
  7. package/Application/BottomTab/BottomTabBar.tsx +229 -223
  8. package/Application/BottomTab/BottomTabInset.tsx +14 -0
  9. package/Application/BottomTab/CurvedContainer.tsx +153 -0
  10. package/Application/BottomTab/CustomBottomTabItem.tsx +132 -108
  11. package/Application/BottomTab/DuoBarCompression.tsx +4 -12
  12. package/Application/BottomTab/DuoRailTabBar.tsx +174 -96
  13. package/Application/BottomTab/DuoTabBar.styles.ts +42 -84
  14. package/Application/BottomTab/DuoTabBar.tsx +35 -115
  15. package/Application/BottomTab/TabBarIcon.tsx +160 -72
  16. package/Application/BottomTab/TabScreen.tsx +48 -61
  17. package/Application/BottomTab/index.tsx +85 -125
  18. package/Application/BottomTab/metrics.ts +164 -0
  19. package/Application/BottomTab/tabItems.ts +46 -0
  20. package/Application/BottomTab/usePressLift.ts +32 -0
  21. package/Application/ModalIconButton.tsx +66 -0
  22. package/Application/ModalScreen.tsx +32 -15
  23. package/Application/__tests__/modalLayout.test.ts +128 -0
  24. package/Application/index.ts +10 -0
  25. package/Application/modalLayout.ts +177 -0
  26. package/Application/types.tabs.ts +5 -0
  27. package/Application/utils.ts +0 -36
  28. package/Layout/ReservedRegions.tsx +21 -4
  29. package/Layout/Screen.tsx +11 -2
  30. package/Layout/index.ts +2 -0
  31. package/Popup/PopupNotify.tsx +38 -68
  32. package/Popup/PopupPromotion.tsx +5 -4
  33. package/Popup/utils.ts +1 -26
  34. package/package.json +2 -1
  35. package/Application/BottomTab/DuoTabBar.parts.tsx +0 -202
  36. package/Application/BottomTab/badge.ts +0 -41
  37. package/Application/BottomTab/hooks.ts +0 -358
  38. package/Application/BottomTab/index.styles.ts +0 -30
@@ -0,0 +1,66 @@
1
+ import React, { useContext } from 'react';
2
+ import {
3
+ StyleProp,
4
+ StyleSheet,
5
+ TouchableOpacity,
6
+ ViewStyle,
7
+ } from 'react-native';
8
+ import { ApplicationContext } from '../Context';
9
+ import { Radius, Spacing } from '../Consts';
10
+ import { Icon } from '../Icon';
11
+
12
+ // modal spec: icon button 32, icon 20, shared with the Compose / SwiftUI kits
13
+ const MODAL_ICON_BUTTON_SIZE = Spacing.XXL;
14
+ const MODAL_ICON_SIZE = 20;
15
+
16
+ type ModalIconButtonProps = {
17
+ icon: string;
18
+ onPress: () => void;
19
+ accessibilityLabel?: string;
20
+ style?: StyleProp<ViewStyle>;
21
+ };
22
+
23
+ /**
24
+ * Close / back button of popups and bottom sheet headers
25
+ */
26
+ const ModalIconButton: React.FC<ModalIconButtonProps> = ({
27
+ icon,
28
+ onPress,
29
+ accessibilityLabel,
30
+ style,
31
+ }) => {
32
+ const { theme } = useContext(ApplicationContext);
33
+ return (
34
+ <TouchableOpacity
35
+ accessibilityLabel={accessibilityLabel}
36
+ onPress={onPress}
37
+ style={[
38
+ styles.button,
39
+ {
40
+ backgroundColor: theme.colors.background.surface,
41
+ borderColor: theme.colors.border.default,
42
+ },
43
+ style,
44
+ ]}
45
+ >
46
+ <Icon
47
+ source={icon}
48
+ color={theme.colors.text.default}
49
+ size={MODAL_ICON_SIZE}
50
+ />
51
+ </TouchableOpacity>
52
+ );
53
+ };
54
+
55
+ const styles = StyleSheet.create({
56
+ button: {
57
+ width: MODAL_ICON_BUTTON_SIZE,
58
+ height: MODAL_ICON_BUTTON_SIZE,
59
+ borderRadius: Radius.L,
60
+ borderWidth: 1,
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ },
64
+ });
65
+
66
+ export { ModalIconButton, MODAL_ICON_BUTTON_SIZE };
@@ -1,4 +1,10 @@
1
- import React, { useCallback, useContext, useEffect, useRef } from 'react';
1
+ import React, {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ } from 'react';
2
8
  import {
3
9
  Animated,
4
10
  BackHandler,
@@ -11,14 +17,14 @@ import {
11
17
  useWindowDimensions,
12
18
  View,
13
19
  } from 'react-native';
14
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
15
20
 
16
21
  import { ApplicationContext, MiniAppContext } from '../Context';
17
- import { Styles } from '../Consts';
22
+ import { Colors, Styles } from '../Consts';
18
23
  import Navigation from './Navigation';
19
24
  import { ModalParams } from './types';
20
25
  import BottomSheet from './BottomSheet';
21
- import { getDuoModalBounds, isIPhoneDuo } from './utils';
26
+ import { isIPhoneDuo } from './utils';
27
+ import { useModalLayout } from './modalLayout';
22
28
  import { scheduleOnRN } from 'react-native-worklets';
23
29
 
24
30
  const ModalScreen: React.FC<any> = props => {
@@ -40,9 +46,9 @@ const Modal: React.FC<ModalParams> = props => {
40
46
  title,
41
47
  } = props.route.params;
42
48
  const Component = useRef(screen).current;
43
- const insets = useSafeAreaInsets();
44
- const windowSize = useWindowDimensions();
49
+ const { width, height } = useWindowDimensions();
45
50
  const isDuo = isIPhoneDuo();
51
+ const { isFoldable, placement, popup, scrimOpacity } = useModalLayout();
46
52
 
47
53
  /**
48
54
  * Fire auto_popup_displayed tracking event on mount
@@ -61,6 +67,15 @@ const Modal: React.FC<ModalParams> = props => {
61
67
 
62
68
  const opacity = useRef(new Animated.Value(0)).current;
63
69
  const scale = useRef(new Animated.Value(0.8)).current;
70
+ const scrimStyle = useMemo(
71
+ () => ({
72
+ opacity: opacity.interpolate({
73
+ inputRange: [0, 1],
74
+ outputRange: [0, scrimOpacity],
75
+ }),
76
+ }),
77
+ [opacity, scrimOpacity],
78
+ );
64
79
 
65
80
  const navigation = new Navigation(props.navigation);
66
81
  const params = {
@@ -146,7 +161,7 @@ const Modal: React.FC<ModalParams> = props => {
146
161
  style={StyleSheet.absoluteFillObject}
147
162
  onPress={() => onDismiss(undefined, barrierDismissible)}
148
163
  >
149
- <Animated.View style={[styles.overlayContent, { opacity }]} />
164
+ <Animated.View style={[styles.overlayContent, scrimStyle]} />
150
165
  </Pressable>
151
166
  <Animated.View
152
167
  style={[
@@ -154,13 +169,15 @@ const Modal: React.FC<ModalParams> = props => {
154
169
  opacity,
155
170
  transform: [{ scale }],
156
171
  },
157
- isDuo && styles.shrink,
158
- isDuo && {
159
- marginTop: insets.top,
160
- marginBottom: insets.bottom,
161
- marginLeft: insets.left,
162
- marginRight: insets.right,
163
- ...getDuoModalBounds(insets, windowSize.width, windowSize.height),
172
+ // centered in the placement area, at most a popup wide
173
+ isFoldable && styles.shrink,
174
+ isFoldable && {
175
+ marginTop: placement.y,
176
+ marginBottom: height - placement.y - placement.height,
177
+ marginLeft: placement.x,
178
+ marginRight: width - placement.x - placement.width,
179
+ maxWidth: popup.width,
180
+ maxHeight: popup.maxHeight,
164
181
  },
165
182
  modalStyle,
166
183
  ]}
@@ -177,7 +194,7 @@ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
177
194
  const styles = StyleSheet.create({
178
195
  overlayContent: {
179
196
  flex: 1,
180
- backgroundColor: 'rgba(0, 0, 0, 0.6)',
197
+ backgroundColor: Colors.black_20,
181
198
  },
182
199
  shrink: { flexShrink: 1 },
183
200
  });
@@ -0,0 +1,128 @@
1
+ // document/__mocks__ shadows react-native with react-native-web for the doc site
2
+ jest.mock('react-native', () => jest.requireActual('react-native'));
3
+
4
+ import { getModalLayout } from '../modalLayout';
5
+
6
+ const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
7
+
8
+ describe('getModalLayout', () => {
9
+ it('keeps the phone look', () => {
10
+ const layout = getModalLayout({
11
+ width: 390,
12
+ height: 844,
13
+ insets: { top: 47, right: 0, bottom: 34, left: 0 },
14
+ isDuo: false,
15
+ });
16
+ expect(layout.isFoldable).toBe(false);
17
+ expect(layout.popup.width).toBe(342);
18
+ expect(layout.popup.maxHeight).toBeUndefined();
19
+ expect(layout.popup.radius).toBe(16);
20
+ expect(layout.sheet.width).toBeUndefined();
21
+ expect(layout.sheet.maxHeight).toBeUndefined();
22
+ expect(layout.sheet.radius).toBe(12);
23
+ expect(layout.scrimOpacity).toBe(0.6);
24
+ });
25
+
26
+ it('keeps the phone look in landscape', () => {
27
+ const layout = getModalLayout({
28
+ width: 844,
29
+ height: 390,
30
+ insets: { top: 0, right: 47, bottom: 21, left: 47 },
31
+ isDuo: false,
32
+ });
33
+ expect(layout.isFoldable).toBe(false);
34
+ expect(layout.popup.width).toBe(844 - 48);
35
+ expect(layout.popup.maxHeight).toBeUndefined();
36
+ expect(layout.popup.radius).toBe(16);
37
+ expect(layout.sheet.width).toBeUndefined();
38
+ expect(layout.scrimOpacity).toBe(0.6);
39
+ });
40
+
41
+ it('caps popup and sheet on a wide foldable window', () => {
42
+ const layout = getModalLayout({
43
+ width: 884,
44
+ height: 1104,
45
+ insets: { top: 24, right: 0, bottom: 20, left: 0 },
46
+ isDuo: false,
47
+ });
48
+ expect(layout.isFoldable).toBe(true);
49
+ expect(layout.popup).toMatchObject({
50
+ width: 450,
51
+ minWidth: 280,
52
+ maxWidth: 450,
53
+ maxHeight: 600,
54
+ margin: 24,
55
+ radius: 24,
56
+ });
57
+ expect(layout.sheet).toMatchObject({
58
+ width: 653,
59
+ maxHeight: 1104 - 24 - 12,
60
+ minHeight: 230,
61
+ margin: 12,
62
+ radius: 24,
63
+ });
64
+ expect(layout.scrimOpacity).toBe(0.2);
65
+ });
66
+
67
+ it('limits popup height to the safe area minus 48', () => {
68
+ const layout = getModalLayout({
69
+ width: 900,
70
+ height: 600,
71
+ insets: { top: 24, right: 0, bottom: 20, left: 0 },
72
+ isDuo: false,
73
+ });
74
+ expect(layout.popup.maxHeight).toBe(600 - 24 - 20 - 48);
75
+ });
76
+
77
+ it('places modals left of the control column on the iPhone Duo outer display', () => {
78
+ const layout = getModalLayout({
79
+ width: 420,
80
+ height: 760,
81
+ insets: { top: 0, right: 64, bottom: 20, left: 0 },
82
+ isDuo: true,
83
+ });
84
+ expect(layout.isFoldable).toBe(true);
85
+ expect(layout.placement).toEqual({ x: 0, y: 0, width: 356, height: 740 });
86
+ expect(layout.popup.margin).toBe(12);
87
+ expect(layout.popup.width).toBe(356 - 24);
88
+ expect(layout.sheet.width).toBe(356 - 24);
89
+ });
90
+
91
+ it('never grows past the margins when the space is under the min width', () => {
92
+ const layout = getModalLayout({
93
+ width: 300,
94
+ height: 600,
95
+ insets: NO_INSETS,
96
+ isDuo: true,
97
+ });
98
+ expect(layout.popup.width).toBe(300 - 24);
99
+ expect(layout.popup.minWidth).toBe(300 - 24);
100
+ expect(layout.sheet.width).toBe(300 - 24);
101
+ });
102
+
103
+ it('moves to the right half when iPhone Duo is half folded', () => {
104
+ const layout = getModalLayout({
105
+ width: 1000,
106
+ height: 800,
107
+ insets: NO_INSETS,
108
+ isDuo: true,
109
+ hinge: { x: 495, y: 0, width: 10, height: 800 },
110
+ });
111
+ expect(layout.placement).toEqual({ x: 505, y: 0, width: 495, height: 800 });
112
+ expect(layout.popup.margin).toBe(12);
113
+ expect(layout.popup.width).toBe(450);
114
+ expect(layout.sheet.width).toBe(495 - 24);
115
+ });
116
+
117
+ it('ignores a division off the iPhone Duo', () => {
118
+ const layout = getModalLayout({
119
+ width: 1000,
120
+ height: 800,
121
+ insets: NO_INSETS,
122
+ isDuo: false,
123
+ hinge: { x: 495, y: 0, width: 10, height: 800 },
124
+ });
125
+ expect(layout.placement.x).toBe(0);
126
+ expect(layout.popup.margin).toBe(24);
127
+ });
128
+ });
@@ -20,6 +20,12 @@ import Navigation from './Navigation';
20
20
  import Navigator from './Navigator';
21
21
  import ScaleSizeProvider from './ScaleSizeProvider';
22
22
  import { useTooltipPortal } from './TooltipPortal';
23
+ import {
24
+ getModalLayout,
25
+ useModalLayout,
26
+ type ModalLayout,
27
+ type ModalRect,
28
+ } from './modalLayout';
23
29
 
24
30
  export {
25
31
  NavigationContainer,
@@ -33,4 +39,8 @@ export {
33
39
  exportHeaderTitle,
34
40
  ScaleSizeProvider,
35
41
  useTooltipPortal,
42
+ getModalLayout,
43
+ useModalLayout,
36
44
  };
45
+
46
+ export type { ModalLayout, ModalRect };
@@ -0,0 +1,177 @@
1
+ import { useWindowDimensions } from 'react-native';
2
+ import { EdgeInsets, useSafeAreaInsets } from 'react-native-safe-area-context';
3
+ import { Radius, Spacing } from '../Consts';
4
+ import { ReservedRegion, useHinge } from '../Layout/ReservedRegions';
5
+ import { isIPhoneDuo } from './utils';
6
+
7
+ // Foldable modal spec, shared with the Compose / SwiftUI kits
8
+ const POPUP_MAX_WIDTH = 450;
9
+ const POPUP_MIN_WIDTH = 280;
10
+ const POPUP_MAX_HEIGHT = 600;
11
+ const SHEET_MAX_WIDTH = 653;
12
+ const SHEET_MIN_WIDTH = 296;
13
+ const SHEET_MIN_HEIGHT = 230;
14
+ const FOLDABLE_MIN_WIDTH = 600;
15
+
16
+ const SCRIM_OPACITY = 0.6;
17
+ const FOLDABLE_SCRIM_OPACITY = 0.2;
18
+
19
+ export type ModalRect = {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height: number;
24
+ };
25
+
26
+ export type ModalLayout = {
27
+ /**
28
+ * iPhone Duo, or a window whose short side is at least 600 (Galaxy Z Fold / Pixel
29
+ * Fold inner display). Phones, landscape included, keep the regular modal look.
30
+ */
31
+ isFoldable: boolean;
32
+
33
+ /**
34
+ * Area popups and sheets are placed in: the safe area, left of the control column on
35
+ * the iPhone Duo outer display, the right half when iPhone Duo is half folded.
36
+ */
37
+ placement: ModalRect;
38
+
39
+ popup: {
40
+ width: number;
41
+ minWidth: number;
42
+ maxWidth: number;
43
+ /**
44
+ * `undefined` on phones, the popup grows with its content.
45
+ */
46
+ maxHeight?: number;
47
+ margin: number;
48
+ radius: number;
49
+ };
50
+
51
+ sheet: {
52
+ /**
53
+ * `undefined` on phones, the sheet spans the window.
54
+ */
55
+ width?: number;
56
+ maxHeight?: number;
57
+ minHeight?: number;
58
+ margin: number;
59
+ radius: number;
60
+ };
61
+
62
+ scrimOpacity: number;
63
+ };
64
+
65
+ type ModalLayoutInput = {
66
+ width: number;
67
+ height: number;
68
+ insets: EdgeInsets;
69
+ isDuo: boolean;
70
+ /**
71
+ * vertical division reported by the system, iPhone Duo half folded
72
+ */
73
+ hinge?: ReservedRegion;
74
+ };
75
+
76
+ const clamp = (value: number, min: number, max: number) =>
77
+ Math.min(Math.max(value, min), max);
78
+
79
+ /**
80
+ * Modal geometry for a window, pure so it can be tested without a device
81
+ */
82
+ const getModalLayout = ({
83
+ width,
84
+ height,
85
+ insets,
86
+ isDuo,
87
+ hinge,
88
+ }: ModalLayoutInput): ModalLayout => {
89
+ const shortSide = Math.min(width, height);
90
+ // short side, so a phone in landscape stays a phone
91
+ const isFoldable = isDuo || shortSide >= FOLDABLE_MIN_WIDTH;
92
+ const isDuoOuter = isDuo && shortSide < FOLDABLE_MIN_WIDTH;
93
+ const hingeRight = hinge ? hinge.x + hinge.width : 0;
94
+ const isHalfFolded =
95
+ isDuo && !!hinge && hinge.x > 0 && hingeRight < width - insets.right;
96
+
97
+ const safeLeft = isHalfFolded ? hingeRight : insets.left;
98
+ const placement = {
99
+ x: safeLeft,
100
+ y: insets.top,
101
+ width: width - insets.right - safeLeft,
102
+ height: height - insets.top - insets.bottom,
103
+ };
104
+
105
+ if (!isFoldable) {
106
+ const popupWidth = width - Spacing.XL * 2;
107
+ return {
108
+ isFoldable,
109
+ placement,
110
+ popup: {
111
+ width: popupWidth,
112
+ minWidth: popupWidth,
113
+ maxWidth: popupWidth,
114
+ margin: Spacing.XL,
115
+ radius: Radius.L,
116
+ },
117
+ sheet: { margin: 0, radius: Radius.M },
118
+ scrimOpacity: SCRIM_OPACITY,
119
+ };
120
+ }
121
+
122
+ const popupMargin = isDuoOuter || isHalfFolded ? Spacing.M : Spacing.XL;
123
+ const popupSpace = placement.width - popupMargin * 2;
124
+ const popupMinWidth = Math.min(POPUP_MIN_WIDTH, popupSpace);
125
+
126
+ const sheetMargin = Spacing.M;
127
+ const sheetSpace = placement.width - sheetMargin * 2;
128
+ const sheetMaxHeight = height - insets.top - Spacing.M;
129
+
130
+ return {
131
+ isFoldable,
132
+ placement,
133
+ popup: {
134
+ width: clamp(popupSpace, popupMinWidth, POPUP_MAX_WIDTH),
135
+ minWidth: popupMinWidth,
136
+ maxWidth: POPUP_MAX_WIDTH,
137
+ maxHeight: Math.min(POPUP_MAX_HEIGHT, placement.height - Spacing.XL * 2),
138
+ margin: popupMargin,
139
+ radius: Radius.XL,
140
+ },
141
+ sheet: {
142
+ width: clamp(
143
+ sheetSpace,
144
+ Math.min(SHEET_MIN_WIDTH, sheetSpace),
145
+ SHEET_MAX_WIDTH,
146
+ ),
147
+ maxHeight: sheetMaxHeight,
148
+ minHeight: Math.min(SHEET_MIN_HEIGHT, sheetMaxHeight),
149
+ margin: sheetMargin,
150
+ radius: Radius.XL,
151
+ },
152
+ scrimOpacity: FOLDABLE_SCRIM_OPACITY,
153
+ };
154
+ };
155
+
156
+ /**
157
+ * Modal geometry for the current window, follows fold, unfold, rotation and the
158
+ * iPhone Duo hinge
159
+ */
160
+ const useModalLayout = (): ModalLayout => {
161
+ const { width, height } = useWindowDimensions();
162
+ const insets = useSafeAreaInsets();
163
+ const hinge = useHinge();
164
+ return getModalLayout({ width, height, insets, isDuo: isIPhoneDuo(), hinge });
165
+ };
166
+
167
+ export {
168
+ getModalLayout,
169
+ useModalLayout,
170
+ POPUP_MAX_WIDTH,
171
+ POPUP_MIN_WIDTH,
172
+ POPUP_MAX_HEIGHT,
173
+ SHEET_MAX_WIDTH,
174
+ SHEET_MIN_WIDTH,
175
+ SHEET_MIN_HEIGHT,
176
+ FOLDABLE_MIN_WIDTH,
177
+ };
@@ -40,4 +40,9 @@ export type FloatingButtonProps = {
40
40
  icon: string | React.ReactNode;
41
41
  label: string;
42
42
  onPress: () => void;
43
+ /**
44
+ * Drawn over the label text, which stays, e.g. a Lottie of the label; centered on the
45
+ * label and unclipped. `icon` takes the button part (inside the 48 button)
46
+ */
47
+ labelContent?: React.ReactNode;
43
48
  };
@@ -1,13 +1,8 @@
1
1
  import { Animated, Dimensions, Platform, ScaledSize } from 'react-native';
2
2
  import type { NavigationState, PartialState } from '@react-navigation/native';
3
3
  import type { SharedValue } from 'react-native-reanimated';
4
- import type { EdgeInsets } from 'react-native-safe-area-context';
5
- import { Spacing } from '../Consts';
6
4
 
7
5
  const IPHONE_MAX_SHORT_SIDE = 440;
8
- const DUO_MODAL_MAX_WIDTH = 440;
9
- const DUO_MODAL_MAX_HEIGHT = 640;
10
- const DUO_SIDE_STATUS_BAR_SCALE = 2;
11
6
 
12
7
  /**
13
8
  * Animation runtimes an internal header component may receive. Public Screen
@@ -100,35 +95,6 @@ const isIPhoneDuo = () => {
100
95
  return Math.max(screenSide, windowSide) > IPHONE_MAX_SHORT_SIDE;
101
96
  };
102
97
 
103
- /**
104
- * Top inset on iPhone Duo. In portrait the status bar sits on the side, about
105
- * twice as tall as its inset is wide, so the larger of the two wins.
106
- */
107
- const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) => {
108
- if (width >= height) {
109
- return insets.top;
110
- }
111
- const sideInset = Math.max(insets.left, insets.right);
112
- return Math.max(insets.top, sideInset * DUO_SIDE_STATUS_BAR_SCALE);
113
- };
114
-
115
- /**
116
- * Max modal size on iPhone Duo: inside the safe area, clear of the side
117
- * toolbar, and capped so it stays a dialog on the inner display.
118
- */
119
- const getDuoModalBounds = (
120
- insets: EdgeInsets,
121
- width: number,
122
- height: number,
123
- ) => {
124
- const safeWidth = width - insets.left - insets.right - Spacing.XL * 2;
125
- const safeHeight = height - insets.top - insets.bottom - Spacing.XL * 2;
126
- return {
127
- maxWidth: Math.min(safeWidth, DUO_MODAL_MAX_WIDTH),
128
- maxHeight: Math.min(safeHeight, DUO_MODAL_MAX_HEIGHT),
129
- };
130
- };
131
-
132
98
  export type { AnimatedCompatValue, NavigationTreeState };
133
99
  export {
134
100
  hasMethod,
@@ -138,6 +104,4 @@ export {
138
104
  getStackDepth,
139
105
  isWhiteListDesignSystem,
140
106
  isIPhoneDuo,
141
- getDuoTopInset,
142
- getDuoModalBounds,
143
107
  };
@@ -1,4 +1,4 @@
1
- import React, { createContext, ReactNode, useMemo } from 'react';
1
+ import React, { createContext, ReactNode, useContext, useMemo } from 'react';
2
2
  import { Platform, StyleSheet } from 'react-native';
3
3
 
4
4
  export type ReservedRegion = {
@@ -25,7 +25,15 @@ try {
25
25
  const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
26
26
 
27
27
  /**
28
- * keeps the occlusion regions reported by the native provider, the only kind layout reads
28
+ * vertical seam splitting the display into left and right halves, e.g. iPhone Duo
29
+ * half folded. `undefined` while the display is flat or the system reports none
30
+ */
31
+ const HingeContext = createContext<ReservedRegion | undefined>(undefined);
32
+
33
+ const useHinge = () => useContext(HingeContext);
34
+
35
+ /**
36
+ * keeps the occlusion regions and the vertical division reported by the native provider
29
37
  */
30
38
  const OcclusionRegions: React.FC<{
31
39
  useRegions: NativeReservedRegions['useReservedRegions'];
@@ -39,10 +47,19 @@ const OcclusionRegions: React.FC<{
39
47
  .map(region => region.frame),
40
48
  [regions],
41
49
  );
50
+ const hinge = useMemo(
51
+ () =>
52
+ regions.find(
53
+ region =>
54
+ region.kind === 'division' &&
55
+ region.frame.height > region.frame.width,
56
+ )?.frame,
57
+ [regions],
58
+ );
42
59
 
43
60
  return (
44
61
  <ReservedRegionsContext.Provider value={occlusion}>
45
- {children}
62
+ <HingeContext.Provider value={hinge}>{children}</HingeContext.Provider>
46
63
  </ReservedRegionsContext.Provider>
47
64
  );
48
65
  };
@@ -75,4 +92,4 @@ const styles = StyleSheet.create({
75
92
  },
76
93
  });
77
94
 
78
- export { ReservedRegionsContext, ReservedRegionsProvider };
95
+ export { ReservedRegionsContext, ReservedRegionsProvider, useHinge };
package/Layout/Screen.tsx CHANGED
@@ -4,6 +4,7 @@ import React, {
4
4
  useContext,
5
5
  useEffect,
6
6
  useImperativeHandle,
7
+ useLayoutEffect,
7
8
  useRef,
8
9
  } from 'react';
9
10
  import {
@@ -26,7 +27,7 @@ import { getOptions } from '../Application/navigationOptions';
26
27
  import { useScreenLayout } from './utils';
27
28
  import { HeaderExtendHeader } from '../Application';
28
29
  import { DuoScrollGradient } from '../Application/Components/DuoGlassBackground';
29
- import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
30
+ import { BottomTabInsetContext } from '../Application/BottomTab/BottomTabInset';
30
31
  import { ScreenProps } from './screenTypes';
31
32
  import { renderAnimatedHeader, renderContent } from './screenContent';
32
33
  import { createScrollHandlers } from './screenScroll';
@@ -94,7 +95,15 @@ const Screen = forwardRef(
94
95
  }, [customAnimatedValue, internalShared]);
95
96
 
96
97
  const isTab = !!screen?.bottomTab;
97
- const duoTabInset = useDuoTabBarInset();
98
+ const tabBarInset = useContext(BottomTabInsetContext);
99
+ const duoTabInset = isTab ? tabBarInset.inset : 0;
100
+ const claimTabBarInset = tabBarInset.claim;
101
+ // this screen keeps its content clear of the floating tab bar, so the tab stops padding it
102
+ useLayoutEffect(() => {
103
+ if (duoTabInset) {
104
+ claimTabBarInset?.();
105
+ }
106
+ }, [duoTabInset, claimTabBarInset]);
98
107
 
99
108
  const isIOS = Platform.OS === 'ios';
100
109
  const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
package/Layout/index.ts CHANGED
@@ -14,6 +14,7 @@ import ItemList from './ItemList';
14
14
  import ItemSectionList from './ItemSectionList';
15
15
  import Screen, { type ScreenProps } from './Screen';
16
16
  import { TrackingScope } from './TrackingScope';
17
+ import { useHinge } from './ReservedRegions';
17
18
 
18
19
  const GridContext = createContext<GridContextProps>({
19
20
  numberOfColumns: 12,
@@ -35,4 +36,5 @@ export {
35
36
  ItemSectionList,
36
37
  Screen,
37
38
  TrackingScope,
39
+ useHinge,
38
40
  };