@momo-kits/foundation 0.167.1-duo.2 → 0.167.1-duo.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Application/BottomSheet.tsx +111 -10
- package/Application/BottomTab/DuoTabBar.tsx +410 -0
- package/Application/BottomTab/index.tsx +28 -17
- package/Application/Components/DuoGlassBackground.tsx +177 -0
- package/Application/Components/OptionalGlassView.ts +11 -0
- package/Application/ModalScreen.tsx +14 -0
- package/Application/device.ts +29 -0
- package/Layout/Screen.tsx +108 -29
- package/Popup/PopupNotify.tsx +31 -20
- package/Popup/PopupPromotion.tsx +20 -8
- package/Popup/utils.ts +32 -0
- package/package.json +1 -1
|
@@ -1,14 +1,24 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
2
8
|
import {
|
|
3
9
|
BackHandler,
|
|
4
10
|
Dimensions,
|
|
11
|
+
Keyboard,
|
|
5
12
|
KeyboardAvoidingView,
|
|
13
|
+
KeyboardEvent,
|
|
14
|
+
LayoutAnimation,
|
|
6
15
|
Modal,
|
|
7
16
|
PanResponder,
|
|
8
17
|
Platform,
|
|
9
18
|
Pressable,
|
|
10
19
|
StyleSheet,
|
|
11
20
|
TouchableOpacity,
|
|
21
|
+
useWindowDimensions,
|
|
12
22
|
View,
|
|
13
23
|
} from 'react-native';
|
|
14
24
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -29,15 +39,31 @@ import Animated, {
|
|
|
29
39
|
} from 'react-native-reanimated';
|
|
30
40
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
31
41
|
import layoutStyles from '../Layout/styles';
|
|
42
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
43
|
+
|
|
44
|
+
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
45
|
+
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
32
46
|
|
|
33
47
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
34
48
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
35
49
|
const context = useContext<any>(MiniAppContext);
|
|
36
|
-
const
|
|
50
|
+
const windowSize = useWindowDimensions();
|
|
51
|
+
const isDuo = isIPhoneDuo();
|
|
52
|
+
const isDuoCentered = isDuo && windowSize.width >= DUO_REGULAR_MIN_WIDTH;
|
|
53
|
+
const isPortrait = windowSize.height > windowSize.width;
|
|
54
|
+
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
55
|
+
const heightDevice = isDuo
|
|
56
|
+
? windowSize.height
|
|
57
|
+
: Dimensions.get('screen').height;
|
|
37
58
|
const action = useRef<undefined | string>(undefined);
|
|
38
59
|
const insets = useSafeAreaInsets();
|
|
39
60
|
const heightHeader = useHeaderHeight();
|
|
40
|
-
const
|
|
61
|
+
const topGap = Math.max(
|
|
62
|
+
getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
63
|
+
Spacing.XL,
|
|
64
|
+
);
|
|
65
|
+
const bottomInset =
|
|
66
|
+
Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
41
67
|
const keyboardOffset = heightHeader - bottomInset;
|
|
42
68
|
|
|
43
69
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -58,6 +84,43 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
58
84
|
}: BottomSheetParams = props.route.params;
|
|
59
85
|
|
|
60
86
|
const translateY = useSharedValue(heightDevice);
|
|
87
|
+
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
88
|
+
|
|
89
|
+
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
90
|
+
// so lift it here and shrink maxHeight by the same amount instead
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!isDuo || !useKeyboardAvoidingView) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const onKeyboardChange = (e: KeyboardEvent) => {
|
|
96
|
+
const overlap = windowSize.height - e.endCoordinates.screenY;
|
|
97
|
+
const lift = Math.max(
|
|
98
|
+
overlap - bottomInset - (keyboardVerticalOffset ?? 0),
|
|
99
|
+
0,
|
|
100
|
+
);
|
|
101
|
+
if (e.duration) {
|
|
102
|
+
LayoutAnimation.configureNext({
|
|
103
|
+
duration: e.duration,
|
|
104
|
+
update: {
|
|
105
|
+
duration: e.duration,
|
|
106
|
+
type: LayoutAnimation.Types[e.easing] || 'keyboard',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
setKeyboardLift(lift);
|
|
111
|
+
};
|
|
112
|
+
const subscriptions = [
|
|
113
|
+
Keyboard.addListener('keyboardWillChangeFrame', onKeyboardChange),
|
|
114
|
+
Keyboard.addListener('keyboardWillHide', onKeyboardChange),
|
|
115
|
+
];
|
|
116
|
+
return () => subscriptions.forEach(sub => sub.remove());
|
|
117
|
+
}, [
|
|
118
|
+
bottomInset,
|
|
119
|
+
isDuo,
|
|
120
|
+
keyboardVerticalOffset,
|
|
121
|
+
useKeyboardAvoidingView,
|
|
122
|
+
windowSize.height,
|
|
123
|
+
]);
|
|
61
124
|
|
|
62
125
|
const openAnimation = useCallback(() => {
|
|
63
126
|
translateY.value = withTiming(0, {
|
|
@@ -248,6 +311,23 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
248
311
|
};
|
|
249
312
|
});
|
|
250
313
|
|
|
314
|
+
// landscape side buttons span the full height, so inset the content; in
|
|
315
|
+
// portrait the side status bar only covers the top corner
|
|
316
|
+
const sideInsetStyle = isDuo &&
|
|
317
|
+
!isDuoCentered &&
|
|
318
|
+
!isPortrait && {
|
|
319
|
+
paddingLeft: insets.left,
|
|
320
|
+
paddingRight: insets.right,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const screen = (
|
|
324
|
+
<Screen
|
|
325
|
+
{...props}
|
|
326
|
+
{...props.route.params}
|
|
327
|
+
onRequestClose={onRequestClose}
|
|
328
|
+
/>
|
|
329
|
+
);
|
|
330
|
+
|
|
251
331
|
return (
|
|
252
332
|
<Container
|
|
253
333
|
transparent
|
|
@@ -264,7 +344,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
264
344
|
android: undefined,
|
|
265
345
|
})}
|
|
266
346
|
keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
|
|
267
|
-
enabled={useKeyboardAvoidingView}
|
|
347
|
+
enabled={useKeyboardAvoidingView && !isDuo}
|
|
268
348
|
contentContainerStyle={styles.container}
|
|
269
349
|
style={styles.container}
|
|
270
350
|
>
|
|
@@ -287,15 +367,34 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
287
367
|
borderTopRightRadius: Radius.M,
|
|
288
368
|
overflow: 'hidden',
|
|
289
369
|
},
|
|
370
|
+
isDuo && {
|
|
371
|
+
maxHeight: windowSize.height - topGap - keyboardLift,
|
|
372
|
+
marginBottom: keyboardLift,
|
|
373
|
+
},
|
|
374
|
+
isDuoCentered && {
|
|
375
|
+
// keeps the portrait width in landscape, like system sheets
|
|
376
|
+
width: Math.min(
|
|
377
|
+
Math.min(windowSize.width, windowSize.height) - Spacing.L * 2,
|
|
378
|
+
windowSize.width -
|
|
379
|
+
Math.max(insets.left, insets.right, Spacing.L) * 2,
|
|
380
|
+
),
|
|
381
|
+
alignSelf: 'center',
|
|
382
|
+
},
|
|
290
383
|
showBaseLineDebug && styles.debugBaseLine,
|
|
291
384
|
]}
|
|
292
385
|
>
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
386
|
+
{isDuo ? (
|
|
387
|
+
// content shrinks so a tall sheet keeps its header and footer
|
|
388
|
+
<View style={[styles.shrink, sideInsetStyle]}>
|
|
389
|
+
{renderHeader()}
|
|
390
|
+
<View style={styles.shrink}>{screen}</View>
|
|
391
|
+
</View>
|
|
392
|
+
) : (
|
|
393
|
+
<>
|
|
394
|
+
{renderHeader()}
|
|
395
|
+
{screen}
|
|
396
|
+
</>
|
|
397
|
+
)}
|
|
299
398
|
{footerComponent && (
|
|
300
399
|
<View
|
|
301
400
|
style={[
|
|
@@ -304,6 +403,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
304
403
|
paddingBottom: Spacing.S,
|
|
305
404
|
backgroundColor: theme.colors.background.surface,
|
|
306
405
|
},
|
|
406
|
+
sideInsetStyle,
|
|
307
407
|
]}
|
|
308
408
|
>
|
|
309
409
|
{footerComponent}
|
|
@@ -353,6 +453,7 @@ const styles = StyleSheet.create({
|
|
|
353
453
|
justifyContent: 'center',
|
|
354
454
|
marginHorizontal: Spacing.M,
|
|
355
455
|
},
|
|
456
|
+
shrink: { flexShrink: 1 },
|
|
356
457
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
357
458
|
});
|
|
358
459
|
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
|
2
|
+
import { CommonActions, Route } from '@react-navigation/native';
|
|
3
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
LayoutChangeEvent,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
TouchableOpacity,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import Animated, {
|
|
11
|
+
useAnimatedStyle,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
withSpring,
|
|
14
|
+
} from 'react-native-reanimated';
|
|
15
|
+
import { Colors, Radius, Spacing } from '../../Consts';
|
|
16
|
+
import { ApplicationContext } from '../../Context';
|
|
17
|
+
import { Icon } from '../../Icon';
|
|
18
|
+
import { DuoMetrics, useScreenLayout } from '../../Layout/utils';
|
|
19
|
+
import { Text } from '../../Text';
|
|
20
|
+
import { GlassView } from '../Components/OptionalGlassView';
|
|
21
|
+
import { FloatingButtonProps } from '../types';
|
|
22
|
+
import { TabBarIcon } from './TabBarIcon';
|
|
23
|
+
|
|
24
|
+
type Props = BottomTabBarProps & {
|
|
25
|
+
activeTintColor: string;
|
|
26
|
+
inactiveTintColor: string;
|
|
27
|
+
floatingButton?: FloatingButtonProps;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ITEM_SIZE = DuoMetrics.controlSize;
|
|
31
|
+
const ICON_SIZE = 28;
|
|
32
|
+
const QR_ICON_SIZE = 24;
|
|
33
|
+
const RAIL_PADDING = 6;
|
|
34
|
+
const RAIL_GAP = Spacing.XXS;
|
|
35
|
+
// selection bubble overflows the 48 rail cell by 5 above and below (Figma MT2)
|
|
36
|
+
const RAIL_BUBBLE_WIDTH = 44;
|
|
37
|
+
const RAIL_BUBBLE_HEIGHT = 58;
|
|
38
|
+
const FLOATING_HEIGHT = 62;
|
|
39
|
+
const FLOATING_PADDING = Spacing.XS;
|
|
40
|
+
const FLOATING_QR_SIZE = 44;
|
|
41
|
+
// floating bar spans 6 of the 8 portrait grid columns, like the content
|
|
42
|
+
const GRID_COLUMNS = 8;
|
|
43
|
+
const GRID_SPAN = 6;
|
|
44
|
+
const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* space tab content leaves under the Duo floating tab bar, 0 elsewhere
|
|
48
|
+
*/
|
|
49
|
+
const useDuoTabBarInset = () => {
|
|
50
|
+
const { isIPhoneDuo, sideBar, insets } = useScreenLayout();
|
|
51
|
+
return isIPhoneDuo && !sideBar
|
|
52
|
+
? FLOATING_HEIGHT + Spacing.M + insets.bottom
|
|
53
|
+
: 0;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const gridSpanWidth = (width: number) => {
|
|
57
|
+
const columnWidth =
|
|
58
|
+
(width - 2 * Spacing.M - (GRID_COLUMNS - 1) * Spacing.M) / GRID_COLUMNS;
|
|
59
|
+
return columnWidth * GRID_SPAN + (GRID_SPAN - 1) * Spacing.M;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const BarMaterial: React.FC<{ radius: number }> = ({ radius }) => {
|
|
63
|
+
const { theme } = useContext(ApplicationContext);
|
|
64
|
+
if (GlassView) {
|
|
65
|
+
return (
|
|
66
|
+
<GlassView
|
|
67
|
+
pointerEvents={'none'}
|
|
68
|
+
effect={'glass'}
|
|
69
|
+
cornerRadius={radius}
|
|
70
|
+
useShadow
|
|
71
|
+
style={StyleSheet.absoluteFill}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return (
|
|
76
|
+
<View
|
|
77
|
+
pointerEvents={'none'}
|
|
78
|
+
style={[
|
|
79
|
+
StyleSheet.absoluteFill,
|
|
80
|
+
styles.fallbackMaterial,
|
|
81
|
+
{
|
|
82
|
+
borderRadius: radius,
|
|
83
|
+
backgroundColor: theme.colors.background.surface,
|
|
84
|
+
borderColor: theme.colors.border.default,
|
|
85
|
+
},
|
|
86
|
+
]}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const getTabItems = ({
|
|
92
|
+
state,
|
|
93
|
+
descriptors,
|
|
94
|
+
navigation,
|
|
95
|
+
activeTintColor,
|
|
96
|
+
inactiveTintColor,
|
|
97
|
+
}: Props) => {
|
|
98
|
+
return state.routes.map((route: Route<string>, index: number) => {
|
|
99
|
+
const { options } = descriptors[route.key];
|
|
100
|
+
const focused = index === state.index;
|
|
101
|
+
const label = options.title ?? route.name;
|
|
102
|
+
|
|
103
|
+
const onPress = () => {
|
|
104
|
+
const event = navigation.emit({
|
|
105
|
+
type: 'tabPress',
|
|
106
|
+
target: route.key,
|
|
107
|
+
canPreventDefault: true,
|
|
108
|
+
});
|
|
109
|
+
if (!focused && !event.defaultPrevented) {
|
|
110
|
+
navigation.dispatch({
|
|
111
|
+
...CommonActions.navigate(route.name),
|
|
112
|
+
target: state.key,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const onLongPress = () => {
|
|
118
|
+
navigation.emit({ type: 'tabLongPress', target: route.key });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const icon = options.tabBarIcon && (
|
|
122
|
+
<TabBarIcon
|
|
123
|
+
route={route}
|
|
124
|
+
badge={options.tabBarBadge}
|
|
125
|
+
badgeStyle={options.tabBarBadgeStyle}
|
|
126
|
+
activeOpacity={focused ? 1 : 0}
|
|
127
|
+
inactiveOpacity={focused ? 0 : 1}
|
|
128
|
+
activeTintColor={activeTintColor}
|
|
129
|
+
inactiveTintColor={inactiveTintColor}
|
|
130
|
+
renderIcon={options.tabBarIcon}
|
|
131
|
+
style={styles.icon}
|
|
132
|
+
/>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { route, focused, label, onPress, onLongPress, icon };
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
|
|
140
|
+
icon,
|
|
141
|
+
size,
|
|
142
|
+
}) => {
|
|
143
|
+
if (typeof icon === 'string') {
|
|
144
|
+
return <Icon source={icon} size={size} color={Colors.black_01} />;
|
|
145
|
+
}
|
|
146
|
+
return <>{icon}</>;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* vertical bar anchored to the bottom of the Duo side bar, QR button below it (Figma MT1-C/E)
|
|
151
|
+
*/
|
|
152
|
+
const DuoRailTabBar: React.FC<Props> = props => {
|
|
153
|
+
const { theme } = useContext(ApplicationContext);
|
|
154
|
+
const { sideBar } = useScreenLayout();
|
|
155
|
+
const { state, floatingButton } = props;
|
|
156
|
+
const items = getTabItems(props);
|
|
157
|
+
const bubbleOffset = useSharedValue(state.index * (ITEM_SIZE + RAIL_GAP));
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
bubbleOffset.value = withSpring(
|
|
161
|
+
state.index * (ITEM_SIZE + RAIL_GAP),
|
|
162
|
+
SPRING,
|
|
163
|
+
);
|
|
164
|
+
}, [state.index, bubbleOffset]);
|
|
165
|
+
|
|
166
|
+
const bubbleStyle = useAnimatedStyle(() => ({
|
|
167
|
+
transform: [{ translateY: bubbleOffset.value }],
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
if (!sideBar) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<View
|
|
176
|
+
pointerEvents={'box-none'}
|
|
177
|
+
style={[
|
|
178
|
+
styles.railColumn,
|
|
179
|
+
{ [sideBar.edge]: 0, width: sideBar.width },
|
|
180
|
+
sideBar.edge === 'right'
|
|
181
|
+
? { alignItems: 'flex-start', paddingLeft: Spacing.M }
|
|
182
|
+
: { alignItems: 'flex-end', paddingRight: Spacing.M },
|
|
183
|
+
]}
|
|
184
|
+
>
|
|
185
|
+
<View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
|
|
186
|
+
<BarMaterial radius={Radius.XL} />
|
|
187
|
+
<Animated.View
|
|
188
|
+
pointerEvents={'none'}
|
|
189
|
+
style={[
|
|
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
|
+
)}
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* floating labelled bar for the unfolded portrait display, QR in the middle cell (Figma MT1-M)
|
|
225
|
+
*/
|
|
226
|
+
const DuoFloatingTabBar: React.FC<Props> = props => {
|
|
227
|
+
const { theme } = useContext(ApplicationContext);
|
|
228
|
+
const { width, insets } = useScreenLayout();
|
|
229
|
+
const { state, floatingButton, activeTintColor, inactiveTintColor } = props;
|
|
230
|
+
const items = getTabItems(props);
|
|
231
|
+
const [rowWidth, setRowWidth] = useState(0);
|
|
232
|
+
const barWidth = gridSpanWidth(width);
|
|
233
|
+
|
|
234
|
+
const useQr = !!floatingButton && state.routes.length % 2 === 0;
|
|
235
|
+
const middle = state.routes.length / 2;
|
|
236
|
+
const cellWidth = rowWidth / (state.routes.length + (useQr ? 1 : 0));
|
|
237
|
+
const visualIndex =
|
|
238
|
+
useQr && state.index >= middle ? state.index + 1 : state.index;
|
|
239
|
+
|
|
240
|
+
const bubbleOffset = useSharedValue(0);
|
|
241
|
+
const measured = useRef(false);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const target = visualIndex * cellWidth;
|
|
245
|
+
// snap on first layout so the bubble doesn't slide in from the left edge
|
|
246
|
+
bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
|
|
247
|
+
measured.current = cellWidth > 0;
|
|
248
|
+
}, [visualIndex, cellWidth, bubbleOffset]);
|
|
249
|
+
|
|
250
|
+
const bubbleStyle = useAnimatedStyle(() => ({
|
|
251
|
+
transform: [{ translateX: bubbleOffset.value }],
|
|
252
|
+
}));
|
|
253
|
+
|
|
254
|
+
const onRowLayout = (e: LayoutChangeEvent) =>
|
|
255
|
+
setRowWidth(e.nativeEvent.layout.width);
|
|
256
|
+
|
|
257
|
+
const cells = items.map(item => (
|
|
258
|
+
<TouchableOpacity
|
|
259
|
+
key={item.route.key}
|
|
260
|
+
accessibilityRole={'tab'}
|
|
261
|
+
accessibilityLabel={item.label}
|
|
262
|
+
accessibilityState={{ selected: item.focused }}
|
|
263
|
+
onPress={item.onPress}
|
|
264
|
+
onLongPress={item.onLongPress}
|
|
265
|
+
style={styles.floatingItem}
|
|
266
|
+
>
|
|
267
|
+
{item.icon}
|
|
268
|
+
<Text
|
|
269
|
+
numberOfLines={1}
|
|
270
|
+
typography={'label_xs_medium'}
|
|
271
|
+
color={item.focused ? activeTintColor : inactiveTintColor}
|
|
272
|
+
>
|
|
273
|
+
{item.label}
|
|
274
|
+
</Text>
|
|
275
|
+
</TouchableOpacity>
|
|
276
|
+
));
|
|
277
|
+
|
|
278
|
+
if (useQr && floatingButton) {
|
|
279
|
+
cells.splice(
|
|
280
|
+
middle,
|
|
281
|
+
0,
|
|
282
|
+
<View key={'duo_tab_qr'} style={styles.floatingItem}>
|
|
283
|
+
<TouchableOpacity
|
|
284
|
+
accessibilityRole={'button'}
|
|
285
|
+
accessibilityLabel={floatingButton.label}
|
|
286
|
+
onPress={floatingButton.onPress}
|
|
287
|
+
style={[styles.floatingQr, { backgroundColor: theme.colors.primary }]}
|
|
288
|
+
>
|
|
289
|
+
<QrIcon icon={floatingButton.icon} size={QR_ICON_SIZE} />
|
|
290
|
+
</TouchableOpacity>
|
|
291
|
+
</View>,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<View
|
|
297
|
+
style={[
|
|
298
|
+
styles.floating,
|
|
299
|
+
{
|
|
300
|
+
width: barWidth,
|
|
301
|
+
left: (width - barWidth) / 2,
|
|
302
|
+
bottom: insets.bottom + Spacing.M,
|
|
303
|
+
},
|
|
304
|
+
]}
|
|
305
|
+
accessibilityLabel={'bottom_tab_bar'}
|
|
306
|
+
>
|
|
307
|
+
<BarMaterial radius={FLOATING_HEIGHT / 2} />
|
|
308
|
+
<View style={styles.floatingRow} onLayout={onRowLayout}>
|
|
309
|
+
{rowWidth > 0 && (
|
|
310
|
+
<Animated.View
|
|
311
|
+
pointerEvents={'none'}
|
|
312
|
+
style={[
|
|
313
|
+
styles.floatingBubble,
|
|
314
|
+
{
|
|
315
|
+
width: cellWidth,
|
|
316
|
+
backgroundColor: theme.colors.background.selected,
|
|
317
|
+
},
|
|
318
|
+
bubbleStyle,
|
|
319
|
+
]}
|
|
320
|
+
/>
|
|
321
|
+
)}
|
|
322
|
+
{cells}
|
|
323
|
+
</View>
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* iPhone Duo tab bar: glass rail in the side bar, or a floating glass bar when unfolded portrait
|
|
330
|
+
*/
|
|
331
|
+
const DuoTabBar: React.FC<Props> = props => {
|
|
332
|
+
const { sideBar } = useScreenLayout();
|
|
333
|
+
return sideBar ? (
|
|
334
|
+
<DuoRailTabBar {...props} />
|
|
335
|
+
) : (
|
|
336
|
+
<DuoFloatingTabBar {...props} />
|
|
337
|
+
);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const styles = StyleSheet.create({
|
|
341
|
+
fallbackMaterial: {
|
|
342
|
+
borderWidth: 1,
|
|
343
|
+
},
|
|
344
|
+
icon: {
|
|
345
|
+
width: ICON_SIZE,
|
|
346
|
+
height: ICON_SIZE,
|
|
347
|
+
},
|
|
348
|
+
railColumn: {
|
|
349
|
+
position: 'absolute',
|
|
350
|
+
bottom: Spacing.XL,
|
|
351
|
+
gap: Spacing.M,
|
|
352
|
+
},
|
|
353
|
+
rail: {
|
|
354
|
+
width: ITEM_SIZE,
|
|
355
|
+
paddingVertical: RAIL_PADDING,
|
|
356
|
+
gap: RAIL_GAP,
|
|
357
|
+
},
|
|
358
|
+
railBubble: {
|
|
359
|
+
position: 'absolute',
|
|
360
|
+
top: RAIL_PADDING - (RAIL_BUBBLE_HEIGHT - ITEM_SIZE) / 2,
|
|
361
|
+
left: (ITEM_SIZE - RAIL_BUBBLE_WIDTH) / 2,
|
|
362
|
+
width: RAIL_BUBBLE_WIDTH,
|
|
363
|
+
height: RAIL_BUBBLE_HEIGHT,
|
|
364
|
+
borderRadius: RAIL_BUBBLE_WIDTH / 2,
|
|
365
|
+
},
|
|
366
|
+
railItem: {
|
|
367
|
+
width: ITEM_SIZE,
|
|
368
|
+
height: ITEM_SIZE,
|
|
369
|
+
alignItems: 'center',
|
|
370
|
+
justifyContent: 'center',
|
|
371
|
+
},
|
|
372
|
+
railQr: {
|
|
373
|
+
width: ITEM_SIZE,
|
|
374
|
+
height: ITEM_SIZE,
|
|
375
|
+
borderRadius: ITEM_SIZE / 2,
|
|
376
|
+
alignItems: 'center',
|
|
377
|
+
justifyContent: 'center',
|
|
378
|
+
},
|
|
379
|
+
floating: {
|
|
380
|
+
position: 'absolute',
|
|
381
|
+
height: FLOATING_HEIGHT,
|
|
382
|
+
padding: FLOATING_PADDING,
|
|
383
|
+
},
|
|
384
|
+
floatingRow: {
|
|
385
|
+
flex: 1,
|
|
386
|
+
flexDirection: 'row',
|
|
387
|
+
},
|
|
388
|
+
floatingBubble: {
|
|
389
|
+
position: 'absolute',
|
|
390
|
+
top: 0,
|
|
391
|
+
bottom: 0,
|
|
392
|
+
left: 0,
|
|
393
|
+
borderRadius: (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2,
|
|
394
|
+
},
|
|
395
|
+
floatingItem: {
|
|
396
|
+
flex: 1,
|
|
397
|
+
alignItems: 'center',
|
|
398
|
+
justifyContent: 'center',
|
|
399
|
+
gap: Spacing.XXS,
|
|
400
|
+
},
|
|
401
|
+
floatingQr: {
|
|
402
|
+
width: FLOATING_QR_SIZE,
|
|
403
|
+
height: FLOATING_QR_SIZE,
|
|
404
|
+
borderRadius: FLOATING_QR_SIZE / 2,
|
|
405
|
+
alignItems: 'center',
|
|
406
|
+
justifyContent: 'center',
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
export { DuoTabBar, useDuoTabBarInset };
|
|
@@ -27,6 +27,7 @@ import StackScreen from '../StackScreen';
|
|
|
27
27
|
import { BottomTabProps } from '../types';
|
|
28
28
|
import { getOptions, getStackOptions, useAppState } from '../utils';
|
|
29
29
|
import BottomTabBar from './BottomTabBar';
|
|
30
|
+
import { DuoTabBar } from './DuoTabBar';
|
|
30
31
|
import { ApplicationContext, MiniAppContext } from '../../Context';
|
|
31
32
|
import { useScreenLayout } from '../../Layout';
|
|
32
33
|
|
|
@@ -206,6 +207,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
206
207
|
floatingButton,
|
|
207
208
|
}) => {
|
|
208
209
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
210
|
+
const { isIPhoneDuo } = useScreenLayout();
|
|
209
211
|
const dimensions = useWindowDimensions();
|
|
210
212
|
const insets = useSafeAreaInsets();
|
|
211
213
|
const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
|
|
@@ -305,23 +307,32 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
305
307
|
</View>
|
|
306
308
|
),
|
|
307
309
|
}}
|
|
308
|
-
tabBar={props =>
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
}
|
|
325
336
|
initialRouteName={initialRouteName}
|
|
326
337
|
>
|
|
327
338
|
{tabs.map((item, index) => {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import React, { useContext, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import LinearGradient from 'react-native-linear-gradient';
|
|
4
|
+
import Animated, {
|
|
5
|
+
Extrapolation,
|
|
6
|
+
interpolate,
|
|
7
|
+
runOnJS,
|
|
8
|
+
useAnimatedReaction,
|
|
9
|
+
useAnimatedStyle,
|
|
10
|
+
} from 'react-native-reanimated';
|
|
11
|
+
import { ApplicationContext } from '../../Context';
|
|
12
|
+
import { Shadow } from '../../Consts';
|
|
13
|
+
import { Image } from '../../Image';
|
|
14
|
+
import { AnimatedCompatValue, useNormalizedSharedValue } from '../utils';
|
|
15
|
+
import { GlassView } from './OptionalGlassView';
|
|
16
|
+
|
|
17
|
+
// extended headers run their gradient this far below the header, as off Duo
|
|
18
|
+
const EXTENDED_GRADIENT_EXTRA = 52;
|
|
19
|
+
// scroll distance over which the extended gradient collapses to the header
|
|
20
|
+
const EXTENDED_COLLAPSE_DISTANCE = 100;
|
|
21
|
+
// scroll distance over which the bar material fades in
|
|
22
|
+
const FADE_DISTANCE = 16;
|
|
23
|
+
const TRANSLUCENT_ALPHA = 'CC';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* iPhone Duo header background: glass running edge to edge, behind the status bar
|
|
27
|
+
* column too, shown once content scrolls under it
|
|
28
|
+
*/
|
|
29
|
+
const DuoGlassBackground: React.FC<{
|
|
30
|
+
animatedValue?: AnimatedCompatValue;
|
|
31
|
+
// scroll offset where the bar starts to show, e.g. below an animated banner
|
|
32
|
+
fadeStart?: number;
|
|
33
|
+
}> = ({ animatedValue, fadeStart = 0 }) => {
|
|
34
|
+
const { theme } = useContext(ApplicationContext);
|
|
35
|
+
const sharedValue = useNormalizedSharedValue(animatedValue);
|
|
36
|
+
const [scrolled, setScrolled] = useState(false);
|
|
37
|
+
|
|
38
|
+
useAnimatedReaction(
|
|
39
|
+
() => sharedValue.value > fadeStart,
|
|
40
|
+
(isScrolled, wasScrolled) => {
|
|
41
|
+
if (GlassView && isScrolled !== wasScrolled) {
|
|
42
|
+
runOnJS(setScrolled)(isScrolled);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
[fadeStart],
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const fadeStyle = useAnimatedStyle(() => ({
|
|
49
|
+
opacity: interpolate(
|
|
50
|
+
sharedValue.value,
|
|
51
|
+
[fadeStart, fadeStart + FADE_DISTANCE],
|
|
52
|
+
[0, 1],
|
|
53
|
+
Extrapolation.CLAMP,
|
|
54
|
+
),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<View pointerEvents={'none'} style={StyleSheet.absoluteFill}>
|
|
59
|
+
{GlassView ? (
|
|
60
|
+
// UIVisualEffectView breaks under alpha, so glass toggles its effect instead
|
|
61
|
+
<GlassView
|
|
62
|
+
effect={scrolled ? 'glass' : 'none'}
|
|
63
|
+
useShadow
|
|
64
|
+
style={StyleSheet.absoluteFill}
|
|
65
|
+
/>
|
|
66
|
+
) : (
|
|
67
|
+
<Animated.View
|
|
68
|
+
style={[
|
|
69
|
+
StyleSheet.absoluteFill,
|
|
70
|
+
styles.translucentBar,
|
|
71
|
+
{
|
|
72
|
+
backgroundColor:
|
|
73
|
+
theme.colors.background.surface + TRANSLUCENT_ALPHA,
|
|
74
|
+
borderBottomColor: theme.colors.border.default,
|
|
75
|
+
},
|
|
76
|
+
fadeStyle,
|
|
77
|
+
]}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
</View>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* pink header gradient drawn under the content: header-high for default headers
|
|
86
|
+
* (scrolls away), 52 taller for extended ones (collapses and fades, as off Duo)
|
|
87
|
+
*/
|
|
88
|
+
const DuoScrollGradient: React.FC<{
|
|
89
|
+
animatedValue?: AnimatedCompatValue;
|
|
90
|
+
gradientColor?: string;
|
|
91
|
+
headerBackground?: string;
|
|
92
|
+
heightHeader: number;
|
|
93
|
+
extended?: boolean;
|
|
94
|
+
}> = ({
|
|
95
|
+
animatedValue,
|
|
96
|
+
gradientColor,
|
|
97
|
+
headerBackground,
|
|
98
|
+
heightHeader,
|
|
99
|
+
extended,
|
|
100
|
+
}) => {
|
|
101
|
+
const { theme } = useContext(ApplicationContext);
|
|
102
|
+
const sharedValue = useNormalizedSharedValue(animatedValue);
|
|
103
|
+
const color = gradientColor ?? theme.colors.gradient;
|
|
104
|
+
const image = headerBackground ?? theme.assets?.headerBackground;
|
|
105
|
+
|
|
106
|
+
const scrollStyle = useAnimatedStyle(() => {
|
|
107
|
+
if (!extended) {
|
|
108
|
+
return {
|
|
109
|
+
height: heightHeader,
|
|
110
|
+
transform: [{ translateY: -Math.max(sharedValue.value, 0) }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
height: interpolate(
|
|
115
|
+
sharedValue.value,
|
|
116
|
+
[0, EXTENDED_COLLAPSE_DISTANCE],
|
|
117
|
+
[heightHeader + EXTENDED_GRADIENT_EXTRA, heightHeader],
|
|
118
|
+
Extrapolation.CLAMP,
|
|
119
|
+
),
|
|
120
|
+
opacity: interpolate(
|
|
121
|
+
sharedValue.value,
|
|
122
|
+
[0, EXTENDED_GRADIENT_EXTRA],
|
|
123
|
+
[1, 0],
|
|
124
|
+
Extrapolation.CLAMP,
|
|
125
|
+
),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (!color) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Animated.View
|
|
135
|
+
pointerEvents={'none'}
|
|
136
|
+
style={[styles.gradient, scrollStyle]}
|
|
137
|
+
>
|
|
138
|
+
{/* extended squeezes the whole fade into its box like main; default clips it */}
|
|
139
|
+
<View style={extended ? StyleSheet.absoluteFill : styles.gradientFill}>
|
|
140
|
+
<LinearGradient
|
|
141
|
+
colors={[color, color + '00']}
|
|
142
|
+
style={StyleSheet.absoluteFill}
|
|
143
|
+
>
|
|
144
|
+
{!!image && (
|
|
145
|
+
<Image
|
|
146
|
+
style={styles.gradientFill}
|
|
147
|
+
source={{ uri: image }}
|
|
148
|
+
loading={false}
|
|
149
|
+
/>
|
|
150
|
+
)}
|
|
151
|
+
</LinearGradient>
|
|
152
|
+
</View>
|
|
153
|
+
</Animated.View>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const styles = StyleSheet.create({
|
|
158
|
+
translucentBar: {
|
|
159
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
160
|
+
...Shadow.Light,
|
|
161
|
+
},
|
|
162
|
+
gradient: {
|
|
163
|
+
position: 'absolute',
|
|
164
|
+
top: 0,
|
|
165
|
+
left: 0,
|
|
166
|
+
right: 0,
|
|
167
|
+
overflow: 'hidden',
|
|
168
|
+
},
|
|
169
|
+
// default header gradient keeps the off-Duo 375:154 shape, the header clips it
|
|
170
|
+
gradientFill: {
|
|
171
|
+
width: '100%',
|
|
172
|
+
position: 'absolute',
|
|
173
|
+
aspectRatio: 375 / 154,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
export { DuoGlassBackground, DuoScrollGradient };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
|
|
3
|
+
// native glass is optional so apps without @momo-kits/glass-view still bundle
|
|
4
|
+
let GlassView: React.ComponentType<any> | undefined;
|
|
5
|
+
try {
|
|
6
|
+
GlassView = require('@momo-kits/glass-view').GlassView;
|
|
7
|
+
} catch {
|
|
8
|
+
GlassView = undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export { GlassView };
|
|
@@ -8,14 +8,17 @@ import {
|
|
|
8
8
|
Platform,
|
|
9
9
|
Pressable,
|
|
10
10
|
StyleSheet,
|
|
11
|
+
useWindowDimensions,
|
|
11
12
|
View,
|
|
12
13
|
} from 'react-native';
|
|
14
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
13
15
|
|
|
14
16
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
15
17
|
import { Styles } from '../Consts';
|
|
16
18
|
import Navigation from './Navigation';
|
|
17
19
|
import { ModalParams } from './types';
|
|
18
20
|
import BottomSheet from './BottomSheet';
|
|
21
|
+
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
19
22
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
20
23
|
|
|
21
24
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -37,6 +40,8 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
37
40
|
title,
|
|
38
41
|
} = props.route.params;
|
|
39
42
|
const Component = useRef(screen).current;
|
|
43
|
+
const insets = useSafeAreaInsets();
|
|
44
|
+
const windowSize = useWindowDimensions();
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -147,6 +152,15 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
147
152
|
opacity,
|
|
148
153
|
transform: [{ scale }],
|
|
149
154
|
},
|
|
155
|
+
// center below the side status bar and above the home indicator
|
|
156
|
+
isIPhoneDuo() && {
|
|
157
|
+
marginTop: getDuoTopInset(
|
|
158
|
+
insets,
|
|
159
|
+
windowSize.width,
|
|
160
|
+
windowSize.height,
|
|
161
|
+
),
|
|
162
|
+
marginBottom: insets.bottom,
|
|
163
|
+
},
|
|
150
164
|
modalStyle,
|
|
151
165
|
]}
|
|
152
166
|
>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
2
|
+
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
// widest short side of any single-display iPhone
|
|
5
|
+
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
6
|
+
|
|
7
|
+
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
8
|
+
// still matches when Split View narrows the window
|
|
9
|
+
const isIPhoneDuo = () => {
|
|
10
|
+
if (Platform.OS !== 'ios' || Platform.isPad) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
const shortSide = (size: ScaledSize) => Math.min(size.width, size.height);
|
|
14
|
+
return (
|
|
15
|
+
Math.max(
|
|
16
|
+
shortSide(Dimensions.get('screen')),
|
|
17
|
+
shortSide(Dimensions.get('window')),
|
|
18
|
+
) > IPHONE_MAX_SHORT_SIDE
|
|
19
|
+
);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// portrait side status bar is about twice as tall as its inset is wide
|
|
23
|
+
const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
|
|
24
|
+
Math.max(
|
|
25
|
+
insets.top,
|
|
26
|
+
height > width ? Math.max(insets.left, insets.right) * 2 : 0,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
export { isIPhoneDuo, getDuoTopInset };
|
package/Layout/Screen.tsx
CHANGED
|
@@ -52,6 +52,11 @@ import {
|
|
|
52
52
|
HeaderTitle,
|
|
53
53
|
SearchHeader,
|
|
54
54
|
} from '../Application';
|
|
55
|
+
import {
|
|
56
|
+
DuoGlassBackground,
|
|
57
|
+
DuoScrollGradient,
|
|
58
|
+
} from '../Application/Components/DuoGlassBackground';
|
|
59
|
+
import { useDuoTabBarInset } from '../Application/BottomTab/DuoTabBar';
|
|
55
60
|
|
|
56
61
|
export interface ScreenProps extends ViewProps {
|
|
57
62
|
/**
|
|
@@ -247,13 +252,29 @@ const Screen = forwardRef(
|
|
|
247
252
|
const currentTint = useRef<string | undefined>(undefined);
|
|
248
253
|
|
|
249
254
|
const isTab = !!screen?.bottomTab;
|
|
255
|
+
// Duo floating tab bar overlays the tab content instead of taking layout space
|
|
256
|
+
const duoTabInset = useDuoTabBarInset();
|
|
250
257
|
|
|
251
258
|
let handleScroll;
|
|
252
259
|
let Component: any = View;
|
|
253
260
|
|
|
254
|
-
const bottomInset =
|
|
261
|
+
const bottomInset =
|
|
262
|
+
Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
|
|
263
|
+
const hasSideBar = !!layout.sideBar;
|
|
264
|
+
// iPhone Duo: content scrolls under a glass header instead of starting below it
|
|
265
|
+
const useDuoGlass =
|
|
266
|
+
hasSideBar &&
|
|
267
|
+
(headerType === 'default' || headerType === 'extended') &&
|
|
268
|
+
!animatedHeader &&
|
|
269
|
+
!inputSearchProps;
|
|
270
|
+
|
|
255
271
|
let keyboardOffset = heightHeader - bottomInset;
|
|
256
|
-
if (
|
|
272
|
+
if (
|
|
273
|
+
headerType === 'extended' ||
|
|
274
|
+
animatedHeader ||
|
|
275
|
+
inputSearchProps ||
|
|
276
|
+
useDuoGlass
|
|
277
|
+
) {
|
|
257
278
|
keyboardOffset = -bottomInset;
|
|
258
279
|
}
|
|
259
280
|
|
|
@@ -282,7 +303,10 @@ const Screen = forwardRef(
|
|
|
282
303
|
options = {
|
|
283
304
|
headerShown: true,
|
|
284
305
|
headerTransparent: true,
|
|
285
|
-
headerBackground: () =>
|
|
306
|
+
headerBackground: () =>
|
|
307
|
+
hasSideBar ? (
|
|
308
|
+
<DuoGlassBackground animatedValue={sharedValue} />
|
|
309
|
+
) : null,
|
|
286
310
|
headerTitle: (props: any) => <HeaderTitle {...props} />,
|
|
287
311
|
};
|
|
288
312
|
if (inputSearchProps) {
|
|
@@ -305,6 +329,18 @@ const Screen = forwardRef(
|
|
|
305
329
|
break;
|
|
306
330
|
|
|
307
331
|
default:
|
|
332
|
+
if (hasSideBar && !inputSearchProps) {
|
|
333
|
+
options = {
|
|
334
|
+
headerTransparent: true,
|
|
335
|
+
headerTintColor: Colors.black_17,
|
|
336
|
+
headerShown: true,
|
|
337
|
+
headerBackground: () => (
|
|
338
|
+
<DuoGlassBackground animatedValue={sharedValue} />
|
|
339
|
+
),
|
|
340
|
+
headerTitle: (props: any) => <HeaderTitle {...props} />,
|
|
341
|
+
};
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
308
344
|
options = {
|
|
309
345
|
headerTransparent: false,
|
|
310
346
|
headerTintColor: Colors.black_17,
|
|
@@ -348,6 +384,7 @@ const Screen = forwardRef(
|
|
|
348
384
|
navigation?.instance,
|
|
349
385
|
sharedValue,
|
|
350
386
|
useShadowHeader,
|
|
387
|
+
hasSideBar,
|
|
351
388
|
],
|
|
352
389
|
);
|
|
353
390
|
|
|
@@ -365,16 +402,23 @@ const Screen = forwardRef(
|
|
|
365
402
|
options = {
|
|
366
403
|
headerTintColor: currentTint.current ?? Colors.black_17,
|
|
367
404
|
headerTransparent: true,
|
|
368
|
-
headerBackground: (props: any) =>
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
405
|
+
headerBackground: (props: any) =>
|
|
406
|
+
hasSideBar ? (
|
|
407
|
+
// glass shows once the banner has scrolled under the header
|
|
408
|
+
<DuoGlassBackground
|
|
409
|
+
animatedValue={sharedValue}
|
|
410
|
+
fadeStart={Math.max(210 + layoutOffset - heightHeader, 0)}
|
|
411
|
+
/>
|
|
412
|
+
) : (
|
|
413
|
+
<HeaderBackground
|
|
414
|
+
{...props}
|
|
415
|
+
animatedValue={sharedValue}
|
|
416
|
+
useGradient={false}
|
|
417
|
+
useShadowHeader={useShadowHeader}
|
|
418
|
+
headerBackground={headerBackground}
|
|
419
|
+
gradientColor={gradientColor}
|
|
420
|
+
/>
|
|
421
|
+
),
|
|
378
422
|
headerTitle: (props: any) => {
|
|
379
423
|
return <HeaderTitle {...props} />;
|
|
380
424
|
},
|
|
@@ -395,6 +439,9 @@ const Screen = forwardRef(
|
|
|
395
439
|
navigation?.instance,
|
|
396
440
|
sharedValue,
|
|
397
441
|
useShadowHeader,
|
|
442
|
+
hasSideBar,
|
|
443
|
+
layoutOffset,
|
|
444
|
+
heightHeader,
|
|
398
445
|
],
|
|
399
446
|
);
|
|
400
447
|
|
|
@@ -668,21 +715,35 @@ const Screen = forwardRef(
|
|
|
668
715
|
},
|
|
669
716
|
]}
|
|
670
717
|
>
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
718
|
+
{useDuoGlass ? (
|
|
719
|
+
<DuoScrollGradient
|
|
720
|
+
animatedValue={sharedValue}
|
|
721
|
+
gradientColor={gradientColor}
|
|
722
|
+
headerBackground={headerBackground}
|
|
723
|
+
heightHeader={heightHeader}
|
|
724
|
+
extended={headerType === 'extended'}
|
|
725
|
+
/>
|
|
726
|
+
) : (
|
|
727
|
+
<HeaderExtendHeader
|
|
728
|
+
headerType={headerType}
|
|
729
|
+
heightHeader={heightHeader}
|
|
730
|
+
headerRightWidth={headerRightWidth}
|
|
731
|
+
animatedValue={sharedValue}
|
|
732
|
+
inputSearchProps={inputSearchProps}
|
|
733
|
+
navigation={navigation}
|
|
734
|
+
inputSearchRef={inputSearchRef}
|
|
735
|
+
useShadowHeader={useShadowHeader}
|
|
736
|
+
gradientColor={gradientColor}
|
|
737
|
+
headerBackground={headerBackground}
|
|
738
|
+
/>
|
|
739
|
+
)}
|
|
683
740
|
|
|
684
741
|
<KeyboardAvoidingView
|
|
685
|
-
style={
|
|
742
|
+
style={[
|
|
743
|
+
Styles.flex,
|
|
744
|
+
useDuoGlass &&
|
|
745
|
+
(!scrollable || !!Header) && { paddingTop: heightHeader },
|
|
746
|
+
]}
|
|
686
747
|
enabled={enableKeyboardAvoidingView}
|
|
687
748
|
keyboardVerticalOffset={keyboardVerticalOffset ?? keyboardOffset}
|
|
688
749
|
behavior={Platform.select({
|
|
@@ -699,10 +760,22 @@ const Screen = forwardRef(
|
|
|
699
760
|
onScroll={handleScroll}
|
|
700
761
|
onScrollEndDrag={handleScrollEnd}
|
|
701
762
|
scrollEventThrottle={16}
|
|
702
|
-
style={[
|
|
763
|
+
style={[
|
|
764
|
+
Styles.flex,
|
|
765
|
+
!scrollable && contentInsetStyle,
|
|
766
|
+
!scrollable &&
|
|
767
|
+
!Footer &&
|
|
768
|
+
isTab &&
|
|
769
|
+
!!duoTabInset && { paddingBottom: duoTabInset },
|
|
770
|
+
]}
|
|
703
771
|
contentContainerStyle={[
|
|
704
772
|
scrollable && !Footer && !isTab && { paddingBottom: bottomInset },
|
|
773
|
+
scrollable &&
|
|
774
|
+
!Footer &&
|
|
775
|
+
isTab &&
|
|
776
|
+
!!duoTabInset && { paddingBottom: duoTabInset },
|
|
705
777
|
scrollable && contentInsetStyle,
|
|
778
|
+
useDuoGlass && !Header && { paddingTop: heightHeader },
|
|
706
779
|
scrollViewProps?.contentContainerStyle,
|
|
707
780
|
]}
|
|
708
781
|
>
|
|
@@ -721,7 +794,11 @@ const Screen = forwardRef(
|
|
|
721
794
|
<FloatingButton
|
|
722
795
|
{...floatingButtonProps}
|
|
723
796
|
animatedValue={sharedValue}
|
|
724
|
-
bottom={
|
|
797
|
+
bottom={
|
|
798
|
+
Footer || isTab
|
|
799
|
+
? 12 + (Footer ? 0 : duoTabInset)
|
|
800
|
+
: bottomInset + Spacing.S
|
|
801
|
+
}
|
|
725
802
|
/>
|
|
726
803
|
</View>
|
|
727
804
|
)}
|
|
@@ -731,7 +808,9 @@ const Screen = forwardRef(
|
|
|
731
808
|
style={[
|
|
732
809
|
styles.shadow,
|
|
733
810
|
{
|
|
734
|
-
paddingBottom:
|
|
811
|
+
paddingBottom:
|
|
812
|
+
(isTab && duoTabInset ? duoTabInset : bottomInset) +
|
|
813
|
+
Spacing.S,
|
|
735
814
|
paddingLeft: layout.insetLeft,
|
|
736
815
|
paddingRight: layout.insetRight,
|
|
737
816
|
backgroundColor: theme.colors.background.surface,
|
package/Popup/PopupNotify.tsx
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import React, { useContext, useRef, useState } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
Dimensions,
|
|
4
|
-
ScrollView,
|
|
5
|
-
StyleSheet,
|
|
6
|
-
TouchableOpacity,
|
|
7
|
-
View,
|
|
8
|
-
} from 'react-native';
|
|
2
|
+
import { ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
9
3
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
10
4
|
import { Button } from '../Button';
|
|
11
5
|
import { Colors, Radius, Spacing, Styles } from '../Consts';
|
|
@@ -14,6 +8,7 @@ import { Image } from '../Image';
|
|
|
14
8
|
import { Text, useScaleSize } from '../Text';
|
|
15
9
|
import { PopupNotifyProps } from './types';
|
|
16
10
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
11
|
+
import { usePopupMaxHeight, usePopupWidth } from './utils';
|
|
17
12
|
|
|
18
13
|
const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
19
14
|
image,
|
|
@@ -29,7 +24,8 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
29
24
|
const context = useContext<any>(MiniAppContext);
|
|
30
25
|
const { theme, navigator, translate } = useContext(ApplicationContext);
|
|
31
26
|
const [scrollContent, setScrollContent] = useState(false);
|
|
32
|
-
const
|
|
27
|
+
const popupWidth = usePopupWidth();
|
|
28
|
+
const maxHeight = usePopupMaxHeight();
|
|
33
29
|
const scaledMaxHeight = useScaleSize(172);
|
|
34
30
|
|
|
35
31
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -172,18 +168,8 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
172
168
|
}
|
|
173
169
|
};
|
|
174
170
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
accessibilityLabel={'popup_notify'}
|
|
178
|
-
style={[
|
|
179
|
-
styles.container,
|
|
180
|
-
{
|
|
181
|
-
backgroundColor: theme.colors.background.surface,
|
|
182
|
-
width: widthDevice - Spacing.XL * 2,
|
|
183
|
-
},
|
|
184
|
-
showBaseLineDebug && styles.debugBaseLine,
|
|
185
|
-
]}
|
|
186
|
-
>
|
|
171
|
+
const body = (
|
|
172
|
+
<>
|
|
187
173
|
{!!image && <Image source={{ uri: image }} style={styles.image} />}
|
|
188
174
|
<View style={styles.content}>
|
|
189
175
|
<Text
|
|
@@ -225,6 +211,30 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
225
211
|
</View>
|
|
226
212
|
)}
|
|
227
213
|
</View>
|
|
214
|
+
</>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<View
|
|
219
|
+
accessibilityLabel={'popup_notify'}
|
|
220
|
+
style={[
|
|
221
|
+
styles.container,
|
|
222
|
+
{
|
|
223
|
+
backgroundColor: theme.colors.background.surface,
|
|
224
|
+
width: popupWidth,
|
|
225
|
+
maxHeight,
|
|
226
|
+
},
|
|
227
|
+
showBaseLineDebug && styles.debugBaseLine,
|
|
228
|
+
]}
|
|
229
|
+
>
|
|
230
|
+
{maxHeight === undefined ? (
|
|
231
|
+
body
|
|
232
|
+
) : (
|
|
233
|
+
// scrolls when iPhone Duo landscape is too short; actions stay visible
|
|
234
|
+
<ScrollView style={styles.body} bounces={false}>
|
|
235
|
+
{body}
|
|
236
|
+
</ScrollView>
|
|
237
|
+
)}
|
|
228
238
|
<View style={styles.contentAction}>{buildAction()}</View>
|
|
229
239
|
{buildCloseIcon()}
|
|
230
240
|
</View>
|
|
@@ -242,6 +252,7 @@ const styles = StyleSheet.create({
|
|
|
242
252
|
aspectRatio: 1.7777,
|
|
243
253
|
overflow: 'hidden',
|
|
244
254
|
},
|
|
255
|
+
body: { flexShrink: 1 },
|
|
245
256
|
content: { padding: Spacing.XL },
|
|
246
257
|
description: {
|
|
247
258
|
marginTop: Spacing.S,
|
package/Popup/PopupPromotion.tsx
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import React, { useContext, useRef } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { StyleSheet, TouchableOpacity, View } from 'react-native';
|
|
3
3
|
import { PopupPromotionProps } from './types';
|
|
4
4
|
import { ApplicationContext, MiniAppContext } from '../Context';
|
|
5
5
|
import { Image } from '../Image';
|
|
6
6
|
import { Colors, Radius, Spacing } from '../Consts';
|
|
7
7
|
import { Icon } from '../Icon';
|
|
8
|
+
import { usePopupMaxHeight, usePopupWidth } from './utils';
|
|
9
|
+
|
|
10
|
+
const ASPECT_RATIO = 0.72;
|
|
11
|
+
const ICON_CLOSE_SIZE = 20;
|
|
12
|
+
// close button row under the image: marginTop + vertical padding + icon
|
|
13
|
+
const CLOSE_ROW_HEIGHT = Spacing.S * 3 + ICON_CLOSE_SIZE;
|
|
8
14
|
|
|
9
15
|
const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
10
16
|
image,
|
|
@@ -14,7 +20,13 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
14
20
|
}) => {
|
|
15
21
|
const context = useContext<any>(MiniAppContext);
|
|
16
22
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
17
|
-
const
|
|
23
|
+
const popupWidth = usePopupWidth();
|
|
24
|
+
const maxHeight = usePopupMaxHeight();
|
|
25
|
+
// fit the portrait image by height when iPhone Duo leaves too little room
|
|
26
|
+
const imageWidth =
|
|
27
|
+
maxHeight === undefined
|
|
28
|
+
? popupWidth
|
|
29
|
+
: Math.min(popupWidth, (maxHeight - CLOSE_ROW_HEIGHT) * ASPECT_RATIO);
|
|
18
30
|
const closeAction = useRef('touch_outside');
|
|
19
31
|
|
|
20
32
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -69,7 +81,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
69
81
|
return (
|
|
70
82
|
<View
|
|
71
83
|
accessibilityLabel={'popup_promotion'}
|
|
72
|
-
style={showBaseLineDebug && styles.debugBaseLine}
|
|
84
|
+
style={[{ width: imageWidth }, showBaseLineDebug && styles.debugBaseLine]}
|
|
73
85
|
>
|
|
74
86
|
<TouchableOpacity onPress={() => onAction(onActionClick)}>
|
|
75
87
|
<Image
|
|
@@ -80,7 +92,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
80
92
|
style={[
|
|
81
93
|
styles.container,
|
|
82
94
|
{
|
|
83
|
-
width:
|
|
95
|
+
width: imageWidth,
|
|
84
96
|
},
|
|
85
97
|
]}
|
|
86
98
|
/>
|
|
@@ -92,7 +104,7 @@ const PopupPromotion: React.FC<PopupPromotionProps> = ({
|
|
|
92
104
|
|
|
93
105
|
const styles = StyleSheet.create({
|
|
94
106
|
container: {
|
|
95
|
-
aspectRatio:
|
|
107
|
+
aspectRatio: ASPECT_RATIO,
|
|
96
108
|
width: '100%',
|
|
97
109
|
},
|
|
98
110
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
@@ -103,11 +115,11 @@ const styles = StyleSheet.create({
|
|
|
103
115
|
marginTop: Spacing.S,
|
|
104
116
|
},
|
|
105
117
|
iconButton: {
|
|
106
|
-
padding:
|
|
118
|
+
padding: Spacing.S,
|
|
107
119
|
},
|
|
108
120
|
iconClose: {
|
|
109
|
-
width:
|
|
110
|
-
height:
|
|
121
|
+
width: ICON_CLOSE_SIZE,
|
|
122
|
+
height: ICON_CLOSE_SIZE,
|
|
111
123
|
alignItems: 'center',
|
|
112
124
|
justifyContent: 'center',
|
|
113
125
|
borderRadius: Radius.M,
|
package/Popup/utils.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Dimensions, useWindowDimensions } from 'react-native';
|
|
2
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
+
import { Spacing } from '../Consts';
|
|
4
|
+
import { getDuoTopInset, isIPhoneDuo } from '../Application/device';
|
|
5
|
+
|
|
6
|
+
// width of a system alert on the iPhone Duo inner display, in both orientations
|
|
7
|
+
const DUO_POPUP_MAX_WIDTH = 440;
|
|
8
|
+
|
|
9
|
+
const usePopupWidth = () => {
|
|
10
|
+
const windowSize = useWindowDimensions();
|
|
11
|
+
if (!isIPhoneDuo()) {
|
|
12
|
+
return Dimensions.get('window').width - Spacing.XL * 2;
|
|
13
|
+
}
|
|
14
|
+
return Math.min(windowSize.width - Spacing.XL * 2, DUO_POPUP_MAX_WIDTH);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// room left for a popup on iPhone Duo, where landscape is too short for it
|
|
18
|
+
const usePopupMaxHeight = () => {
|
|
19
|
+
const insets = useSafeAreaInsets();
|
|
20
|
+
const { width, height } = useWindowDimensions();
|
|
21
|
+
if (!isIPhoneDuo()) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return (
|
|
25
|
+
height -
|
|
26
|
+
getDuoTopInset(insets, width, height) -
|
|
27
|
+
insets.bottom -
|
|
28
|
+
Spacing.XL * 2
|
|
29
|
+
);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { usePopupWidth, usePopupMaxHeight };
|