@momo-kits/foundation 0.167.1-duo.5 → 0.167.1-duo.8
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/DuoBarCompression.tsx +135 -0
- package/Application/BottomTab/DuoTabBar.tsx +107 -55
- package/Application/BottomTab/index.tsx +143 -132
- package/Application/Components/DuoGlassBackground.tsx +28 -26
- package/Application/Components/DuoQuickReturnHeader.tsx +42 -0
- package/Application/Components/HeaderLeft.tsx +8 -2
- package/Application/Components/HeaderRight.tsx +47 -2
- package/Application/NavigationContainer.tsx +14 -11
- package/Application/types.ts +5 -0
- package/Layout/ReservedRegions.tsx +47 -0
- package/Layout/Screen.tsx +79 -3
- package/Layout/utils.ts +76 -8
- package/package.json +1 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
ReactNode,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { Spacing } from '../../Consts';
|
|
9
|
+
import { useScreenLayout } from '../../Layout';
|
|
10
|
+
import { DuoMetrics } from '../../Layout/utils';
|
|
11
|
+
|
|
12
|
+
export type DuoBarCompressionBehavior = 'toolbar' | 'tabBar';
|
|
13
|
+
|
|
14
|
+
type DuoBarCompression = {
|
|
15
|
+
toolbarCompressed: boolean;
|
|
16
|
+
tabBarCompressed: boolean;
|
|
17
|
+
// the open rail needs the toolbar button slot too: only Back stays
|
|
18
|
+
toolbarHidden?: boolean;
|
|
19
|
+
// tapping the compressed bar expands it and compresses the other one
|
|
20
|
+
expandToolbar?: () => void;
|
|
21
|
+
expandTabBar?: () => void;
|
|
22
|
+
// a rail opened from the compressed state closes once a tab is picked
|
|
23
|
+
onTabSelected?: () => void;
|
|
24
|
+
// header buttons report their full height so the tab bar knows what is left
|
|
25
|
+
setToolbarHeight?: (height: number) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RAIL_PADDING = 6;
|
|
29
|
+
const RAIL_GAP = Spacing.XXS;
|
|
30
|
+
// space kept between the top and the bottom groups (HIG Vertical controls)
|
|
31
|
+
const GROUP_GAP = Spacing.M;
|
|
32
|
+
// Back, then the right toolkit: help + home capsule
|
|
33
|
+
const DEFAULT_TOOLBAR_HEIGHT =
|
|
34
|
+
DuoMetrics.controlSize + Spacing.M + 2 * DuoMetrics.itemPitch;
|
|
35
|
+
// Back, then the single button the toolbar folds into
|
|
36
|
+
const COMPRESSED_TOOLBAR_HEIGHT = 2 * DuoMetrics.controlSize + Spacing.M;
|
|
37
|
+
// Back alone, while a rail opened from the compressed state takes the toolbar slot
|
|
38
|
+
const BACK_ONLY_HEIGHT = DuoMetrics.controlSize;
|
|
39
|
+
|
|
40
|
+
const NO_COMPRESSION: DuoBarCompression = {
|
|
41
|
+
toolbarCompressed: false,
|
|
42
|
+
tabBarCompressed: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const DuoBarCompressionContext =
|
|
46
|
+
createContext<DuoBarCompression>(NO_COMPRESSION);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* height of the vertical tab rail, plus the QR button under it
|
|
50
|
+
*/
|
|
51
|
+
const getRailHeight = (tabCount: number, hasQr: boolean) =>
|
|
52
|
+
2 * RAIL_PADDING +
|
|
53
|
+
tabCount * DuoMetrics.controlSize +
|
|
54
|
+
Math.max(tabCount - 1, 0) * RAIL_GAP +
|
|
55
|
+
(hasQr ? Spacing.M + DuoMetrics.controlSize : 0);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* keeps the side bar's toolbar and tab bar clear of each other (HIG, Designing for
|
|
59
|
+
* iPhone Duo: Vertical controls):
|
|
60
|
+
* - both fit: both stay whole
|
|
61
|
+
* - one fits whole beside the other compressed: one is compressed, tapping it swaps them
|
|
62
|
+
* - otherwise both rest compressed; tapping one opens it for a moment (the rail closes
|
|
63
|
+
* again once a tab is picked)
|
|
64
|
+
*/
|
|
65
|
+
const DuoBarCompressionProvider: React.FC<{
|
|
66
|
+
behavior?: DuoBarCompressionBehavior;
|
|
67
|
+
tabCount: number;
|
|
68
|
+
hasQr: boolean;
|
|
69
|
+
children: ReactNode;
|
|
70
|
+
}> = ({ behavior = 'toolbar', tabCount, hasQr, children }) => {
|
|
71
|
+
const { height, sideBar } = useScreenLayout();
|
|
72
|
+
const [toolbarHeight, setToolbarHeight] = useState(DEFAULT_TOOLBAR_HEIGHT);
|
|
73
|
+
// 'toolbar' compression keeps the tab bar whole until the toolbar is tapped
|
|
74
|
+
const [expanded, setExpanded] = useState<'toolbar' | 'tabBar'>(
|
|
75
|
+
behavior === 'toolbar' ? 'tabBar' : 'toolbar',
|
|
76
|
+
);
|
|
77
|
+
// bar opened for a moment while both rest compressed
|
|
78
|
+
const [opened, setOpened] = useState<'toolbar' | 'tabBar'>();
|
|
79
|
+
|
|
80
|
+
const value = useMemo(() => {
|
|
81
|
+
if (!sideBar) {
|
|
82
|
+
return NO_COMPRESSION;
|
|
83
|
+
}
|
|
84
|
+
const room = height - sideBar.bottom - sideBar.top - GROUP_GAP;
|
|
85
|
+
const tabBar = getRailHeight(tabCount, hasQr);
|
|
86
|
+
const compressedTabBar = getRailHeight(1, hasQr) - 2 * RAIL_PADDING;
|
|
87
|
+
const fits = (toolbar: number, tabs: number) => toolbar + tabs <= room;
|
|
88
|
+
|
|
89
|
+
if (fits(toolbarHeight, tabBar)) {
|
|
90
|
+
return { ...NO_COMPRESSION, setToolbarHeight };
|
|
91
|
+
}
|
|
92
|
+
const canSwap =
|
|
93
|
+
fits(COMPRESSED_TOOLBAR_HEIGHT, tabBar) &&
|
|
94
|
+
fits(toolbarHeight, compressedTabBar);
|
|
95
|
+
if (canSwap) {
|
|
96
|
+
return {
|
|
97
|
+
toolbarCompressed: expanded === 'tabBar',
|
|
98
|
+
tabBarCompressed: expanded === 'toolbar',
|
|
99
|
+
expandToolbar: () => setExpanded('toolbar'),
|
|
100
|
+
expandTabBar: () => setExpanded('tabBar'),
|
|
101
|
+
setToolbarHeight,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const open = {
|
|
106
|
+
expandToolbar: () => setOpened('toolbar'),
|
|
107
|
+
expandTabBar: () => setOpened('tabBar'),
|
|
108
|
+
setToolbarHeight,
|
|
109
|
+
};
|
|
110
|
+
if (opened === 'tabBar' && fits(BACK_ONLY_HEIGHT, tabBar)) {
|
|
111
|
+
return {
|
|
112
|
+
...open,
|
|
113
|
+
toolbarCompressed: true,
|
|
114
|
+
toolbarHidden: true,
|
|
115
|
+
tabBarCompressed: false,
|
|
116
|
+
onTabSelected: () => setOpened(undefined),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...open,
|
|
121
|
+
toolbarCompressed: opened !== 'toolbar',
|
|
122
|
+
tabBarCompressed: true,
|
|
123
|
+
};
|
|
124
|
+
}, [expanded, opened, hasQr, height, sideBar, tabCount, toolbarHeight]);
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<DuoBarCompressionContext.Provider value={value}>
|
|
128
|
+
{children}
|
|
129
|
+
</DuoBarCompressionContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const useDuoBarCompression = () => useContext(DuoBarCompressionContext);
|
|
134
|
+
|
|
135
|
+
export { DuoBarCompressionProvider, useDuoBarCompression };
|
|
@@ -18,6 +18,7 @@ import { Icon } from '../../Icon';
|
|
|
18
18
|
import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
|
|
19
19
|
import { Text } from '../../Text';
|
|
20
20
|
import { GlassView } from '../Components/OptionalGlassView';
|
|
21
|
+
import { useDuoBarCompression } from './DuoBarCompression';
|
|
21
22
|
import { FloatingButtonProps } from '../types';
|
|
22
23
|
import { TabBarIcon } from './TabBarIcon';
|
|
23
24
|
|
|
@@ -146,76 +147,121 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
|
|
|
146
147
|
return <>{icon}</>;
|
|
147
148
|
};
|
|
148
149
|
|
|
150
|
+
type TabItem = ReturnType<typeof getTabItems>[number];
|
|
151
|
+
|
|
149
152
|
/**
|
|
150
|
-
*
|
|
153
|
+
* glass column of tab items with the sliding selection bubble
|
|
151
154
|
*/
|
|
152
|
-
const
|
|
155
|
+
const Rail: React.FC<{
|
|
156
|
+
items: TabItem[];
|
|
157
|
+
index: number;
|
|
158
|
+
onItemPress?: () => void;
|
|
159
|
+
}> = ({ items, index, onItemPress }) => {
|
|
153
160
|
const { theme } = useContext(ApplicationContext);
|
|
154
|
-
const
|
|
155
|
-
const { state, floatingButton } = props;
|
|
156
|
-
const items = getTabItems(props);
|
|
157
|
-
const bubbleOffset = useSharedValue(state.index * (ITEM_SIZE + RAIL_GAP));
|
|
161
|
+
const bubbleOffset = useSharedValue(index * (ITEM_SIZE + RAIL_GAP));
|
|
158
162
|
|
|
159
163
|
useEffect(() => {
|
|
160
|
-
bubbleOffset.value = withSpring(
|
|
161
|
-
|
|
162
|
-
SPRING,
|
|
163
|
-
);
|
|
164
|
-
}, [state.index, bubbleOffset]);
|
|
164
|
+
bubbleOffset.value = withSpring(index * (ITEM_SIZE + RAIL_GAP), SPRING);
|
|
165
|
+
}, [index, bubbleOffset]);
|
|
165
166
|
|
|
166
167
|
const bubbleStyle = useAnimatedStyle(() => ({
|
|
167
168
|
transform: [{ translateY: bubbleOffset.value }],
|
|
168
169
|
}));
|
|
169
170
|
|
|
171
|
+
return (
|
|
172
|
+
<View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
|
|
173
|
+
<BarMaterial radius={Radius.XL} />
|
|
174
|
+
<Animated.View
|
|
175
|
+
pointerEvents={'none'}
|
|
176
|
+
style={[
|
|
177
|
+
styles.railBubble,
|
|
178
|
+
{ backgroundColor: theme.colors.background.selected },
|
|
179
|
+
bubbleStyle,
|
|
180
|
+
]}
|
|
181
|
+
/>
|
|
182
|
+
{items.map(item => (
|
|
183
|
+
<TouchableOpacity
|
|
184
|
+
key={item.route.key}
|
|
185
|
+
accessibilityRole={'tab'}
|
|
186
|
+
accessibilityLabel={item.label}
|
|
187
|
+
accessibilityState={{ selected: item.focused }}
|
|
188
|
+
onPress={() => {
|
|
189
|
+
item.onPress();
|
|
190
|
+
onItemPress?.();
|
|
191
|
+
}}
|
|
192
|
+
onLongPress={item.onLongPress}
|
|
193
|
+
style={styles.railItem}
|
|
194
|
+
>
|
|
195
|
+
{item.icon}
|
|
196
|
+
</TouchableOpacity>
|
|
197
|
+
))}
|
|
198
|
+
</View>
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E).
|
|
204
|
+
* When the side bar is short, it can shrink to one button showing the current tab
|
|
205
|
+
* (HIG "Tab bar compressed"); tapping it expands the rail and compresses the toolbar
|
|
206
|
+
*/
|
|
207
|
+
const DuoRailTabBar: React.FC<Props> = props => {
|
|
208
|
+
const { theme } = useContext(ApplicationContext);
|
|
209
|
+
const { sideBar } = useScreenLayout();
|
|
210
|
+
const { tabBarCompressed, expandTabBar, onTabSelected } =
|
|
211
|
+
useDuoBarCompression();
|
|
212
|
+
const { state, floatingButton } = props;
|
|
213
|
+
const items = getTabItems(props);
|
|
214
|
+
const current = items[state.index];
|
|
215
|
+
|
|
216
|
+
const columnStyle = sideBar && [
|
|
217
|
+
styles.railColumn,
|
|
218
|
+
{
|
|
219
|
+
[sideBar.edge]: 0,
|
|
220
|
+
width: sideBar.width,
|
|
221
|
+
bottom: sideBar.bottom,
|
|
222
|
+
},
|
|
223
|
+
sideBar.edge === 'right'
|
|
224
|
+
? { alignItems: 'flex-start' as const, paddingLeft: Spacing.M }
|
|
225
|
+
: { alignItems: 'flex-end' as const, paddingRight: Spacing.M },
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const qr = floatingButton && (
|
|
229
|
+
<TouchableOpacity
|
|
230
|
+
accessibilityRole={'button'}
|
|
231
|
+
accessibilityLabel={floatingButton.label}
|
|
232
|
+
onPress={floatingButton.onPress}
|
|
233
|
+
style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
|
|
234
|
+
>
|
|
235
|
+
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
236
|
+
</TouchableOpacity>
|
|
237
|
+
);
|
|
238
|
+
|
|
170
239
|
if (!sideBar) {
|
|
171
240
|
return null;
|
|
172
241
|
}
|
|
173
242
|
|
|
243
|
+
if (!tabBarCompressed) {
|
|
244
|
+
return (
|
|
245
|
+
<View pointerEvents={'box-none'} style={columnStyle}>
|
|
246
|
+
<Rail items={items} index={state.index} onItemPress={onTabSelected} />
|
|
247
|
+
{qr}
|
|
248
|
+
</View>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
174
252
|
return (
|
|
175
|
-
<View
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
]}
|
|
184
|
-
>
|
|
185
|
-
<View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
|
|
253
|
+
<View pointerEvents={'box-none'} style={columnStyle}>
|
|
254
|
+
<TouchableOpacity
|
|
255
|
+
accessibilityRole={'button'}
|
|
256
|
+
accessibilityLabel={current?.label}
|
|
257
|
+
accessibilityHint={'bottom_tab_bar'}
|
|
258
|
+
onPress={expandTabBar}
|
|
259
|
+
style={styles.railCompact}
|
|
260
|
+
>
|
|
186
261
|
<BarMaterial radius={Radius.XL} />
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
styles.railBubble,
|
|
191
|
-
{ backgroundColor: theme.colors.background.selected },
|
|
192
|
-
bubbleStyle,
|
|
193
|
-
]}
|
|
194
|
-
/>
|
|
195
|
-
{items.map(item => (
|
|
196
|
-
<TouchableOpacity
|
|
197
|
-
key={item.route.key}
|
|
198
|
-
accessibilityRole={'tab'}
|
|
199
|
-
accessibilityLabel={item.label}
|
|
200
|
-
accessibilityState={{ selected: item.focused }}
|
|
201
|
-
onPress={item.onPress}
|
|
202
|
-
onLongPress={item.onLongPress}
|
|
203
|
-
style={styles.railItem}
|
|
204
|
-
>
|
|
205
|
-
{item.icon}
|
|
206
|
-
</TouchableOpacity>
|
|
207
|
-
))}
|
|
208
|
-
</View>
|
|
209
|
-
{floatingButton && (
|
|
210
|
-
<TouchableOpacity
|
|
211
|
-
accessibilityRole={'button'}
|
|
212
|
-
accessibilityLabel={floatingButton.label}
|
|
213
|
-
onPress={floatingButton.onPress}
|
|
214
|
-
style={[styles.railQr, { backgroundColor: theme.colors.primary }]}
|
|
215
|
-
>
|
|
216
|
-
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
217
|
-
</TouchableOpacity>
|
|
218
|
-
)}
|
|
262
|
+
{current?.icon}
|
|
263
|
+
</TouchableOpacity>
|
|
264
|
+
{qr}
|
|
219
265
|
</View>
|
|
220
266
|
);
|
|
221
267
|
};
|
|
@@ -347,9 +393,15 @@ const styles = StyleSheet.create({
|
|
|
347
393
|
},
|
|
348
394
|
railColumn: {
|
|
349
395
|
position: 'absolute',
|
|
350
|
-
bottom: Spacing.XL,
|
|
351
396
|
gap: Spacing.M,
|
|
352
397
|
},
|
|
398
|
+
railCompact: {
|
|
399
|
+
width: ITEM_SIZE,
|
|
400
|
+
height: ITEM_SIZE,
|
|
401
|
+
borderRadius: ITEM_SIZE / 2,
|
|
402
|
+
alignItems: 'center',
|
|
403
|
+
justifyContent: 'center',
|
|
404
|
+
},
|
|
353
405
|
rail: {
|
|
354
406
|
width: ITEM_SIZE,
|
|
355
407
|
paddingVertical: RAIL_PADDING,
|
|
@@ -28,6 +28,7 @@ import { BottomTabProps } from '../types';
|
|
|
28
28
|
import { getOptions, getStackOptions, useAppState } from '../utils';
|
|
29
29
|
import BottomTabBar from './BottomTabBar';
|
|
30
30
|
import { DuoTabBar } from './DuoTabBar';
|
|
31
|
+
import { DuoBarCompressionProvider } from './DuoBarCompression';
|
|
31
32
|
import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
32
33
|
import { useScreenLayout } from '../../Layout';
|
|
33
34
|
|
|
@@ -205,6 +206,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
205
206
|
navigation,
|
|
206
207
|
initialRouteName,
|
|
207
208
|
floatingButton,
|
|
209
|
+
compressionBehavior,
|
|
208
210
|
}) => {
|
|
209
211
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
210
212
|
const { isIPhoneDuo } = useScreenLayout();
|
|
@@ -281,147 +283,156 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
281
283
|
};
|
|
282
284
|
|
|
283
285
|
return (
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
headerBackground: undefined,
|
|
289
|
-
tabBarLabelPosition: 'below-icon',
|
|
290
|
-
tabBarBackground: () => (
|
|
291
|
-
<View
|
|
292
|
-
style={[
|
|
293
|
-
styles.container,
|
|
294
|
-
{
|
|
295
|
-
backgroundColor: theme.colors.background.surface,
|
|
296
|
-
},
|
|
297
|
-
]}
|
|
298
|
-
>
|
|
299
|
-
<Animated.View style={[styles.indicatorContainer, indicatorStyle]}>
|
|
300
|
-
<View
|
|
301
|
-
style={[
|
|
302
|
-
styles.indicator,
|
|
303
|
-
{ backgroundColor: theme.colors.primary },
|
|
304
|
-
]}
|
|
305
|
-
/>
|
|
306
|
-
</Animated.View>
|
|
307
|
-
</View>
|
|
308
|
-
),
|
|
309
|
-
}}
|
|
310
|
-
tabBar={props =>
|
|
311
|
-
isIPhoneDuo ? (
|
|
312
|
-
<DuoTabBar
|
|
313
|
-
{...props}
|
|
314
|
-
floatingButton={floatingButton}
|
|
315
|
-
inactiveTintColor={theme.colors.text.default}
|
|
316
|
-
activeTintColor={theme.colors.primary}
|
|
317
|
-
/>
|
|
318
|
-
) : (
|
|
319
|
-
<BottomTabBar
|
|
320
|
-
{...props}
|
|
321
|
-
floatingButton={floatingButton}
|
|
322
|
-
inactiveTintColor={theme.colors.text.hint}
|
|
323
|
-
activeTintColor={theme.colors.primary}
|
|
324
|
-
style={[
|
|
325
|
-
{
|
|
326
|
-
height:
|
|
327
|
-
64 +
|
|
328
|
-
(Platform.OS === 'ios'
|
|
329
|
-
? Math.min(insets.bottom, 21)
|
|
330
|
-
: insets.bottom),
|
|
331
|
-
},
|
|
332
|
-
]}
|
|
333
|
-
/>
|
|
334
|
-
)
|
|
335
|
-
}
|
|
336
|
-
initialRouteName={initialRouteName}
|
|
286
|
+
<DuoBarCompressionProvider
|
|
287
|
+
behavior={compressionBehavior}
|
|
288
|
+
tabCount={tabs.length}
|
|
289
|
+
hasQr={!!floatingButton}
|
|
337
290
|
>
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
291
|
+
<Tab.Navigator
|
|
292
|
+
screenOptions={{
|
|
293
|
+
headerShown: false,
|
|
294
|
+
headerTransparent: true,
|
|
295
|
+
headerBackground: undefined,
|
|
296
|
+
tabBarLabelPosition: 'below-icon',
|
|
297
|
+
tabBarBackground: () => (
|
|
298
|
+
<View
|
|
299
|
+
style={[
|
|
300
|
+
styles.container,
|
|
301
|
+
{
|
|
302
|
+
backgroundColor: theme.colors.background.surface,
|
|
303
|
+
},
|
|
304
|
+
]}
|
|
305
|
+
>
|
|
306
|
+
<Animated.View
|
|
307
|
+
style={[styles.indicatorContainer, indicatorStyle]}
|
|
308
|
+
>
|
|
309
|
+
<View
|
|
310
|
+
style={[
|
|
311
|
+
styles.indicator,
|
|
312
|
+
{ backgroundColor: theme.colors.primary },
|
|
313
|
+
]}
|
|
314
|
+
/>
|
|
315
|
+
</Animated.View>
|
|
316
|
+
</View>
|
|
317
|
+
),
|
|
318
|
+
}}
|
|
319
|
+
tabBar={props =>
|
|
320
|
+
isIPhoneDuo ? (
|
|
321
|
+
<DuoTabBar
|
|
322
|
+
{...props}
|
|
323
|
+
floatingButton={floatingButton}
|
|
324
|
+
inactiveTintColor={theme.colors.text.default}
|
|
325
|
+
activeTintColor={theme.colors.primary}
|
|
326
|
+
/>
|
|
327
|
+
) : (
|
|
328
|
+
<BottomTabBar
|
|
329
|
+
{...props}
|
|
330
|
+
floatingButton={floatingButton}
|
|
331
|
+
inactiveTintColor={theme.colors.text.hint}
|
|
332
|
+
activeTintColor={theme.colors.primary}
|
|
333
|
+
style={[
|
|
334
|
+
{
|
|
335
|
+
height:
|
|
336
|
+
64 +
|
|
337
|
+
(Platform.OS === 'ios'
|
|
338
|
+
? Math.min(insets.bottom, 21)
|
|
339
|
+
: insets.bottom),
|
|
340
|
+
},
|
|
341
|
+
]}
|
|
342
|
+
/>
|
|
343
|
+
)
|
|
346
344
|
}
|
|
345
|
+
initialRouteName={initialRouteName}
|
|
346
|
+
>
|
|
347
|
+
{tabs.map((item, index) => {
|
|
348
|
+
const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
|
|
349
|
+
const isDot =
|
|
350
|
+
typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
|
|
351
|
+
const isNumZero = isNum && Number(item.badgeLabel) === 0;
|
|
347
352
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
width?: number;
|
|
353
|
-
} = {
|
|
354
|
-
marginRight: -8,
|
|
355
|
-
top: -4,
|
|
356
|
-
backgroundColor: Colors.orange_03,
|
|
357
|
-
};
|
|
353
|
+
let badgeLabel = item?.badgeLabel;
|
|
354
|
+
if (isNumZero) {
|
|
355
|
+
badgeLabel = undefined;
|
|
356
|
+
}
|
|
358
357
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
358
|
+
let badgeStyle: {
|
|
359
|
+
marginRight: number;
|
|
360
|
+
top: number;
|
|
361
|
+
backgroundColor: string;
|
|
362
|
+
width?: number;
|
|
363
|
+
} = {
|
|
364
|
+
marginRight: -8,
|
|
362
365
|
top: -4,
|
|
363
|
-
backgroundColor: Colors.red_03,
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
|
-
if (isDot) {
|
|
367
|
-
badgeStyle = {
|
|
368
|
-
marginRight: -2,
|
|
369
|
-
top: -2,
|
|
370
366
|
backgroundColor: Colors.orange_03,
|
|
371
|
-
width: 12,
|
|
372
367
|
};
|
|
373
|
-
}
|
|
374
368
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
alignSelf: 'center',
|
|
412
|
-
fontFamily: exportFontFamily('bold'),
|
|
369
|
+
if (isNum) {
|
|
370
|
+
badgeStyle = {
|
|
371
|
+
marginRight: -4,
|
|
372
|
+
top: -4,
|
|
373
|
+
backgroundColor: Colors.red_03,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (isDot) {
|
|
377
|
+
badgeStyle = {
|
|
378
|
+
marginRight: -2,
|
|
379
|
+
top: -2,
|
|
380
|
+
backgroundColor: Colors.orange_03,
|
|
381
|
+
width: 12,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<Tab.Screen
|
|
387
|
+
key={`BottomTab-${item.name}`}
|
|
388
|
+
name={item.name}
|
|
389
|
+
component={TabScreen}
|
|
390
|
+
initialParams={{ ...item, nested, index }}
|
|
391
|
+
listeners={handler}
|
|
392
|
+
options={{
|
|
393
|
+
tabBarLabel: ({ focused, color }) => {
|
|
394
|
+
return (
|
|
395
|
+
<Text
|
|
396
|
+
numberOfLines={1}
|
|
397
|
+
typography={
|
|
398
|
+
focused ? 'label_xs_medium' : 'description_xs_regular'
|
|
399
|
+
}
|
|
400
|
+
color={color}
|
|
401
|
+
>
|
|
402
|
+
{item.label}
|
|
403
|
+
</Text>
|
|
404
|
+
);
|
|
413
405
|
},
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
406
|
+
title: item.label,
|
|
407
|
+
tabBarStyle: [
|
|
408
|
+
{
|
|
409
|
+
height: 64,
|
|
410
|
+
},
|
|
411
|
+
styles.tabStyle,
|
|
412
|
+
],
|
|
413
|
+
tabBarBadge: badgeLabel,
|
|
414
|
+
tabBarBadgeStyle: [
|
|
415
|
+
{
|
|
416
|
+
borderColor: 'white',
|
|
417
|
+
borderWidth: 2,
|
|
418
|
+
fontSize: 10,
|
|
419
|
+
lineHeight: 14,
|
|
420
|
+
fontWeight: 'bold',
|
|
421
|
+
alignSelf: 'center',
|
|
422
|
+
fontFamily: exportFontFamily('bold'),
|
|
423
|
+
},
|
|
424
|
+
badgeStyle,
|
|
425
|
+
],
|
|
426
|
+
tabBarIcon: ({ color }) => (
|
|
427
|
+
<Icon source={item.icon} color={color} size={28} />
|
|
428
|
+
),
|
|
429
|
+
...item,
|
|
430
|
+
}}
|
|
431
|
+
/>
|
|
432
|
+
);
|
|
433
|
+
})}
|
|
434
|
+
</Tab.Navigator>
|
|
435
|
+
</DuoBarCompressionProvider>
|
|
425
436
|
);
|
|
426
437
|
};
|
|
427
438
|
|
|
@@ -82,8 +82,8 @@ const DuoGlassBackground: React.FC<{
|
|
|
82
82
|
};
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* pink header gradient drawn under the content
|
|
86
|
-
*
|
|
85
|
+
* pink header gradient drawn under the content, fading out on scroll as main's extended
|
|
86
|
+
* header does: header-high for default headers, 52 taller (collapsing) for extended
|
|
87
87
|
*/
|
|
88
88
|
const DuoScrollGradient: React.FC<{
|
|
89
89
|
animatedValue?: AnimatedCompatValue;
|
|
@@ -91,12 +91,15 @@ const DuoScrollGradient: React.FC<{
|
|
|
91
91
|
headerBackground?: string;
|
|
92
92
|
heightHeader: number;
|
|
93
93
|
extended?: boolean;
|
|
94
|
+
// quick return header: the gradient leaves with the content instead of fading
|
|
95
|
+
followScroll?: boolean;
|
|
94
96
|
}> = ({
|
|
95
97
|
animatedValue,
|
|
96
98
|
gradientColor,
|
|
97
99
|
headerBackground,
|
|
98
100
|
heightHeader,
|
|
99
101
|
extended,
|
|
102
|
+
followScroll,
|
|
100
103
|
}) => {
|
|
101
104
|
const { theme } = useContext(ApplicationContext);
|
|
102
105
|
const sharedValue = useNormalizedSharedValue(animatedValue);
|
|
@@ -104,19 +107,21 @@ const DuoScrollGradient: React.FC<{
|
|
|
104
107
|
const image = headerBackground ?? theme.assets?.headerBackground;
|
|
105
108
|
|
|
106
109
|
const scrollStyle = useAnimatedStyle(() => {
|
|
107
|
-
if (
|
|
110
|
+
if (followScroll) {
|
|
108
111
|
return {
|
|
109
|
-
height: heightHeader,
|
|
112
|
+
height: heightHeader + (extended ? EXTENDED_GRADIENT_EXTRA : 0),
|
|
110
113
|
transform: [{ translateY: -Math.max(sharedValue.value, 0) }],
|
|
111
114
|
};
|
|
112
115
|
}
|
|
113
116
|
return {
|
|
114
|
-
height:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
height: extended
|
|
118
|
+
? interpolate(
|
|
119
|
+
sharedValue.value,
|
|
120
|
+
[0, EXTENDED_COLLAPSE_DISTANCE],
|
|
121
|
+
[heightHeader + EXTENDED_GRADIENT_EXTRA, heightHeader],
|
|
122
|
+
Extrapolation.CLAMP,
|
|
123
|
+
)
|
|
124
|
+
: heightHeader,
|
|
120
125
|
opacity: interpolate(
|
|
121
126
|
sharedValue.value,
|
|
122
127
|
[0, EXTENDED_GRADIENT_EXTRA],
|
|
@@ -135,21 +140,18 @@ const DuoScrollGradient: React.FC<{
|
|
|
135
140
|
pointerEvents={'none'}
|
|
136
141
|
style={[styles.gradient, scrollStyle]}
|
|
137
142
|
>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
)}
|
|
151
|
-
</LinearGradient>
|
|
152
|
-
</View>
|
|
143
|
+
<LinearGradient
|
|
144
|
+
colors={[color, color + '00']}
|
|
145
|
+
style={StyleSheet.absoluteFill}
|
|
146
|
+
>
|
|
147
|
+
{!!image && (
|
|
148
|
+
<Image
|
|
149
|
+
style={styles.gradientFill}
|
|
150
|
+
source={{ uri: image }}
|
|
151
|
+
loading={false}
|
|
152
|
+
/>
|
|
153
|
+
)}
|
|
154
|
+
</LinearGradient>
|
|
153
155
|
</Animated.View>
|
|
154
156
|
);
|
|
155
157
|
};
|
|
@@ -166,7 +168,7 @@ const styles = StyleSheet.create({
|
|
|
166
168
|
right: 0,
|
|
167
169
|
overflow: 'hidden',
|
|
168
170
|
},
|
|
169
|
-
//
|
|
171
|
+
// banner image keeps its 375:154 shape, the gradient box clips it
|
|
170
172
|
gradientFill: {
|
|
171
173
|
width: '100%',
|
|
172
174
|
position: 'absolute',
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React, { createContext, useContext } from 'react';
|
|
2
|
+
import { StackHeaderProps, Header } from '@react-navigation/stack';
|
|
3
|
+
import Animated, {
|
|
4
|
+
SharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
} from 'react-native-reanimated';
|
|
7
|
+
|
|
8
|
+
// how far the header is pushed above the top edge
|
|
9
|
+
const DuoHeaderOffsetContext = createContext<SharedValue<number> | undefined>(
|
|
10
|
+
undefined,
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* iPhone Duo header that leaves with the content and comes back when scrolling back
|
|
15
|
+
*/
|
|
16
|
+
const DuoQuickReturnHeader: React.FC<
|
|
17
|
+
StackHeaderProps & { offset: SharedValue<number> }
|
|
18
|
+
> = ({ offset, ...props }) => {
|
|
19
|
+
const style = useAnimatedStyle(() => ({
|
|
20
|
+
transform: [{ translateY: -offset.value }],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<DuoHeaderOffsetContext.Provider value={offset}>
|
|
25
|
+
<Animated.View pointerEvents={'box-none'} style={style}>
|
|
26
|
+
<Header {...props} />
|
|
27
|
+
</Animated.View>
|
|
28
|
+
</DuoHeaderOffsetContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* side bar controls render inside the header; this cancels its offset so they stay put
|
|
34
|
+
*/
|
|
35
|
+
const useSideBarCounterStyle = () => {
|
|
36
|
+
const offset = useContext(DuoHeaderOffsetContext);
|
|
37
|
+
return useAnimatedStyle(() => ({
|
|
38
|
+
transform: [{ translateY: offset ? offset.value : 0 }],
|
|
39
|
+
}));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { DuoQuickReturnHeader, useSideBarCounterStyle };
|
|
@@ -5,7 +5,9 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
|
5
5
|
import { PopupNotify } from '../../Popup';
|
|
6
6
|
import { NavigationButton } from './NavigationButton';
|
|
7
7
|
import { SideBarButton } from './SideBarButton';
|
|
8
|
+
import Animated from 'react-native-reanimated';
|
|
8
9
|
import { useSideBarItemStyle } from '../../Layout/utils';
|
|
10
|
+
import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
13
|
* default header left used for nav
|
|
@@ -20,6 +22,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
|
|
|
20
22
|
const context = useContext<any>(MiniAppContext);
|
|
21
23
|
const { navigator } = useContext(ApplicationContext);
|
|
22
24
|
const sideBarStyle = useSideBarItemStyle('left', 0);
|
|
25
|
+
const counterStyle = useSideBarCounterStyle();
|
|
23
26
|
|
|
24
27
|
const goBack = useCallback(() => {
|
|
25
28
|
const canGoBack = navigator?.ref?.current?.canGoBack?.();
|
|
@@ -78,14 +81,17 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
|
|
|
78
81
|
|
|
79
82
|
if (floating && sideBarStyle) {
|
|
80
83
|
return (
|
|
81
|
-
<View
|
|
84
|
+
<Animated.View
|
|
85
|
+
style={[sideBarStyle, counterStyle]}
|
|
86
|
+
pointerEvents={'box-none'}
|
|
87
|
+
>
|
|
82
88
|
<SideBarButton
|
|
83
89
|
accessibilityLabel="btn_navigation_back"
|
|
84
90
|
icon="arrow-back"
|
|
85
91
|
tintColor={tintColor}
|
|
86
92
|
onPress={goBackSafe}
|
|
87
93
|
/>
|
|
88
|
-
</View>
|
|
94
|
+
</Animated.View>
|
|
89
95
|
);
|
|
90
96
|
}
|
|
91
97
|
|
|
@@ -13,8 +13,12 @@ import { PopupNotify } from '../../Popup';
|
|
|
13
13
|
import { Tool, ToolGroup } from '../types';
|
|
14
14
|
import { Icon } from '../../Icon';
|
|
15
15
|
import { Text } from '../../Text';
|
|
16
|
+
import Animated from 'react-native-reanimated';
|
|
16
17
|
import { useSideBarItemStyle } from '../../Layout/utils';
|
|
18
|
+
import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
|
|
17
19
|
import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
|
|
20
|
+
import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
|
|
21
|
+
import { DuoMetrics } from '../../Layout/utils';
|
|
18
22
|
|
|
19
23
|
const DID_SYNC_NEW_HOME = 'did_sync_new_home';
|
|
20
24
|
|
|
@@ -26,6 +30,7 @@ const HeaderRight: React.FC<any> = props => {
|
|
|
26
30
|
const { headerRight = {}, floating = false } = props;
|
|
27
31
|
const { useOnBoarding = false, buttonOnBoarding, onPress } = headerRight;
|
|
28
32
|
const sideBarStyle = useSideBarItemStyle('right', 1);
|
|
33
|
+
const counterStyle = useSideBarCounterStyle();
|
|
29
34
|
|
|
30
35
|
if (typeof props.headerRight === 'function') {
|
|
31
36
|
return props.headerRight(props);
|
|
@@ -43,13 +48,16 @@ const HeaderRight: React.FC<any> = props => {
|
|
|
43
48
|
|
|
44
49
|
if (floating && sideBarStyle) {
|
|
45
50
|
return (
|
|
46
|
-
<View
|
|
51
|
+
<Animated.View
|
|
52
|
+
style={[sideBarStyle, counterStyle]}
|
|
53
|
+
pointerEvents={'box-none'}
|
|
54
|
+
>
|
|
47
55
|
<HeaderToolkitAction
|
|
48
56
|
{...props.headerRight}
|
|
49
57
|
tintColor={props.tintColor}
|
|
50
58
|
vertical
|
|
51
59
|
/>
|
|
52
|
-
</View>
|
|
60
|
+
</Animated.View>
|
|
53
61
|
);
|
|
54
62
|
}
|
|
55
63
|
|
|
@@ -75,6 +83,8 @@ const HeaderToolkitAction: React.FC<any> = ({
|
|
|
75
83
|
}) => {
|
|
76
84
|
const { navigator } = useContext(ApplicationContext);
|
|
77
85
|
const context = useContext<any>(MiniAppContext);
|
|
86
|
+
const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
|
|
87
|
+
useDuoBarCompression();
|
|
78
88
|
|
|
79
89
|
const [isFavorite, setIsFavorite] = useState<boolean>(false);
|
|
80
90
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -281,6 +291,41 @@ const HeaderToolkitAction: React.FC<any> = ({
|
|
|
281
291
|
? '16_navigation_close_circle'
|
|
282
292
|
: '16_basic_home';
|
|
283
293
|
|
|
294
|
+
const groupCount = useHelpCenter ? 2 : 1;
|
|
295
|
+
// Back slot, the optional shortcut, then the help/home capsule
|
|
296
|
+
const fullToolbarHeight =
|
|
297
|
+
DuoMetrics.controlSize +
|
|
298
|
+
Spacing.M +
|
|
299
|
+
(useShortcut ? DuoMetrics.controlSize + Spacing.M : 0) +
|
|
300
|
+
(groupCount > 1
|
|
301
|
+
? groupCount * DuoMetrics.itemPitch
|
|
302
|
+
: DuoMetrics.controlSize);
|
|
303
|
+
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (vertical) {
|
|
306
|
+
setToolbarHeight?.(fullToolbarHeight);
|
|
307
|
+
}
|
|
308
|
+
}, [vertical, fullToolbarHeight, setToolbarHeight]);
|
|
309
|
+
|
|
310
|
+
// an open tab rail takes this slot for a moment
|
|
311
|
+
if (vertical && toolbarHidden) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// HIG "Toolbar compressed": tools fold into one button; tapping it expands the
|
|
316
|
+
// toolbar and compresses the tab bar instead. Not the useMore icon, to tell them apart
|
|
317
|
+
if (vertical && toolbarCompressed) {
|
|
318
|
+
return (
|
|
319
|
+
<SideBarButton
|
|
320
|
+
accessibilityLabel={'btn_navigation_toolbar'}
|
|
321
|
+
icon={'navigation_menu'}
|
|
322
|
+
tintColor={tintColor}
|
|
323
|
+
showBadge={showBadge}
|
|
324
|
+
onPress={expandToolbar}
|
|
325
|
+
/>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
284
329
|
if (vertical) {
|
|
285
330
|
return (
|
|
286
331
|
<View style={styles.sideBarColumn}>
|
|
@@ -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
|
};
|
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,
|
|
@@ -56,6 +61,7 @@ import {
|
|
|
56
61
|
DuoGlassBackground,
|
|
57
62
|
DuoScrollGradient,
|
|
58
63
|
} from '../Application/Components/DuoGlassBackground';
|
|
64
|
+
import { DuoQuickReturnHeader } from '../Application/Components/DuoQuickReturnHeader';
|
|
59
65
|
import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
|
|
60
66
|
|
|
61
67
|
export interface ScreenProps extends ViewProps {
|
|
@@ -268,6 +274,41 @@ const Screen = forwardRef(
|
|
|
268
274
|
!animatedHeader &&
|
|
269
275
|
!inputSearchProps;
|
|
270
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
|
+
|
|
271
312
|
let keyboardOffset = heightHeader - bottomInset;
|
|
272
313
|
if (
|
|
273
314
|
headerType === 'extended' ||
|
|
@@ -301,6 +342,7 @@ const Screen = forwardRef(
|
|
|
301
342
|
|
|
302
343
|
case 'extended':
|
|
303
344
|
options = {
|
|
345
|
+
...quickReturnOptions,
|
|
304
346
|
headerShown: true,
|
|
305
347
|
headerTransparent: true,
|
|
306
348
|
headerBackground: () =>
|
|
@@ -331,6 +373,7 @@ const Screen = forwardRef(
|
|
|
331
373
|
default:
|
|
332
374
|
if (hasSideBar && !inputSearchProps) {
|
|
333
375
|
options = {
|
|
376
|
+
...quickReturnOptions,
|
|
334
377
|
headerTransparent: true,
|
|
335
378
|
headerTintColor: Colors.black_17,
|
|
336
379
|
headerShown: true,
|
|
@@ -375,7 +418,7 @@ const Screen = forwardRef(
|
|
|
375
418
|
}
|
|
376
419
|
}
|
|
377
420
|
|
|
378
|
-
navigation?.instance?.setOptions(options);
|
|
421
|
+
navigation?.instance?.setOptions({ header: undefined, ...options });
|
|
379
422
|
},
|
|
380
423
|
[
|
|
381
424
|
gradientColor,
|
|
@@ -385,6 +428,7 @@ const Screen = forwardRef(
|
|
|
385
428
|
sharedValue,
|
|
386
429
|
useShadowHeader,
|
|
387
430
|
hasSideBar,
|
|
431
|
+
quickReturnOptions,
|
|
388
432
|
],
|
|
389
433
|
);
|
|
390
434
|
|
|
@@ -400,6 +444,7 @@ const Screen = forwardRef(
|
|
|
400
444
|
}
|
|
401
445
|
|
|
402
446
|
options = {
|
|
447
|
+
...quickReturnOptions,
|
|
403
448
|
headerTintColor: currentTint.current ?? Colors.black_17,
|
|
404
449
|
headerTransparent: true,
|
|
405
450
|
headerBackground: (props: any) =>
|
|
@@ -442,6 +487,7 @@ const Screen = forwardRef(
|
|
|
442
487
|
hasSideBar,
|
|
443
488
|
layoutOffset,
|
|
444
489
|
heightHeader,
|
|
490
|
+
quickReturnOptions,
|
|
445
491
|
],
|
|
446
492
|
);
|
|
447
493
|
|
|
@@ -465,7 +511,7 @@ const Screen = forwardRef(
|
|
|
465
511
|
),
|
|
466
512
|
};
|
|
467
513
|
|
|
468
|
-
navigation?.instance?.setOptions(options);
|
|
514
|
+
navigation?.instance?.setOptions({ header: undefined, ...options });
|
|
469
515
|
}, [navigation?.instance, sharedValue]);
|
|
470
516
|
|
|
471
517
|
/**
|
|
@@ -593,8 +639,36 @@ const Screen = forwardRef(
|
|
|
593
639
|
* handle scroll end
|
|
594
640
|
* @param e
|
|
595
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
|
+
|
|
596
669
|
const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
597
670
|
const offsetY = e.nativeEvent.contentOffset.y;
|
|
671
|
+
snapQuickReturnHeader(offsetY);
|
|
598
672
|
if (inputSearchProps && offsetY < 100 && offsetY > 0) {
|
|
599
673
|
Animated.timing(animatedValue.current, {
|
|
600
674
|
toValue: 0,
|
|
@@ -722,6 +796,7 @@ const Screen = forwardRef(
|
|
|
722
796
|
headerBackground={headerBackground}
|
|
723
797
|
heightHeader={heightHeader}
|
|
724
798
|
extended={headerType === 'extended'}
|
|
799
|
+
followScroll={useQuickReturn}
|
|
725
800
|
/>
|
|
726
801
|
) : (
|
|
727
802
|
<HeaderExtendHeader
|
|
@@ -759,6 +834,7 @@ const Screen = forwardRef(
|
|
|
759
834
|
showsVerticalScrollIndicator={false}
|
|
760
835
|
onScroll={handleScroll}
|
|
761
836
|
onScrollEndDrag={handleScrollEnd}
|
|
837
|
+
onMomentumScrollEnd={handleMomentumScrollEnd}
|
|
762
838
|
scrollEventThrottle={16}
|
|
763
839
|
style={[
|
|
764
840
|
Styles.flex,
|
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
|
|