@momo-kits/foundation 0.167.1-duo.2 → 0.167.1-duo.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Application/BottomSheet.tsx +111 -10
- package/Application/BottomTab/DuoBarCompression.tsx +135 -0
- package/Application/BottomTab/DuoTabBar.tsx +462 -0
- package/Application/BottomTab/index.tsx +145 -123
- package/Application/Components/DuoGlassBackground.tsx +179 -0
- package/Application/Components/DuoQuickReturnHeader.tsx +42 -0
- package/Application/Components/HeaderLeft.tsx +8 -2
- package/Application/Components/HeaderRight.tsx +47 -2
- package/Application/Components/OptionalGlassView.ts +11 -0
- package/Application/ModalScreen.tsx +14 -0
- package/Application/NavigationContainer.tsx +14 -11
- package/Application/device.ts +29 -0
- package/Application/types.ts +5 -0
- package/Layout/ReservedRegions.tsx +47 -0
- package/Layout/Screen.tsx +187 -32
- package/Layout/utils.ts +76 -8
- package/Popup/PopupNotify.tsx +31 -20
- package/Popup/PopupPromotion.tsx +20 -8
- package/Popup/utils.ts +32 -0
- package/package.json +1 -1
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
Platform,
|
|
9
9
|
Pressable,
|
|
10
10
|
StyleSheet,
|
|
11
|
+
useWindowDimensions,
|
|
11
12
|
View,
|
|
12
13
|
} from 'react-native';
|
|
14
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
13
15
|
|
|
14
16
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
15
17
|
import { Styles } from '../Consts';
|
|
16
18
|
import Navigation from './Navigation';
|
|
17
19
|
import { ModalParams } from './types';
|
|
18
20
|
import BottomSheet from './BottomSheet';
|
|
21
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
19
22
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
20
23
|
|
|
21
24
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -37,6 +40,8 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
37
40
|
title,
|
|
38
41
|
} = props.route.params;
|
|
39
42
|
const Component = useRef(screen).current;
|
|
43
|
+
const insets = useSafeAreaInsets();
|
|
44
|
+
const windowSize = useWindowDimensions();
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -147,6 +152,15 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
147
152
|
opacity,
|
|
148
153
|
transform: [{ scale }],
|
|
149
154
|
},
|
|
155
|
+
// center below the side status bar and above the home indicator
|
|
156
|
+
isIPhoneDuo() && {
|
|
157
|
+
marginTop: getDuoTopInset(
|
|
158
|
+
insets,
|
|
159
|
+
windowSize.width,
|
|
160
|
+
windowSize.height,
|
|
161
|
+
),
|
|
162
|
+
marginBottom: insets.bottom,
|
|
163
|
+
},
|
|
150
164
|
modalStyle,
|
|
151
165
|
]}
|
|
152
166
|
>
|
|
@@ -31,6 +31,7 @@ import { HeaderTitle } from './Components/HeaderTitle';
|
|
|
31
31
|
import { HeaderBackground } from './Components/HeaderBackground';
|
|
32
32
|
import { type EdgeInsets } from 'react-native-safe-area-context';
|
|
33
33
|
import { useScreenLayout } from '../Layout';
|
|
34
|
+
import { ReservedRegionsProvider } from '../Layout/ReservedRegions';
|
|
34
35
|
|
|
35
36
|
const Stack = createStackNavigator();
|
|
36
37
|
|
|
@@ -76,17 +77,19 @@ const NavigationContainer: React.FC<NavigationContainerProps> = ({
|
|
|
76
77
|
frame: { x: 0, y: 0, width: 0, height: 0 },
|
|
77
78
|
}}
|
|
78
79
|
>
|
|
79
|
-
<
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
80
|
+
<ReservedRegionsProvider>
|
|
81
|
+
<MiniAppContext.Provider value={mergedContext}>
|
|
82
|
+
<Navigation
|
|
83
|
+
screen={screen}
|
|
84
|
+
theme={theme}
|
|
85
|
+
options={options}
|
|
86
|
+
maxApi={maxApi}
|
|
87
|
+
setCurrentContext={setCurrentContext}
|
|
88
|
+
initialParams={initialParams}
|
|
89
|
+
localize={localize}
|
|
90
|
+
/>
|
|
91
|
+
</MiniAppContext.Provider>
|
|
92
|
+
</ReservedRegionsProvider>
|
|
90
93
|
</SafeAreaProvider>
|
|
91
94
|
);
|
|
92
95
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
2
|
+
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
// widest short side of any single-display iPhone
|
|
5
|
+
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
6
|
+
|
|
7
|
+
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
8
|
+
// still matches when Split View narrows the window
|
|
9
|
+
const isIPhoneDuo = () => {
|
|
10
|
+
if (Platform.OS !== 'ios' || Platform.isPad) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const shortSide = (size: ScaledSize) => Math.min(size.width, size.height);
|
|
14
|
+
return (
|
|
15
|
+
Math.max(
|
|
16
|
+
shortSide(Dimensions.get('screen')),
|
|
17
|
+
shortSide(Dimensions.get('window')),
|
|
18
|
+
) > IPHONE_MAX_SHORT_SIDE
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// portrait side status bar is about twice as tall as its inset is wide
|
|
23
|
+
const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
|
|
24
|
+
Math.max(
|
|
25
|
+
insets.top,
|
|
26
|
+
height > width ? Math.max(insets.left, insets.right) * 2 : 0,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export { isIPhoneDuo, getDuoTopInset };
|
package/Application/types.ts
CHANGED
|
@@ -333,6 +333,11 @@ export type BottomTabProps = {
|
|
|
333
333
|
blur?: (e: EventArg<'blur', false, unknown>) => void;
|
|
334
334
|
};
|
|
335
335
|
floatingButton?: FloatingButtonProps;
|
|
336
|
+
/**
|
|
337
|
+
* iPhone Duo: when the side bar can't hold both bars, which one starts compressed;
|
|
338
|
+
* tapping the compressed one swaps them. Defaults to 'toolbar'
|
|
339
|
+
*/
|
|
340
|
+
compressionBehavior?: 'toolbar' | 'tabBar';
|
|
336
341
|
};
|
|
337
342
|
|
|
338
343
|
export type FloatingButtonProps = {
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React, { createContext, ReactNode, useState } from 'react';
|
|
2
|
+
import { Platform, StyleSheet } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export type ReservedRegion = {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// native probe is optional so apps without @momo-kits/reserved-regions still bundle
|
|
12
|
+
let ReservedRegionsView: React.ComponentType<any> | undefined;
|
|
13
|
+
try {
|
|
14
|
+
ReservedRegionsView =
|
|
15
|
+
require('@momo-kits/reserved-regions').ReservedRegionsView;
|
|
16
|
+
} catch {
|
|
17
|
+
ReservedRegionsView = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* screen regions the system currently occludes, in window coordinates
|
|
22
|
+
*/
|
|
23
|
+
const ReservedRegionsContext = createContext<ReservedRegion[]>([]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* tracks the system occlusion regions (iOS 27.1+), e.g. the iPhone Duo status bar
|
|
27
|
+
* cluster growing for a call or a Live Activity
|
|
28
|
+
*/
|
|
29
|
+
const ReservedRegionsProvider: React.FC<{ children: ReactNode }> = ({
|
|
30
|
+
children,
|
|
31
|
+
}) => {
|
|
32
|
+
const [regions, setRegions] = useState<ReservedRegion[]>([]);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<ReservedRegionsContext.Provider value={regions}>
|
|
36
|
+
{children}
|
|
37
|
+
{Platform.OS === 'ios' && !!ReservedRegionsView && (
|
|
38
|
+
<ReservedRegionsView
|
|
39
|
+
style={StyleSheet.absoluteFill}
|
|
40
|
+
onRegionsChange={setRegions}
|
|
41
|
+
/>
|
|
42
|
+
)}
|
|
43
|
+
</ReservedRegionsContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { ReservedRegionsContext, ReservedRegionsProvider };
|
package/Layout/Screen.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import React, {
|
|
|
9
9
|
useContext,
|
|
10
10
|
useEffect,
|
|
11
11
|
useImperativeHandle,
|
|
12
|
+
useMemo,
|
|
12
13
|
useRef,
|
|
13
14
|
} from 'react';
|
|
14
15
|
import {
|
|
@@ -24,7 +25,11 @@ import {
|
|
|
24
25
|
View,
|
|
25
26
|
ViewProps,
|
|
26
27
|
} from 'react-native';
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
useAnimatedReaction,
|
|
30
|
+
useSharedValue,
|
|
31
|
+
withTiming,
|
|
32
|
+
} from 'react-native-reanimated';
|
|
28
33
|
import {
|
|
29
34
|
useSafeAreaFrame,
|
|
30
35
|
useSafeAreaInsets,
|
|
@@ -52,6 +57,12 @@ import {
|
|
|
52
57
|
HeaderTitle,
|
|
53
58
|
SearchHeader,
|
|
54
59
|
} from '../Application';
|
|
60
|
+
import {
|
|
61
|
+
DuoGlassBackground,
|
|
62
|
+
DuoScrollGradient,
|
|
63
|
+
} from '../Application/Components/DuoGlassBackground';
|
|
64
|
+
import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
|
|
65
|
+
import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
|
|
55
66
|
|
|
56
67
|
export interface ScreenProps extends ViewProps {
|
|
57
68
|
/**
|
|
@@ -247,13 +258,64 @@ const Screen = forwardRef(
|
|
|
247
258
|
const currentTint = useRef<string | undefined>(undefined);
|
|
248
259
|
|
|
249
260
|
const isTab = !!screen?.bottomTab;
|
|
261
|
+
// Duo floating tab bar overlays the tab content instead of taking layout space
|
|
262
|
+
const duoTabInset = useDuoTabBarInset();
|
|
250
263
|
|
|
251
264
|
let handleScroll;
|
|
252
265
|
let Component: any = View;
|
|
253
266
|
|
|
254
|
-
const bottomInset =
|
|
267
|
+
const bottomInset =
|
|
268
|
+
Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
269
|
+
const hasSideBar = !!layout.sideBar;
|
|
270
|
+
// iPhone Duo: content scrolls under a glass header instead of starting below it
|
|
271
|
+
const useDuoGlass =
|
|
272
|
+
hasSideBar &&
|
|
273
|
+
(headerType === 'default' || headerType === 'extended') &&
|
|
274
|
+
!animatedHeader &&
|
|
275
|
+
!inputSearchProps;
|
|
276
|
+
|
|
277
|
+
// iPhone Duo: the header leaves with the content and returns on a reverse scroll
|
|
278
|
+
const useQuickReturn =
|
|
279
|
+
hasSideBar && scrollable && (useDuoGlass || !!animatedHeader);
|
|
280
|
+
const headerHide = useSharedValue(0);
|
|
281
|
+
const lastScrollY = useSharedValue(0);
|
|
282
|
+
|
|
283
|
+
useAnimatedReaction(
|
|
284
|
+
() => sharedValue.value,
|
|
285
|
+
y => {
|
|
286
|
+
const delta = y - lastScrollY.value;
|
|
287
|
+
lastScrollY.value = y;
|
|
288
|
+
if (!useQuickReturn || y <= 0) {
|
|
289
|
+
headerHide.value = 0;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
headerHide.value = Math.min(
|
|
293
|
+
Math.max(headerHide.value + delta, 0),
|
|
294
|
+
heightHeader,
|
|
295
|
+
);
|
|
296
|
+
},
|
|
297
|
+
[useQuickReturn, heightHeader],
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
const quickReturnOptions: StackNavigationOptions = useMemo(
|
|
301
|
+
() =>
|
|
302
|
+
useQuickReturn
|
|
303
|
+
? {
|
|
304
|
+
header: (props: any) => (
|
|
305
|
+
<DuoQuickReturnHeader {...props} offset={headerHide} />
|
|
306
|
+
),
|
|
307
|
+
}
|
|
308
|
+
: { header: undefined },
|
|
309
|
+
[useQuickReturn, headerHide],
|
|
310
|
+
);
|
|
311
|
+
|
|
255
312
|
let keyboardOffset = heightHeader - bottomInset;
|
|
256
|
-
if (
|
|
313
|
+
if (
|
|
314
|
+
headerType === 'extended' ||
|
|
315
|
+
animatedHeader ||
|
|
316
|
+
inputSearchProps ||
|
|
317
|
+
useDuoGlass
|
|
318
|
+
) {
|
|
257
319
|
keyboardOffset = -bottomInset;
|
|
258
320
|
}
|
|
259
321
|
|
|
@@ -280,9 +342,13 @@ const Screen = forwardRef(
|
|
|
280
342
|
|
|
281
343
|
case 'extended':
|
|
282
344
|
options = {
|
|
345
|
+
...quickReturnOptions,
|
|
283
346
|
headerShown: true,
|
|
284
347
|
headerTransparent: true,
|
|
285
|
-
headerBackground: () =>
|
|
348
|
+
headerBackground: () =>
|
|
349
|
+
hasSideBar ? (
|
|
350
|
+
<DuoGlassBackground animatedValue={sharedValue} />
|
|
351
|
+
) : null,
|
|
286
352
|
headerTitle: (props: any) => <HeaderTitle {...props} />,
|
|
287
353
|
};
|
|
288
354
|
if (inputSearchProps) {
|
|
@@ -305,6 +371,19 @@ const Screen = forwardRef(
|
|
|
305
371
|
break;
|
|
306
372
|
|
|
307
373
|
default:
|
|
374
|
+
if (hasSideBar && !inputSearchProps) {
|
|
375
|
+
options = {
|
|
376
|
+
...quickReturnOptions,
|
|
377
|
+
headerTransparent: true,
|
|
378
|
+
headerTintColor: Colors.black_17,
|
|
379
|
+
headerShown: true,
|
|
380
|
+
headerBackground: () => (
|
|
381
|
+
<DuoGlassBackground animatedValue={sharedValue} />
|
|
382
|
+
),
|
|
383
|
+
headerTitle: (props: any) => <HeaderTitle {...props} />,
|
|
384
|
+
};
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
308
387
|
options = {
|
|
309
388
|
headerTransparent: false,
|
|
310
389
|
headerTintColor: Colors.black_17,
|
|
@@ -339,7 +418,7 @@ const Screen = forwardRef(
|
|
|
339
418
|
}
|
|
340
419
|
}
|
|
341
420
|
|
|
342
|
-
navigation?.instance?.setOptions(options);
|
|
421
|
+
navigation?.instance?.setOptions({ header: undefined, ...options });
|
|
343
422
|
},
|
|
344
423
|
[
|
|
345
424
|
gradientColor,
|
|
@@ -348,6 +427,8 @@ const Screen = forwardRef(
|
|
|
348
427
|
navigation?.instance,
|
|
349
428
|
sharedValue,
|
|
350
429
|
useShadowHeader,
|
|
430
|
+
hasSideBar,
|
|
431
|
+
quickReturnOptions,
|
|
351
432
|
],
|
|
352
433
|
);
|
|
353
434
|
|
|
@@ -363,18 +444,26 @@ const Screen = forwardRef(
|
|
|
363
444
|
}
|
|
364
445
|
|
|
365
446
|
options = {
|
|
447
|
+
...quickReturnOptions,
|
|
366
448
|
headerTintColor: currentTint.current ?? Colors.black_17,
|
|
367
449
|
headerTransparent: true,
|
|
368
|
-
headerBackground: (props: any) =>
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
450
|
+
headerBackground: (props: any) =>
|
|
451
|
+
hasSideBar ? (
|
|
452
|
+
// glass shows once the banner has scrolled under the header
|
|
453
|
+
<DuoGlassBackground
|
|
454
|
+
animatedValue={sharedValue}
|
|
455
|
+
fadeStart={Math.max(210 + layoutOffset - heightHeader, 0)}
|
|
456
|
+
/>
|
|
457
|
+
) : (
|
|
458
|
+
<HeaderBackground
|
|
459
|
+
{...props}
|
|
460
|
+
animatedValue={sharedValue}
|
|
461
|
+
useGradient={false}
|
|
462
|
+
useShadowHeader={useShadowHeader}
|
|
463
|
+
headerBackground={headerBackground}
|
|
464
|
+
gradientColor={gradientColor}
|
|
465
|
+
/>
|
|
466
|
+
),
|
|
378
467
|
headerTitle: (props: any) => {
|
|
379
468
|
return <HeaderTitle {...props} />;
|
|
380
469
|
},
|
|
@@ -395,6 +484,10 @@ const Screen = forwardRef(
|
|
|
395
484
|
navigation?.instance,
|
|
396
485
|
sharedValue,
|
|
397
486
|
useShadowHeader,
|
|
487
|
+
hasSideBar,
|
|
488
|
+
layoutOffset,
|
|
489
|
+
heightHeader,
|
|
490
|
+
quickReturnOptions,
|
|
398
491
|
],
|
|
399
492
|
);
|
|
400
493
|
|
|
@@ -418,7 +511,7 @@ const Screen = forwardRef(
|
|
|
418
511
|
),
|
|
419
512
|
};
|
|
420
513
|
|
|
421
|
-
navigation?.instance?.setOptions(options);
|
|
514
|
+
navigation?.instance?.setOptions({ header: undefined, ...options });
|
|
422
515
|
}, [navigation?.instance, sharedValue]);
|
|
423
516
|
|
|
424
517
|
/**
|
|
@@ -546,8 +639,36 @@ const Screen = forwardRef(
|
|
|
546
639
|
* handle scroll end
|
|
547
640
|
* @param e
|
|
548
641
|
*/
|
|
642
|
+
/**
|
|
643
|
+
* a half-shown quick return header settles fully in or out
|
|
644
|
+
*/
|
|
645
|
+
const snapQuickReturnHeader = (offsetY: number) => {
|
|
646
|
+
const hide = headerHide.value;
|
|
647
|
+
// above heightHeader the header still sits on the content
|
|
648
|
+
if (
|
|
649
|
+
!useQuickReturn ||
|
|
650
|
+
offsetY < heightHeader ||
|
|
651
|
+
hide <= 0 ||
|
|
652
|
+
hide >= heightHeader
|
|
653
|
+
) {
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
headerHide.value = withTiming(
|
|
657
|
+
hide > heightHeader / 2 ? heightHeader : 0,
|
|
658
|
+
{ duration: 200 },
|
|
659
|
+
);
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
const handleMomentumScrollEnd = (
|
|
663
|
+
e: NativeSyntheticEvent<NativeScrollEvent>,
|
|
664
|
+
) => {
|
|
665
|
+
snapQuickReturnHeader(e.nativeEvent.contentOffset.y);
|
|
666
|
+
scrollViewProps?.onMomentumScrollEnd?.(e);
|
|
667
|
+
};
|
|
668
|
+
|
|
549
669
|
const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
550
670
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
671
|
+
snapQuickReturnHeader(offsetY);
|
|
551
672
|
if (inputSearchProps && offsetY < 100 && offsetY > 0) {
|
|
552
673
|
Animated.timing(animatedValue.current, {
|
|
553
674
|
toValue: 0,
|
|
@@ -668,21 +789,36 @@ const Screen = forwardRef(
|
|
|
668
789
|
},
|
|
669
790
|
]}
|
|
670
791
|
>
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
792
|
+
{useDuoGlass ? (
|
|
793
|
+
<DuoScrollGradient
|
|
794
|
+
animatedValue={sharedValue}
|
|
795
|
+
gradientColor={gradientColor}
|
|
796
|
+
headerBackground={headerBackground}
|
|
797
|
+
heightHeader={heightHeader}
|
|
798
|
+
extended={headerType === 'extended'}
|
|
799
|
+
followScroll={useQuickReturn}
|
|
800
|
+
/>
|
|
801
|
+
) : (
|
|
802
|
+
<HeaderExtendHeader
|
|
803
|
+
headerType={headerType}
|
|
804
|
+
heightHeader={heightHeader}
|
|
805
|
+
headerRightWidth={headerRightWidth}
|
|
806
|
+
animatedValue={sharedValue}
|
|
807
|
+
inputSearchProps={inputSearchProps}
|
|
808
|
+
navigation={navigation}
|
|
809
|
+
inputSearchRef={inputSearchRef}
|
|
810
|
+
useShadowHeader={useShadowHeader}
|
|
811
|
+
gradientColor={gradientColor}
|
|
812
|
+
headerBackground={headerBackground}
|
|
813
|
+
/>
|
|
814
|
+
)}
|
|
683
815
|
|
|
684
816
|
<KeyboardAvoidingView
|
|
685
|
-
style={
|
|
817
|
+
style={[
|
|
818
|
+
Styles.flex,
|
|
819
|
+
useDuoGlass &&
|
|
820
|
+
(!scrollable || !!Header) && { paddingTop: heightHeader },
|
|
821
|
+
]}
|
|
686
822
|
enabled={enableKeyboardAvoidingView}
|
|
687
823
|
keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
|
|
688
824
|
behavior={Platform.select({
|
|
@@ -698,11 +834,24 @@ const Screen = forwardRef(
|
|
|
698
834
|
showsVerticalScrollIndicator={false}
|
|
699
835
|
onScroll={handleScroll}
|
|
700
836
|
onScrollEndDrag={handleScrollEnd}
|
|
837
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
701
838
|
scrollEventThrottle={16}
|
|
702
|
-
style={[
|
|
839
|
+
style={[
|
|
840
|
+
Styles.flex,
|
|
841
|
+
!scrollable && contentInsetStyle,
|
|
842
|
+
!scrollable &&
|
|
843
|
+
!Footer &&
|
|
844
|
+
isTab &&
|
|
845
|
+
!!duoTabInset && { paddingBottom: duoTabInset },
|
|
846
|
+
]}
|
|
703
847
|
contentContainerStyle={[
|
|
704
848
|
scrollable && !Footer && !isTab && { paddingBottom: bottomInset },
|
|
849
|
+
scrollable &&
|
|
850
|
+
!Footer &&
|
|
851
|
+
isTab &&
|
|
852
|
+
!!duoTabInset && { paddingBottom: duoTabInset },
|
|
705
853
|
scrollable && contentInsetStyle,
|
|
854
|
+
useDuoGlass && !Header && { paddingTop: heightHeader },
|
|
706
855
|
scrollViewProps?.contentContainerStyle,
|
|
707
856
|
]}
|
|
708
857
|
>
|
|
@@ -721,7 +870,11 @@ const Screen = forwardRef(
|
|
|
721
870
|
<FloatingButton
|
|
722
871
|
{...floatingButtonProps}
|
|
723
872
|
animatedValue={sharedValue}
|
|
724
|
-
bottom={
|
|
873
|
+
bottom={
|
|
874
|
+
Footer || isTab
|
|
875
|
+
? 12 + (Footer ? 0 : duoTabInset)
|
|
876
|
+
: bottomInset + Spacing.S
|
|
877
|
+
}
|
|
725
878
|
/>
|
|
726
879
|
</View>
|
|
727
880
|
)}
|
|
@@ -731,7 +884,9 @@ const Screen = forwardRef(
|
|
|
731
884
|
style={[
|
|
732
885
|
styles.shadow,
|
|
733
886
|
{
|
|
734
|
-
paddingBottom:
|
|
887
|
+
paddingBottom:
|
|
888
|
+
(isTab && duoTabInset ? duoTabInset : bottomInset) +
|
|
889
|
+
Spacing.S,
|
|
735
890
|
paddingLeft: layout.insetLeft,
|
|
736
891
|
paddingRight: layout.insetRight,
|
|
737
892
|
backgroundColor: theme.colors.background.surface,
|
package/Layout/utils.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
SafeAreaInsetsContext,
|
|
6
6
|
} from 'react-native-safe-area-context';
|
|
7
7
|
import { Spacing } from '../Consts';
|
|
8
|
+
import { ReservedRegion, ReservedRegionsContext } from './ReservedRegions';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* validate children type
|
|
@@ -70,6 +71,12 @@ const HeaderMetrics = {
|
|
|
70
71
|
* - firstItemTop: with the status bar in the strip, the first control sits under the
|
|
71
72
|
* camera, clock and connectivity cluster (cluster ends near y 151 on the outer display,
|
|
72
73
|
* y 98 on the inner display in landscape)
|
|
74
|
+
* - outerHeaderHeight / outerCameraCenterY: on the outer display in portrait the header
|
|
75
|
+
* is 4pt taller than iPhoneMinHeight and its title lines up with the camera punchout
|
|
76
|
+
* (y 24–66 at the top of the strip)
|
|
77
|
+
* - edgeInset: on the outer display in landscape the strip holds no status bar, so its
|
|
78
|
+
* controls start this far from the top and stay this far above the bottom, unless a
|
|
79
|
+
* reserved region (the camera at either end) asks for more
|
|
73
80
|
*/
|
|
74
81
|
const DuoMetrics = {
|
|
75
82
|
maxAspectRatio: 1.6,
|
|
@@ -78,10 +85,40 @@ const DuoMetrics = {
|
|
|
78
85
|
itemPitch: 50,
|
|
79
86
|
firstItemTop: 170,
|
|
80
87
|
firstItemTopLandscape: 120,
|
|
88
|
+
outerHeaderHeight: 76,
|
|
89
|
+
outerCameraCenterY: 45,
|
|
90
|
+
edgeInset: 24,
|
|
81
91
|
};
|
|
82
92
|
|
|
83
93
|
const NO_INSETS: EdgeInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
84
94
|
|
|
95
|
+
/**
|
|
96
|
+
* how far system regions reach into a side strip from its top and from its bottom end
|
|
97
|
+
* (e.g. the camera, which sits at either end depending on the rotation)
|
|
98
|
+
*/
|
|
99
|
+
const getStripOcclusion = (
|
|
100
|
+
regions: ReservedRegion[],
|
|
101
|
+
stripLeft: number,
|
|
102
|
+
stripRight: number,
|
|
103
|
+
windowHeight: number,
|
|
104
|
+
) =>
|
|
105
|
+
regions.reduce(
|
|
106
|
+
(occlusion, region) => {
|
|
107
|
+
const overlapsStrip =
|
|
108
|
+
region.x < stripRight && region.x + region.width > stripLeft;
|
|
109
|
+
if (!overlapsStrip) {
|
|
110
|
+
return occlusion;
|
|
111
|
+
}
|
|
112
|
+
if (region.y + region.height / 2 < windowHeight / 2) {
|
|
113
|
+
occlusion.top = Math.max(occlusion.top, region.y + region.height);
|
|
114
|
+
} else {
|
|
115
|
+
occlusion.bottom = Math.max(occlusion.bottom, windowHeight - region.y);
|
|
116
|
+
}
|
|
117
|
+
return occlusion;
|
|
118
|
+
},
|
|
119
|
+
{ top: 0, bottom: 0 },
|
|
120
|
+
);
|
|
121
|
+
|
|
85
122
|
const isIPhone = () =>
|
|
86
123
|
Platform.OS === 'ios' && Platform.constants.interfaceIdiom === 'phone';
|
|
87
124
|
|
|
@@ -120,6 +157,11 @@ export type SideBarLayout = {
|
|
|
120
157
|
* Window y of the first side bar item, below the navigation bar and the side status bar.
|
|
121
158
|
*/
|
|
122
159
|
top: number;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Room to keep free above the window bottom, clear of a camera at the bottom end.
|
|
163
|
+
*/
|
|
164
|
+
bottom: number;
|
|
123
165
|
};
|
|
124
166
|
|
|
125
167
|
export type ScreenLayout = {
|
|
@@ -201,8 +243,9 @@ const isIPhoneDuoLayout = (
|
|
|
201
243
|
const useScreenLayout = (): ScreenLayout => {
|
|
202
244
|
const { width, height } = useWindowDimensions();
|
|
203
245
|
const insets = useContext(SafeAreaInsetsContext) ?? NO_INSETS;
|
|
246
|
+
const reservedRegions = useContext(ReservedRegionsContext);
|
|
204
247
|
|
|
205
|
-
|
|
248
|
+
let headerLayout = getHeaderLayout(insets.top);
|
|
206
249
|
const isIPhoneDuo = isIPhoneDuoLayout(width, height, insets);
|
|
207
250
|
if (!isIPhoneDuo) {
|
|
208
251
|
return {
|
|
@@ -224,17 +267,42 @@ const useScreenLayout = (): ScreenLayout => {
|
|
|
224
267
|
let top = insets.top + HeaderMetrics.barHeight + Spacing.M;
|
|
225
268
|
if (insets.top < HeaderMetrics.statusBarMinInset) {
|
|
226
269
|
// status bar lives in the strip: start under its cluster, as the native bar does
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
270
|
+
const isLandscape = width > height;
|
|
271
|
+
if (!isLandscape) {
|
|
272
|
+
top = DuoMetrics.firstItemTop;
|
|
273
|
+
} else if (height >= DuoMetrics.innerMinHeight) {
|
|
274
|
+
top = DuoMetrics.firstItemTopLandscape;
|
|
275
|
+
} else {
|
|
276
|
+
// outer display in landscape: no status bar in the strip
|
|
277
|
+
top = DuoMetrics.edgeInset;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (height > width && width < DuoMetrics.innerMinHeight) {
|
|
281
|
+
// outer display: the row starts below the top so the title centers on the camera
|
|
282
|
+
headerLayout = {
|
|
283
|
+
headerHeight: DuoMetrics.outerHeaderHeight,
|
|
284
|
+
headerStatusBarHeight:
|
|
285
|
+
DuoMetrics.outerCameraCenterY * 2 - DuoMetrics.outerHeaderHeight,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
232
288
|
}
|
|
233
289
|
|
|
290
|
+
const edge = insets.right >= insets.left ? 'right' : 'left';
|
|
291
|
+
const stripLeft = edge === 'right' ? width - sideInset : 0;
|
|
292
|
+
// keep controls clear of the camera and of the status bar cluster, which grows for
|
|
293
|
+
// calls and Live Activities. Region frames already include the system's margin
|
|
294
|
+
const occlusion = getStripOcclusion(
|
|
295
|
+
reservedRegions,
|
|
296
|
+
stripLeft,
|
|
297
|
+
stripLeft + sideInset,
|
|
298
|
+
height,
|
|
299
|
+
);
|
|
300
|
+
|
|
234
301
|
sideBar = {
|
|
235
|
-
edge
|
|
302
|
+
edge,
|
|
236
303
|
width: sideInset,
|
|
237
|
-
top,
|
|
304
|
+
top: Math.max(top, occlusion.top),
|
|
305
|
+
bottom: Math.max(DuoMetrics.edgeInset, occlusion.bottom),
|
|
238
306
|
};
|
|
239
307
|
}
|
|
240
308
|
|