@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.
- package/Application/BottomSheet.hooks.ts +17 -37
- package/Application/BottomSheet.parts.tsx +81 -165
- package/Application/BottomSheet.styles.ts +7 -23
- package/Application/BottomSheet.tsx +15 -67
- package/Application/BottomTab/ActiveIndicator.tsx +90 -0
- package/Application/BottomTab/BottomTabBar.styles.ts +48 -43
- package/Application/BottomTab/BottomTabBar.tsx +229 -223
- package/Application/BottomTab/BottomTabInset.tsx +14 -0
- package/Application/BottomTab/CurvedContainer.tsx +153 -0
- package/Application/BottomTab/CustomBottomTabItem.tsx +132 -108
- package/Application/BottomTab/DuoBarCompression.tsx +4 -12
- package/Application/BottomTab/DuoRailTabBar.tsx +174 -96
- package/Application/BottomTab/DuoTabBar.styles.ts +42 -84
- package/Application/BottomTab/DuoTabBar.tsx +35 -115
- package/Application/BottomTab/TabBarIcon.tsx +160 -72
- package/Application/BottomTab/TabScreen.tsx +48 -61
- package/Application/BottomTab/index.tsx +85 -125
- package/Application/BottomTab/metrics.ts +164 -0
- package/Application/BottomTab/tabItems.ts +46 -0
- package/Application/BottomTab/usePressLift.ts +32 -0
- package/Application/ModalIconButton.tsx +66 -0
- package/Application/ModalScreen.tsx +32 -15
- package/Application/__tests__/modalLayout.test.ts +128 -0
- package/Application/index.ts +10 -0
- package/Application/modalLayout.ts +177 -0
- package/Application/types.tabs.ts +5 -0
- package/Application/utils.ts +0 -36
- package/Layout/ReservedRegions.tsx +21 -4
- package/Layout/Screen.tsx +11 -2
- package/Layout/index.ts +2 -0
- package/Popup/PopupNotify.tsx +38 -68
- package/Popup/PopupPromotion.tsx +5 -4
- package/Popup/utils.ts +1 -26
- package/package.json +2 -1
- package/Application/BottomTab/DuoTabBar.parts.tsx +0 -202
- package/Application/BottomTab/badge.ts +0 -41
- package/Application/BottomTab/hooks.ts +0 -358
- 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, {
|
|
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
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
|
+
});
|
package/Application/index.ts
CHANGED
|
@@ -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
|
};
|
package/Application/utils.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
|
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
|
};
|