@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.
@@ -1,14 +1,24 @@
1
- import React, { useCallback, useContext, useEffect, useRef } from '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 heightDevice = Dimensions.get('screen').height;
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 bottomInset = Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
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
- {renderHeader()}
294
- <Screen
295
- {...props}
296
- {...props.route.params}
297
- onRequestClose={onRequestClose}
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
- <BottomTabBar
310
- {...props}
311
- floatingButton={floatingButton}
312
- inactiveTintColor={theme.colors.text.hint}
313
- activeTintColor={theme.colors.primary}
314
- style={[
315
- {
316
- height:
317
- 64 +
318
- (Platform.OS === 'ios'
319
- ? Math.min(insets.bottom, 21)
320
- : insets.bottom),
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 = Platform.OS === 'ios' ? Math.min(insets.bottom, 21) : insets.bottom;
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 (headerType === 'extended' || animatedHeader || inputSearchProps) {
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: () => null,
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
- <HeaderBackground
370
- {...props}
371
- animatedValue={sharedValue}
372
- useGradient={false}
373
- useShadowHeader={useShadowHeader}
374
- headerBackground={headerBackground}
375
- gradientColor={gradientColor}
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
- <HeaderExtendHeader
672
- headerType={headerType}
673
- heightHeader={heightHeader}
674
- headerRightWidth={headerRightWidth}
675
- animatedValue={sharedValue}
676
- inputSearchProps={inputSearchProps}
677
- navigation={navigation}
678
- inputSearchRef={inputSearchRef}
679
- useShadowHeader={useShadowHeader}
680
- gradientColor={gradientColor}
681
- headerBackground={headerBackground}
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={Styles.flex}
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={[Styles.flex, !scrollable && contentInsetStyle]}
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={Footer || isTab ? 12 : bottomInset + Spacing.S}
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: bottomInset + Spacing.S,
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,
@@ -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 { width: widthDevice } = Dimensions.get('window');
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
- return (
176
- <View
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,
@@ -1,10 +1,16 @@
1
1
  import React, { useContext, useRef } from 'react';
2
- import { Dimensions, StyleSheet, TouchableOpacity, View } from 'react-native';
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 { width: widthDevice } = Dimensions.get('window');
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: widthDevice - Spacing.XL * 2,
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: 0.72,
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: 8,
118
+ padding: Spacing.S,
107
119
  },
108
120
  iconClose: {
109
- width: 20,
110
- height: 20,
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.167.1-duo.2",
3
+ "version": "0.167.1-duo.3",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},