@momo-kits/foundation 0.168.1-beta.30 → 0.168.1-beta.31
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.tsx +45 -161
- package/Application/ModalScreen.tsx +33 -16
- package/Application/__tests__/modalLayout.test.ts +128 -0
- package/Application/device.ts +2 -47
- package/Application/index.ts +6 -0
- package/Application/modalLayout.ts +180 -0
- package/Layout/ReservedRegions.tsx +21 -4
- package/Layout/index.ts +2 -0
- package/Popup/PopupNotify.tsx +68 -38
- package/Popup/PopupPromotion.tsx +5 -4
- package/package.json +1 -1
- package/Popup/utils.ts +0 -26
|
@@ -16,7 +16,6 @@ import {
|
|
|
16
16
|
PanResponder,
|
|
17
17
|
Platform,
|
|
18
18
|
Pressable,
|
|
19
|
-
StatusBar,
|
|
20
19
|
StyleSheet,
|
|
21
20
|
TouchableOpacity,
|
|
22
21
|
useWindowDimensions,
|
|
@@ -40,60 +39,31 @@ import Animated, {
|
|
|
40
39
|
} from 'react-native-reanimated';
|
|
41
40
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
42
41
|
import layoutStyles from '../Layout/styles';
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import { SideBarButton } from './Components/SideBarButton';
|
|
46
|
-
|
|
47
|
-
// foldable inner display: center the sheet instead of spanning the width
|
|
48
|
-
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
42
|
+
import { isIPhoneDuo } from './device';
|
|
43
|
+
import { useModalLayout } from './modalLayout';
|
|
49
44
|
|
|
50
45
|
const INDICATOR_HEIGHT = 16;
|
|
51
46
|
const HEADER_HEIGHT = 56;
|
|
52
47
|
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const getSideInset = (inset: number) => Math.max(inset - Spacing.M, 0);
|
|
48
|
+
// foldable spec: icon button 32, icon 20
|
|
49
|
+
const FOLDABLE_ICON_BUTTON_SIZE = 32;
|
|
50
|
+
const FOLDABLE_ICON_SIZE = 20;
|
|
57
51
|
|
|
58
52
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
59
53
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
60
54
|
const context = useContext<any>(MiniAppContext);
|
|
61
55
|
const windowSize = useWindowDimensions();
|
|
62
|
-
const isDuo =
|
|
63
|
-
const
|
|
64
|
-
isDuo &&
|
|
65
|
-
Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
|
|
66
|
-
const isDuoFloating = isDuo && !isDuoCentered;
|
|
67
|
-
const { sideBar } = useScreenLayout();
|
|
68
|
-
const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
|
|
69
|
-
const sideColumn =
|
|
70
|
-
isDuoFloating &&
|
|
71
|
-
sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
|
|
72
|
-
sideBar
|
|
73
|
-
? sideBar
|
|
74
|
-
: undefined;
|
|
75
|
-
const hasSideColumn = !!sideColumn;
|
|
56
|
+
const isDuo = isIPhoneDuo();
|
|
57
|
+
const { isFoldable, placement, sheet, scrimOpacity } = useModalLayout();
|
|
76
58
|
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
77
59
|
const screenHeight = Dimensions.get('screen').height;
|
|
78
60
|
const heightDevice = isDuo ? windowSize.height : screenHeight;
|
|
79
61
|
const action = useRef<undefined | string>(undefined);
|
|
80
62
|
const insets = useSafeAreaInsets();
|
|
81
63
|
const heightHeader = useHeaderHeight();
|
|
82
|
-
const topGap = Math.max(
|
|
83
|
-
sideColumn
|
|
84
|
-
? insets.top
|
|
85
|
-
: getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
86
|
-
Spacing.XL,
|
|
87
|
-
);
|
|
88
|
-
const sideInsetLeft = isDuoFloating ? getSideInset(insets.left) : 0;
|
|
89
|
-
const sideInsetRight = isDuoFloating ? getSideInset(insets.right) : 0;
|
|
90
|
-
const indicatorShift = (sideInsetRight - sideInsetLeft) / 2;
|
|
91
64
|
const isIOS = Platform.OS === 'ios';
|
|
92
65
|
const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
93
66
|
const keyboardOffset = heightHeader - bottomInset;
|
|
94
|
-
// Android shrinks the root for the keyboard but keeps the window height and
|
|
95
|
-
// fires no keyboardWill* events, so only the root can bound the sheet
|
|
96
|
-
const fitsResizedRoot = isDuo && !isIOS;
|
|
97
67
|
|
|
98
68
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
99
69
|
|
|
@@ -116,7 +86,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
116
86
|
const onDismissRef = useRef(props.route.params?.onDismiss);
|
|
117
87
|
onDismissRef.current = props.route.params?.onDismiss;
|
|
118
88
|
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
119
|
-
const [sheetTop, setSheetTop] = useState(windowSize.height);
|
|
120
89
|
|
|
121
90
|
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
122
91
|
// so lift it here and shrink maxHeight by the same amount instead
|
|
@@ -212,8 +181,8 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
212
181
|
}
|
|
213
182
|
|
|
214
183
|
/**
|
|
215
|
-
* emit dismiss event, once: folding or rotating
|
|
216
|
-
*
|
|
184
|
+
* emit dismiss event, once: folding or rotating iPhone Duo changes the
|
|
185
|
+
* window height while the sheet stays open
|
|
217
186
|
*/
|
|
218
187
|
useEffect(() => {
|
|
219
188
|
openAnimation();
|
|
@@ -272,39 +241,20 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
272
241
|
|
|
273
242
|
const showBack = !!leftOptions?.onPressIconLeft;
|
|
274
243
|
const iconBack = leftOptions?.iconLeft ?? 'ic_back';
|
|
275
|
-
const sideColumnCount = showBack ? 2 : 1;
|
|
276
|
-
const sideColumnHeight =
|
|
277
|
-
sideColumnCount * DuoMetrics.controlSize +
|
|
278
|
-
(sideColumnCount - 1) * Spacing.M;
|
|
279
|
-
const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
|
|
280
|
-
|
|
281
|
-
useEffect(() => {
|
|
282
|
-
if (!coversStatusBar) {
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
const entry = StatusBar.pushStackEntry({
|
|
286
|
-
barStyle: theme.dark ? 'light-content' : 'dark-content',
|
|
287
|
-
animated: true,
|
|
288
|
-
});
|
|
289
|
-
return () => StatusBar.popStackEntry(entry);
|
|
290
|
-
}, [coversStatusBar, theme.dark]);
|
|
291
244
|
|
|
292
245
|
/**
|
|
293
246
|
* render header
|
|
294
247
|
*/
|
|
295
248
|
const renderHeader = useCallback(() => {
|
|
296
|
-
const
|
|
249
|
+
const iconButtonStyle = [
|
|
250
|
+
styles.iconButton,
|
|
251
|
+
isFoldable && styles.foldableIconButton,
|
|
252
|
+
];
|
|
253
|
+
const iconSize = isFoldable ? FOLDABLE_ICON_SIZE : undefined;
|
|
297
254
|
return (
|
|
298
255
|
<View {...panResponder.panHandlers}>
|
|
299
256
|
<View style={{ height: INDICATOR_HEIGHT }}>
|
|
300
|
-
<View
|
|
301
|
-
style={[
|
|
302
|
-
styles.indicator,
|
|
303
|
-
indicatorShift !== 0 && {
|
|
304
|
-
transform: [{ translateX: indicatorShift }],
|
|
305
|
-
},
|
|
306
|
-
]}
|
|
307
|
-
/>
|
|
257
|
+
<View style={styles.indicator} />
|
|
308
258
|
</View>
|
|
309
259
|
<View
|
|
310
260
|
style={[
|
|
@@ -315,9 +265,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
315
265
|
},
|
|
316
266
|
]}
|
|
317
267
|
>
|
|
318
|
-
{
|
|
319
|
-
<TouchableOpacity style={
|
|
320
|
-
<Icon source={iconBack} />
|
|
268
|
+
{showBack ? (
|
|
269
|
+
<TouchableOpacity style={iconButtonStyle} onPress={onPressBack}>
|
|
270
|
+
<Icon source={iconBack} size={iconSize} />
|
|
321
271
|
</TouchableOpacity>
|
|
322
272
|
) : null}
|
|
323
273
|
|
|
@@ -325,15 +275,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
325
275
|
<View style={Styles.flex}>{options.header}</View>
|
|
326
276
|
) : (
|
|
327
277
|
<>
|
|
328
|
-
{
|
|
329
|
-
<View style={styles.iconButton} />
|
|
330
|
-
) : null}
|
|
278
|
+
{!leftOptions ? <View style={iconButtonStyle} /> : null}
|
|
331
279
|
<Text
|
|
332
|
-
style={[
|
|
333
|
-
Styles.flex,
|
|
334
|
-
styles.title,
|
|
335
|
-
!showButtons && styles.titleInset,
|
|
336
|
-
]}
|
|
280
|
+
style={[Styles.flex, styles.title]}
|
|
337
281
|
numberOfLines={1}
|
|
338
282
|
typography="header_default_bold"
|
|
339
283
|
>
|
|
@@ -341,18 +285,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
341
285
|
</Text>
|
|
342
286
|
</>
|
|
343
287
|
)}
|
|
344
|
-
{
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
</TouchableOpacity>
|
|
348
|
-
) : null}
|
|
288
|
+
<TouchableOpacity style={iconButtonStyle} onPress={onPressClose}>
|
|
289
|
+
<Icon source="navigation_close" size={iconSize} />
|
|
290
|
+
</TouchableOpacity>
|
|
349
291
|
</View>
|
|
350
292
|
</View>
|
|
351
293
|
);
|
|
352
294
|
}, [
|
|
353
|
-
hasSideColumn,
|
|
354
295
|
iconBack,
|
|
355
|
-
|
|
296
|
+
isFoldable,
|
|
356
297
|
leftOptions,
|
|
357
298
|
onPressBack,
|
|
358
299
|
onPressClose,
|
|
@@ -364,41 +305,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
364
305
|
useDivider,
|
|
365
306
|
]);
|
|
366
307
|
|
|
367
|
-
/**
|
|
368
|
-
* iPhone Duo folded: back and close move to the side bar column, drawn on
|
|
369
|
-
* the part of the sheet under the side safe area. They line up with the
|
|
370
|
-
* header row, stay below the status bar cluster on a tall sheet and above a
|
|
371
|
-
* camera at the bottom end of the strip.
|
|
372
|
-
*/
|
|
373
|
-
const renderSideColumn = () => {
|
|
374
|
-
if (!sideColumn) {
|
|
375
|
-
return null;
|
|
376
|
-
}
|
|
377
|
-
const lowestTop =
|
|
378
|
-
windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
|
|
379
|
-
const top = Math.min(
|
|
380
|
-
Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
|
|
381
|
-
lowestTop,
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
return (
|
|
385
|
-
<View
|
|
386
|
-
pointerEvents={'box-none'}
|
|
387
|
-
style={[
|
|
388
|
-
styles.sideColumn,
|
|
389
|
-
sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
|
|
390
|
-
InnerEdgeStyles[sideColumn.edge],
|
|
391
|
-
{ top, width: sideColumnWidth },
|
|
392
|
-
]}
|
|
393
|
-
>
|
|
394
|
-
{showBack ? (
|
|
395
|
-
<SideBarButton icon={iconBack} onPress={onPressBack} />
|
|
396
|
-
) : null}
|
|
397
|
-
<SideBarButton icon="navigation_close" onPress={onPressClose} />
|
|
398
|
-
</View>
|
|
399
|
-
);
|
|
400
|
-
};
|
|
401
|
-
|
|
402
308
|
const animatedStyle = useAnimatedStyle(() => {
|
|
403
309
|
return {
|
|
404
310
|
transform: [{ translateY: translateY.value }],
|
|
@@ -410,17 +316,12 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
410
316
|
opacity: interpolate(
|
|
411
317
|
translateY.value,
|
|
412
318
|
[0, heightDevice],
|
|
413
|
-
[
|
|
319
|
+
[scrimOpacity, 0],
|
|
414
320
|
Extrapolation.CLAMP,
|
|
415
321
|
),
|
|
416
322
|
};
|
|
417
323
|
});
|
|
418
324
|
|
|
419
|
-
const sideInsetStyle = isDuoFloating && {
|
|
420
|
-
paddingLeft: sideInsetLeft,
|
|
421
|
-
paddingRight: sideInsetRight,
|
|
422
|
-
};
|
|
423
|
-
|
|
424
325
|
const screen = (
|
|
425
326
|
<Screen
|
|
426
327
|
{...props}
|
|
@@ -461,43 +362,31 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
461
362
|
</Pressable>
|
|
462
363
|
|
|
463
364
|
<Animated.View
|
|
464
|
-
onLayout={
|
|
465
|
-
isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
|
|
466
|
-
}
|
|
467
365
|
style={[
|
|
468
366
|
animatedStyle,
|
|
469
367
|
{
|
|
470
368
|
backgroundColor,
|
|
471
|
-
borderTopLeftRadius:
|
|
472
|
-
borderTopRightRadius:
|
|
369
|
+
borderTopLeftRadius: sheet.radius,
|
|
370
|
+
borderTopRightRadius: sheet.radius,
|
|
473
371
|
overflow: 'hidden',
|
|
474
372
|
},
|
|
475
|
-
isDuo && {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
isDuoCentered && {
|
|
487
|
-
// keeps the portrait width in landscape, like system sheets
|
|
488
|
-
width: Math.min(
|
|
489
|
-
Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
|
|
490
|
-
windowSize.width -
|
|
491
|
-
Math.max(insets.left, insets.right, Spacing.L) * 2,
|
|
492
|
-
),
|
|
493
|
-
alignSelf: 'center',
|
|
494
|
-
},
|
|
373
|
+
isDuo && { marginBottom: keyboardLift },
|
|
374
|
+
isFoldable &&
|
|
375
|
+
sheet.width !== undefined &&
|
|
376
|
+
sheet.maxHeight !== undefined && {
|
|
377
|
+
// centered in the placement area, clear of the iPhone Duo
|
|
378
|
+
// control column and on the right half when half folded
|
|
379
|
+
width: sheet.width,
|
|
380
|
+
marginLeft: placement.x + (placement.width - sheet.width) / 2,
|
|
381
|
+
maxHeight: sheet.maxHeight - keyboardLift,
|
|
382
|
+
minHeight: sheet.minHeight,
|
|
383
|
+
},
|
|
495
384
|
showBaseLineDebug && styles.debugBaseLine,
|
|
496
385
|
]}
|
|
497
386
|
>
|
|
498
|
-
{
|
|
387
|
+
{isFoldable ? (
|
|
499
388
|
// content shrinks so a tall sheet keeps its header and footer
|
|
500
|
-
<View style={
|
|
389
|
+
<View style={styles.shrink}>
|
|
501
390
|
{renderHeader()}
|
|
502
391
|
<View style={styles.shrink}>{screen}</View>
|
|
503
392
|
</View>
|
|
@@ -515,7 +404,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
515
404
|
paddingBottom: Spacing.S,
|
|
516
405
|
backgroundColor: theme.colors.background.surface,
|
|
517
406
|
},
|
|
518
|
-
sideInsetStyle,
|
|
519
407
|
]}
|
|
520
408
|
>
|
|
521
409
|
{footerComponent}
|
|
@@ -529,7 +417,6 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
529
417
|
}}
|
|
530
418
|
/>
|
|
531
419
|
)}
|
|
532
|
-
{renderSideColumn()}
|
|
533
420
|
</Animated.View>
|
|
534
421
|
</KeyboardAvoidingView>
|
|
535
422
|
</Container>
|
|
@@ -543,7 +430,7 @@ const styles = StyleSheet.create({
|
|
|
543
430
|
},
|
|
544
431
|
overlay: {
|
|
545
432
|
flex: 1,
|
|
546
|
-
backgroundColor:
|
|
433
|
+
backgroundColor: Colors.black_20,
|
|
547
434
|
},
|
|
548
435
|
indicator: {
|
|
549
436
|
width: 40,
|
|
@@ -560,7 +447,6 @@ const styles = StyleSheet.create({
|
|
|
560
447
|
alignItems: 'center',
|
|
561
448
|
},
|
|
562
449
|
title: { textAlign: 'center' },
|
|
563
|
-
titleInset: { paddingHorizontal: Spacing.M },
|
|
564
450
|
iconButton: {
|
|
565
451
|
width: 24,
|
|
566
452
|
height: 24,
|
|
@@ -568,13 +454,11 @@ const styles = StyleSheet.create({
|
|
|
568
454
|
justifyContent: 'center',
|
|
569
455
|
marginHorizontal: Spacing.M,
|
|
570
456
|
},
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
gap: Spacing.M,
|
|
457
|
+
foldableIconButton: {
|
|
458
|
+
width: FOLDABLE_ICON_BUTTON_SIZE,
|
|
459
|
+
height: FOLDABLE_ICON_BUTTON_SIZE,
|
|
575
460
|
},
|
|
576
|
-
|
|
577
|
-
alignLeft: { left: 0 },
|
|
461
|
+
shrink: { flexShrink: 1 },
|
|
578
462
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
579
463
|
});
|
|
580
464
|
|
|
@@ -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 './device';
|
|
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
|
|
45
|
-
const
|
|
49
|
+
const { width, height } = useWindowDimensions();
|
|
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/device.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import { Dimensions,
|
|
2
|
-
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
-
import { Spacing } from '../Consts';
|
|
1
|
+
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
4
2
|
|
|
5
3
|
// widest short side of any single-display iPhone
|
|
6
4
|
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
7
5
|
|
|
8
|
-
const DUO_MODAL_MAX_WIDTH = 440;
|
|
9
|
-
const DUO_MODAL_MAX_HEIGHT = 640;
|
|
10
|
-
|
|
11
6
|
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
12
7
|
// still matches when Split View narrows the window
|
|
13
8
|
const isIPhoneDuo = () => {
|
|
@@ -23,44 +18,4 @@ const isIPhoneDuo = () => {
|
|
|
23
18
|
);
|
|
24
19
|
};
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
const isAndroidFoldable = () =>
|
|
28
|
-
Platform.OS === 'android' &&
|
|
29
|
-
NativeModules?.FoldStateModule?.isFoldableDevice === true;
|
|
30
|
-
|
|
31
|
-
const isFoldableDevice = () => isIPhoneDuo() || isAndroidFoldable();
|
|
32
|
-
|
|
33
|
-
// portrait side status bar is about twice as tall as its inset is wide
|
|
34
|
-
const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
|
|
35
|
-
Math.max(
|
|
36
|
-
insets.top,
|
|
37
|
-
height > width ? Math.max(insets.left, insets.right) * 2 : 0,
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Max modal size on foldable devices: inside the safe area, so clear of the
|
|
42
|
-
* side toolbar strip on iPhone Duo, and capped so it stays a dialog on the
|
|
43
|
-
* inner display.
|
|
44
|
-
*/
|
|
45
|
-
const getDuoModalBounds = (
|
|
46
|
-
insets: EdgeInsets,
|
|
47
|
-
width: number,
|
|
48
|
-
height: number,
|
|
49
|
-
) => ({
|
|
50
|
-
maxWidth: Math.min(
|
|
51
|
-
width - insets.left - insets.right - Spacing.XL * 2,
|
|
52
|
-
DUO_MODAL_MAX_WIDTH,
|
|
53
|
-
),
|
|
54
|
-
maxHeight: Math.min(
|
|
55
|
-
height - insets.top - insets.bottom - Spacing.XL * 2,
|
|
56
|
-
DUO_MODAL_MAX_HEIGHT,
|
|
57
|
-
),
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
export {
|
|
61
|
-
isIPhoneDuo,
|
|
62
|
-
isAndroidFoldable,
|
|
63
|
-
isFoldableDevice,
|
|
64
|
-
getDuoTopInset,
|
|
65
|
-
getDuoModalBounds,
|
|
66
|
-
};
|
|
21
|
+
export { isIPhoneDuo };
|
package/Application/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import Navigation from './Navigation';
|
|
|
18
18
|
import Navigator from './Navigator';
|
|
19
19
|
import ScaleSizeProvider from './ScaleSizeProvider';
|
|
20
20
|
import { useTooltipPortal } from './TooltipPortal';
|
|
21
|
+
import { getModalLayout, useModalLayout } from './modalLayout';
|
|
22
|
+
import type { ModalLayout, ModalRect } from './modalLayout';
|
|
21
23
|
|
|
22
24
|
export {
|
|
23
25
|
NavigationContainer,
|
|
@@ -31,4 +33,8 @@ export {
|
|
|
31
33
|
exportHeaderTitle,
|
|
32
34
|
ScaleSizeProvider,
|
|
33
35
|
useTooltipPortal,
|
|
36
|
+
getModalLayout,
|
|
37
|
+
useModalLayout,
|
|
34
38
|
};
|
|
39
|
+
|
|
40
|
+
export type { ModalLayout, ModalRect };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { useWindowDimensions } from 'react-native';
|
|
2
|
+
import {
|
|
3
|
+
EdgeInsets,
|
|
4
|
+
useSafeAreaInsets,
|
|
5
|
+
} from 'react-native-safe-area-context';
|
|
6
|
+
import { Radius, Spacing } from '../Consts';
|
|
7
|
+
import { ReservedRegion, useHinge } from '../Layout/ReservedRegions';
|
|
8
|
+
import { isIPhoneDuo } from './device';
|
|
9
|
+
|
|
10
|
+
// Foldable modal spec, shared with the Compose / SwiftUI kits
|
|
11
|
+
const POPUP_MAX_WIDTH = 450;
|
|
12
|
+
const POPUP_MIN_WIDTH = 280;
|
|
13
|
+
const POPUP_MAX_HEIGHT = 600;
|
|
14
|
+
const SHEET_MAX_WIDTH = 653;
|
|
15
|
+
const SHEET_MIN_WIDTH = 296;
|
|
16
|
+
const SHEET_MIN_HEIGHT = 230;
|
|
17
|
+
const FOLDABLE_MIN_WIDTH = 600;
|
|
18
|
+
|
|
19
|
+
const SCRIM_OPACITY = 0.6;
|
|
20
|
+
const FOLDABLE_SCRIM_OPACITY = 0.2;
|
|
21
|
+
|
|
22
|
+
export type ModalRect = {
|
|
23
|
+
x: number;
|
|
24
|
+
y: number;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type ModalLayout = {
|
|
30
|
+
/**
|
|
31
|
+
* iPhone Duo, or a window whose short side is at least 600 (Galaxy Z Fold / Pixel
|
|
32
|
+
* Fold inner display). Phones, landscape included, keep the regular modal look.
|
|
33
|
+
*/
|
|
34
|
+
isFoldable: boolean;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Area popups and sheets are placed in: the safe area, left of the control column on
|
|
38
|
+
* the iPhone Duo outer display, the right half when iPhone Duo is half folded.
|
|
39
|
+
*/
|
|
40
|
+
placement: ModalRect;
|
|
41
|
+
|
|
42
|
+
popup: {
|
|
43
|
+
width: number;
|
|
44
|
+
minWidth: number;
|
|
45
|
+
maxWidth: number;
|
|
46
|
+
/**
|
|
47
|
+
* `undefined` on phones, the popup grows with its content.
|
|
48
|
+
*/
|
|
49
|
+
maxHeight?: number;
|
|
50
|
+
margin: number;
|
|
51
|
+
radius: number;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
sheet: {
|
|
55
|
+
/**
|
|
56
|
+
* `undefined` on phones, the sheet spans the window.
|
|
57
|
+
*/
|
|
58
|
+
width?: number;
|
|
59
|
+
maxHeight?: number;
|
|
60
|
+
minHeight?: number;
|
|
61
|
+
margin: number;
|
|
62
|
+
radius: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
scrimOpacity: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
type ModalLayoutInput = {
|
|
69
|
+
width: number;
|
|
70
|
+
height: number;
|
|
71
|
+
insets: EdgeInsets;
|
|
72
|
+
isDuo: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* vertical division reported by the system, iPhone Duo half folded
|
|
75
|
+
*/
|
|
76
|
+
hinge?: ReservedRegion;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
80
|
+
Math.min(Math.max(value, min), max);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Modal geometry for a window, pure so it can be tested without a device
|
|
84
|
+
*/
|
|
85
|
+
const getModalLayout = ({
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
insets,
|
|
89
|
+
isDuo,
|
|
90
|
+
hinge,
|
|
91
|
+
}: ModalLayoutInput): ModalLayout => {
|
|
92
|
+
const shortSide = Math.min(width, height);
|
|
93
|
+
// short side, so a phone in landscape stays a phone
|
|
94
|
+
const isFoldable = isDuo || shortSide >= FOLDABLE_MIN_WIDTH;
|
|
95
|
+
const isDuoOuter = isDuo && shortSide < FOLDABLE_MIN_WIDTH;
|
|
96
|
+
const hingeRight = hinge ? hinge.x + hinge.width : 0;
|
|
97
|
+
const isHalfFolded =
|
|
98
|
+
isDuo && !!hinge && hinge.x > 0 && hingeRight < width - insets.right;
|
|
99
|
+
|
|
100
|
+
const safeLeft = isHalfFolded ? hingeRight : insets.left;
|
|
101
|
+
const placement = {
|
|
102
|
+
x: safeLeft,
|
|
103
|
+
y: insets.top,
|
|
104
|
+
width: width - insets.right - safeLeft,
|
|
105
|
+
height: height - insets.top - insets.bottom,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (!isFoldable) {
|
|
109
|
+
const popupWidth = width - Spacing.XL * 2;
|
|
110
|
+
return {
|
|
111
|
+
isFoldable,
|
|
112
|
+
placement,
|
|
113
|
+
popup: {
|
|
114
|
+
width: popupWidth,
|
|
115
|
+
minWidth: popupWidth,
|
|
116
|
+
maxWidth: popupWidth,
|
|
117
|
+
margin: Spacing.XL,
|
|
118
|
+
radius: Radius.L,
|
|
119
|
+
},
|
|
120
|
+
sheet: { margin: 0, radius: Radius.M },
|
|
121
|
+
scrimOpacity: SCRIM_OPACITY,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const popupMargin = isDuoOuter || isHalfFolded ? Spacing.M : Spacing.XL;
|
|
126
|
+
const popupSpace = placement.width - popupMargin * 2;
|
|
127
|
+
const popupMinWidth = Math.min(POPUP_MIN_WIDTH, popupSpace);
|
|
128
|
+
|
|
129
|
+
const sheetMargin = Spacing.M;
|
|
130
|
+
const sheetSpace = placement.width - sheetMargin * 2;
|
|
131
|
+
const sheetMaxHeight = height - insets.top - Spacing.M;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
isFoldable,
|
|
135
|
+
placement,
|
|
136
|
+
popup: {
|
|
137
|
+
width: clamp(popupSpace, popupMinWidth, POPUP_MAX_WIDTH),
|
|
138
|
+
minWidth: popupMinWidth,
|
|
139
|
+
maxWidth: POPUP_MAX_WIDTH,
|
|
140
|
+
maxHeight: Math.min(POPUP_MAX_HEIGHT, placement.height - Spacing.XL * 2),
|
|
141
|
+
margin: popupMargin,
|
|
142
|
+
radius: Radius.XL,
|
|
143
|
+
},
|
|
144
|
+
sheet: {
|
|
145
|
+
width: clamp(
|
|
146
|
+
sheetSpace,
|
|
147
|
+
Math.min(SHEET_MIN_WIDTH, sheetSpace),
|
|
148
|
+
SHEET_MAX_WIDTH,
|
|
149
|
+
),
|
|
150
|
+
maxHeight: sheetMaxHeight,
|
|
151
|
+
minHeight: Math.min(SHEET_MIN_HEIGHT, sheetMaxHeight),
|
|
152
|
+
margin: sheetMargin,
|
|
153
|
+
radius: Radius.XL,
|
|
154
|
+
},
|
|
155
|
+
scrimOpacity: FOLDABLE_SCRIM_OPACITY,
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Modal geometry for the current window, follows fold, unfold, rotation and the
|
|
161
|
+
* iPhone Duo hinge
|
|
162
|
+
*/
|
|
163
|
+
const useModalLayout = (): ModalLayout => {
|
|
164
|
+
const { width, height } = useWindowDimensions();
|
|
165
|
+
const insets = useSafeAreaInsets();
|
|
166
|
+
const hinge = useHinge();
|
|
167
|
+
return getModalLayout({ width, height, insets, isDuo: isIPhoneDuo(), hinge });
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
export {
|
|
171
|
+
getModalLayout,
|
|
172
|
+
useModalLayout,
|
|
173
|
+
POPUP_MAX_WIDTH,
|
|
174
|
+
POPUP_MIN_WIDTH,
|
|
175
|
+
POPUP_MAX_HEIGHT,
|
|
176
|
+
SHEET_MAX_WIDTH,
|
|
177
|
+
SHEET_MIN_WIDTH,
|
|
178
|
+
SHEET_MIN_HEIGHT,
|
|
179
|
+
FOLDABLE_MIN_WIDTH,
|
|
180
|
+
};
|
|
@@ -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/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import ItemSectionList from './ItemSectionList';
|
|
|
10
10
|
import Screen from './Screen';
|
|
11
11
|
import { TrackingScope } from './TrackingScope';
|
|
12
12
|
import { ScreenProps } from './Screen';
|
|
13
|
+
import { useHinge } from './ReservedRegions';
|
|
13
14
|
|
|
14
15
|
const GridContext = createContext<GridContextProps>({
|
|
15
16
|
numberOfColumns: 12,
|
|
@@ -31,4 +32,5 @@ export {
|
|
|
31
32
|
ItemSectionList,
|
|
32
33
|
Screen,
|
|
33
34
|
TrackingScope,
|
|
35
|
+
useHinge,
|
|
34
36
|
};
|
package/Popup/PopupNotify.tsx
CHANGED
|
@@ -8,7 +8,11 @@ import { Image } from '../Image';
|
|
|
8
8
|
import { Text, useScaleSize } from '../Text';
|
|
9
9
|
import { PopupNotifyProps } from './types';
|
|
10
10
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
11
|
-
import {
|
|
11
|
+
import { useModalLayout } from '../Application/modalLayout';
|
|
12
|
+
|
|
13
|
+
// foldable spec: icon button 32, icon 20
|
|
14
|
+
const FOLDABLE_CLOSE_SIZE = 32;
|
|
15
|
+
const FOLDABLE_CLOSE_ICON_SIZE = 20;
|
|
12
16
|
|
|
13
17
|
const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
14
18
|
image,
|
|
@@ -24,16 +28,19 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
24
28
|
const context = useContext<any>(MiniAppContext);
|
|
25
29
|
const { theme, navigator, translate } = useContext(ApplicationContext);
|
|
26
30
|
const [scrollContent, setScrollContent] = useState(false);
|
|
27
|
-
const
|
|
28
|
-
const maxHeight =
|
|
29
|
-
const radius = getPopupRadius();
|
|
31
|
+
const { isFoldable, popup } = useModalLayout();
|
|
32
|
+
const { width: popupWidth, maxHeight, radius } = popup;
|
|
30
33
|
const scaledMaxHeight = useScaleSize(172);
|
|
31
34
|
|
|
32
35
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
33
36
|
const closeAction = useRef('touch_outside');
|
|
34
37
|
let descriptionStyle = {};
|
|
35
38
|
let Description: any = View;
|
|
36
|
-
if (
|
|
39
|
+
if (isFoldable) {
|
|
40
|
+
// foldables cap the popup height, the description takes what is left
|
|
41
|
+
Description = ScrollView;
|
|
42
|
+
descriptionStyle = styles.shrink;
|
|
43
|
+
} else if (scrollContent) {
|
|
37
44
|
Description = ScrollView;
|
|
38
45
|
descriptionStyle = { maxHeight: scaledMaxHeight };
|
|
39
46
|
}
|
|
@@ -139,34 +146,53 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
139
146
|
* build close action
|
|
140
147
|
*/
|
|
141
148
|
const buildCloseIcon = () => {
|
|
142
|
-
if (typeof onIconClose
|
|
149
|
+
if (typeof onIconClose !== 'function') {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (isFoldable) {
|
|
143
153
|
return (
|
|
144
154
|
<TouchableOpacity
|
|
145
155
|
accessibilityLabel={'ic_popup_close'}
|
|
146
|
-
style={styles.
|
|
156
|
+
style={styles.foldableClose}
|
|
147
157
|
onPress={() => {
|
|
148
158
|
closeAction.current = 'icon_close';
|
|
149
159
|
onAction(onIconClose);
|
|
150
160
|
}}
|
|
151
161
|
>
|
|
152
|
-
<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
borderColor: theme.colors.background.surface,
|
|
158
|
-
},
|
|
159
|
-
]}
|
|
160
|
-
>
|
|
161
|
-
<Icon
|
|
162
|
-
source="navigation_close"
|
|
163
|
-
color={theme.colors.background.surface}
|
|
164
|
-
size={14}
|
|
165
|
-
/>
|
|
166
|
-
</View>
|
|
162
|
+
<Icon
|
|
163
|
+
source="navigation_close"
|
|
164
|
+
color={theme.colors.text.default}
|
|
165
|
+
size={FOLDABLE_CLOSE_ICON_SIZE}
|
|
166
|
+
/>
|
|
167
167
|
</TouchableOpacity>
|
|
168
168
|
);
|
|
169
169
|
}
|
|
170
|
+
return (
|
|
171
|
+
<TouchableOpacity
|
|
172
|
+
accessibilityLabel={'ic_popup_close'}
|
|
173
|
+
style={styles.iconCloseContainer}
|
|
174
|
+
onPress={() => {
|
|
175
|
+
closeAction.current = 'icon_close';
|
|
176
|
+
onAction(onIconClose);
|
|
177
|
+
}}
|
|
178
|
+
>
|
|
179
|
+
<View
|
|
180
|
+
style={[
|
|
181
|
+
styles.iconClose,
|
|
182
|
+
{
|
|
183
|
+
backgroundColor: theme.colors.text.default,
|
|
184
|
+
borderColor: theme.colors.background.surface,
|
|
185
|
+
},
|
|
186
|
+
]}
|
|
187
|
+
>
|
|
188
|
+
<Icon
|
|
189
|
+
source="navigation_close"
|
|
190
|
+
color={theme.colors.background.surface}
|
|
191
|
+
size={14}
|
|
192
|
+
/>
|
|
193
|
+
</View>
|
|
194
|
+
</TouchableOpacity>
|
|
195
|
+
);
|
|
170
196
|
};
|
|
171
197
|
|
|
172
198
|
const body = (
|
|
@@ -180,11 +206,18 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
180
206
|
]}
|
|
181
207
|
/>
|
|
182
208
|
)}
|
|
183
|
-
<View style={styles.content}>
|
|
209
|
+
<View style={[styles.content, isFoldable && styles.shrink]}>
|
|
184
210
|
<Text
|
|
185
211
|
typography={'header_default_bold'}
|
|
186
212
|
numberOfLines={3}
|
|
187
213
|
accessibilityLabel={'title_popup_notify'}
|
|
214
|
+
// keeps the title clear of the close button on a text only popup
|
|
215
|
+
style={
|
|
216
|
+
isFoldable &&
|
|
217
|
+
!image &&
|
|
218
|
+
typeof onIconClose === 'function' &&
|
|
219
|
+
styles.titleClearClose
|
|
220
|
+
}
|
|
188
221
|
>
|
|
189
222
|
{title}
|
|
190
223
|
</Text>
|
|
@@ -236,20 +269,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
236
269
|
showBaseLineDebug && styles.debugBaseLine,
|
|
237
270
|
]}
|
|
238
271
|
>
|
|
239
|
-
{
|
|
240
|
-
body
|
|
241
|
-
) : (
|
|
242
|
-
// scrolls when iPhone Duo landscape is too short; actions stay visible
|
|
243
|
-
<ScrollView
|
|
244
|
-
style={[
|
|
245
|
-
styles.body,
|
|
246
|
-
{ borderTopLeftRadius: radius, borderTopRightRadius: radius },
|
|
247
|
-
]}
|
|
248
|
-
bounces={false}
|
|
249
|
-
>
|
|
250
|
-
{body}
|
|
251
|
-
</ScrollView>
|
|
252
|
-
)}
|
|
272
|
+
{body}
|
|
253
273
|
<View style={styles.contentAction}>{buildAction()}</View>
|
|
254
274
|
{buildCloseIcon()}
|
|
255
275
|
</View>
|
|
@@ -262,7 +282,7 @@ const styles = StyleSheet.create({
|
|
|
262
282
|
aspectRatio: 1.7777,
|
|
263
283
|
overflow: 'hidden',
|
|
264
284
|
},
|
|
265
|
-
|
|
285
|
+
shrink: { flexShrink: 1 },
|
|
266
286
|
content: { padding: Spacing.XL },
|
|
267
287
|
description: {
|
|
268
288
|
marginTop: Spacing.S,
|
|
@@ -284,6 +304,16 @@ const styles = StyleSheet.create({
|
|
|
284
304
|
right: -Spacing.M,
|
|
285
305
|
padding: Spacing.XS,
|
|
286
306
|
},
|
|
307
|
+
titleClearClose: { paddingRight: Spacing.XL },
|
|
308
|
+
foldableClose: {
|
|
309
|
+
position: 'absolute',
|
|
310
|
+
top: Spacing.M,
|
|
311
|
+
right: Spacing.M,
|
|
312
|
+
width: FOLDABLE_CLOSE_SIZE,
|
|
313
|
+
height: FOLDABLE_CLOSE_SIZE,
|
|
314
|
+
alignItems: 'center',
|
|
315
|
+
justifyContent: 'center',
|
|
316
|
+
},
|
|
287
317
|
iconClose: {
|
|
288
318
|
width: 20,
|
|
289
319
|
height: 20,
|
package/Popup/PopupPromotion.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { ApplicationContext, MiniAppContext } from '../Context';
|
|
|
5
5
|
import { Image } from '../Image';
|
|
6
6
|
import { Colors, Radius, Spacing } from '../Consts';
|
|
7
7
|
import { Icon } from '../Icon';
|
|
8
|
-
import {
|
|
8
|
+
import { useModalLayout } from '../Application/modalLayout';
|
|
9
9
|
|
|
10
10
|
const ASPECT_RATIO = 0.72;
|
|
11
11
|
const ICON_CLOSE_SIZE = 20;
|
|
@@ -20,9 +20,10 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
20
20
|
}) => {
|
|
21
21
|
const context = useContext<any>(MiniAppContext);
|
|
22
22
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const {
|
|
24
|
+
popup: { width: popupWidth, maxHeight },
|
|
25
|
+
} = useModalLayout();
|
|
26
|
+
// fit the portrait image by height when a foldable leaves too little room
|
|
26
27
|
let imageWidth = popupWidth;
|
|
27
28
|
if (maxHeight !== undefined) {
|
|
28
29
|
const heightFitWidth = (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO;
|
package/package.json
CHANGED
package/Popup/utils.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { Dimensions, useWindowDimensions } from 'react-native';
|
|
2
|
-
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
-
import { Radius, Spacing } from '../Consts';
|
|
4
|
-
import { getDuoModalBounds, isFoldableDevice } from '../Application/device';
|
|
5
|
-
|
|
6
|
-
const usePopupWidth = () => {
|
|
7
|
-
const insets = useSafeAreaInsets();
|
|
8
|
-
const { width, height } = useWindowDimensions();
|
|
9
|
-
if (!isFoldableDevice()) {
|
|
10
|
-
return Dimensions.get('window').width - Spacing.XL * 2;
|
|
11
|
-
}
|
|
12
|
-
return getDuoModalBounds(insets, width, height).maxWidth;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const usePopupMaxHeight = () => {
|
|
16
|
-
const insets = useSafeAreaInsets();
|
|
17
|
-
const { width, height } = useWindowDimensions();
|
|
18
|
-
if (!isFoldableDevice()) {
|
|
19
|
-
return undefined;
|
|
20
|
-
}
|
|
21
|
-
return getDuoModalBounds(insets, width, height).maxHeight;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const getPopupRadius = () => (isFoldableDevice() ? Radius.XL : Radius.L);
|
|
25
|
-
|
|
26
|
-
export { usePopupWidth, usePopupMaxHeight, getPopupRadius };
|