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