@momo-kits/foundation 0.168.1-beta.3 → 0.168.1-beta.30

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.
@@ -16,6 +16,7 @@ import {
16
16
  PanResponder,
17
17
  Platform,
18
18
  Pressable,
19
+ StatusBar,
19
20
  StyleSheet,
20
21
  TouchableOpacity,
21
22
  useWindowDimensions,
@@ -39,18 +40,39 @@ import Animated, {
39
40
  } from 'react-native-reanimated';
40
41
  import { scheduleOnRN } from 'react-native-worklets';
41
42
  import layoutStyles from '../Layout/styles';
42
- import { getDuoTopInset, isIPhoneDuo } from './device';
43
+ import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
44
+ import { getDuoTopInset, isFoldableDevice } from './device';
45
+ import { SideBarButton } from './Components/SideBarButton';
43
46
 
44
- // iPhone Duo inner display: center the sheet instead of spanning the width
47
+ // foldable inner display: center the sheet instead of spanning the width
45
48
  const DUO_REGULAR_MIN_WIDTH = 600;
46
49
 
50
+ const INDICATOR_HEIGHT = 16;
51
+ const HEADER_HEIGHT = 56;
52
+ const DUO_ORIENTATIONS = ['portrait', 'landscape'];
53
+ const SIDE_COLUMN_OFFSET =
54
+ INDICATOR_HEIGHT + (HEADER_HEIGHT - DuoMetrics.controlSize) / 2;
55
+
56
+ const getSideInset = (inset: number) => Math.max(inset - Spacing.M, 0);
57
+
47
58
  const BottomSheet: React.FC<BottomSheetParams> = props => {
48
59
  const { theme, navigator } = useContext(ApplicationContext);
49
60
  const context = useContext<any>(MiniAppContext);
50
61
  const windowSize = useWindowDimensions();
51
- const isDuo = isIPhoneDuo();
52
- const isDuoCentered = isDuo && windowSize.width >= DUO_REGULAR_MIN_WIDTH;
53
- const isPortrait = windowSize.height > windowSize.width;
62
+ const isDuo = isFoldableDevice();
63
+ const isDuoCentered =
64
+ isDuo &&
65
+ Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
66
+ const isDuoFloating = isDuo && !isDuoCentered;
67
+ const { sideBar } = useScreenLayout();
68
+ const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
69
+ const sideColumn =
70
+ isDuoFloating &&
71
+ sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
72
+ sideBar
73
+ ? sideBar
74
+ : undefined;
75
+ const hasSideColumn = !!sideColumn;
54
76
  // 'screen' is the outer display on iPhone Duo, even when unfolded
55
77
  const screenHeight = Dimensions.get('screen').height;
56
78
  const heightDevice = isDuo ? windowSize.height : screenHeight;
@@ -58,12 +80,20 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
58
80
  const insets = useSafeAreaInsets();
59
81
  const heightHeader = useHeaderHeight();
60
82
  const topGap = Math.max(
61
- getDuoTopInset(insets, windowSize.width, windowSize.height),
83
+ sideColumn
84
+ ? insets.top
85
+ : getDuoTopInset(insets, windowSize.width, windowSize.height),
62
86
  Spacing.XL,
63
87
  );
88
+ const sideInsetLeft = isDuoFloating ? getSideInset(insets.left) : 0;
89
+ const sideInsetRight = isDuoFloating ? getSideInset(insets.right) : 0;
90
+ const indicatorShift = (sideInsetRight - sideInsetLeft) / 2;
64
91
  const isIOS = Platform.OS === 'ios';
65
92
  const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
66
93
  const keyboardOffset = heightHeader - bottomInset;
94
+ // Android shrinks the root for the keyboard but keeps the window height and
95
+ // fires no keyboardWill* events, so only the root can bound the sheet
96
+ const fitsResizedRoot = isDuo && !isIOS;
67
97
 
68
98
  const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
69
99
 
@@ -83,7 +113,10 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
83
113
  }: BottomSheetParams = props.route.params;
84
114
 
85
115
  const translateY = useSharedValue(heightDevice);
116
+ const onDismissRef = useRef(props.route.params?.onDismiss);
117
+ onDismissRef.current = props.route.params?.onDismiss;
86
118
  const [keyboardLift, setKeyboardLift] = useState(0);
119
+ const [sheetTop, setSheetTop] = useState(windowSize.height);
87
120
 
88
121
  // KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
89
122
  // so lift it here and shrink maxHeight by the same amount instead
@@ -179,15 +212,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
179
212
  }
180
213
 
181
214
  /**
182
- * emit dismiss event
215
+ * emit dismiss event, once: folding or rotating a foldable device changes
216
+ * the window height while the sheet stays open
183
217
  */
184
218
  useEffect(() => {
185
- translateY.value = heightDevice;
186
219
  openAnimation();
187
220
  return () => {
188
- props.route.params?.onDismiss?.(action.current);
221
+ onDismissRef.current?.(action.current);
189
222
  };
190
- }, [heightDevice, openAnimation, props.route.params, translateY]);
223
+ }, [openAnimation]);
191
224
 
192
225
  /**
193
226
  * handler dismiss
@@ -228,14 +261,50 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
228
261
  return () => backHandler.remove();
229
262
  }, [barrierDismissible, onDismiss]);
230
263
 
264
+ const onPressBack = useCallback(() => {
265
+ onDismiss(true, leftOptions?.onPressIconLeft);
266
+ }, [leftOptions, onDismiss]);
267
+
268
+ const onPressClose = useCallback(() => {
269
+ action.current = 'icon_close';
270
+ onDismiss(true);
271
+ }, [onDismiss]);
272
+
273
+ const showBack = !!leftOptions?.onPressIconLeft;
274
+ const iconBack = leftOptions?.iconLeft ?? 'ic_back';
275
+ const sideColumnCount = showBack ? 2 : 1;
276
+ const sideColumnHeight =
277
+ sideColumnCount * DuoMetrics.controlSize +
278
+ (sideColumnCount - 1) * Spacing.M;
279
+ const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
280
+
281
+ useEffect(() => {
282
+ if (!coversStatusBar) {
283
+ return;
284
+ }
285
+ const entry = StatusBar.pushStackEntry({
286
+ barStyle: theme.dark ? 'light-content' : 'dark-content',
287
+ animated: true,
288
+ });
289
+ return () => StatusBar.popStackEntry(entry);
290
+ }, [coversStatusBar, theme.dark]);
291
+
231
292
  /**
232
293
  * render header
233
294
  */
234
295
  const renderHeader = useCallback(() => {
296
+ const showButtons = !hasSideColumn;
235
297
  return (
236
298
  <View {...panResponder.panHandlers}>
237
- <View style={{ height: 16 }}>
238
- <View style={styles.indicator} />
299
+ <View style={{ height: INDICATOR_HEIGHT }}>
300
+ <View
301
+ style={[
302
+ styles.indicator,
303
+ indicatorShift !== 0 && {
304
+ transform: [{ translateX: indicatorShift }],
305
+ },
306
+ ]}
307
+ />
239
308
  </View>
240
309
  <View
241
310
  style={[
@@ -246,14 +315,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
246
315
  },
247
316
  ]}
248
317
  >
249
- {leftOptions?.onPressIconLeft ? (
250
- <TouchableOpacity
251
- style={styles.iconButton}
252
- onPress={() => {
253
- onDismiss(true, leftOptions.onPressIconLeft);
254
- }}
255
- >
256
- <Icon source={leftOptions.iconLeft ?? 'ic_back'} />
318
+ {showButtons && showBack ? (
319
+ <TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
320
+ <Icon source={iconBack} />
257
321
  </TouchableOpacity>
258
322
  ) : null}
259
323
 
@@ -261,9 +325,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
261
325
  <View style={Styles.flex}>{options.header}</View>
262
326
  ) : (
263
327
  <>
264
- {!leftOptions ? <View style={styles.iconButton} /> : null}
328
+ {showButtons && !leftOptions ? (
329
+ <View style={styles.iconButton} />
330
+ ) : null}
265
331
  <Text
266
- style={[Styles.flex, { textAlign: 'center' }]}
332
+ style={[
333
+ Styles.flex,
334
+ styles.title,
335
+ !showButtons && styles.titleInset,
336
+ ]}
267
337
  numberOfLines={1}
268
338
  typography="header_default_bold"
269
339
  >
@@ -271,28 +341,64 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
271
341
  </Text>
272
342
  </>
273
343
  )}
274
- <TouchableOpacity
275
- style={styles.iconButton}
276
- onPress={() => {
277
- action.current = 'icon_close';
278
- onDismiss(true);
279
- }}
280
- >
281
- <Icon source="navigation_close" />
282
- </TouchableOpacity>
344
+ {showButtons ? (
345
+ <TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
346
+ <Icon source="navigation_close" />
347
+ </TouchableOpacity>
348
+ ) : null}
283
349
  </View>
284
350
  </View>
285
351
  );
286
352
  }, [
287
- onDismiss,
353
+ hasSideColumn,
354
+ iconBack,
355
+ indicatorShift,
288
356
  leftOptions,
357
+ onPressBack,
358
+ onPressClose,
289
359
  options.header,
290
360
  options.title,
291
361
  panResponder.panHandlers,
362
+ showBack,
292
363
  theme.colors.border.default,
293
364
  useDivider,
294
365
  ]);
295
366
 
367
+ /**
368
+ * iPhone Duo folded: back and close move to the side bar column, drawn on
369
+ * the part of the sheet under the side safe area. They line up with the
370
+ * header row, stay below the status bar cluster on a tall sheet and above a
371
+ * camera at the bottom end of the strip.
372
+ */
373
+ const renderSideColumn = () => {
374
+ if (!sideColumn) {
375
+ return null;
376
+ }
377
+ const lowestTop =
378
+ windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
379
+ const top = Math.min(
380
+ Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
381
+ lowestTop,
382
+ );
383
+
384
+ return (
385
+ <View
386
+ pointerEvents={'box-none'}
387
+ style={[
388
+ styles.sideColumn,
389
+ sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
390
+ InnerEdgeStyles[sideColumn.edge],
391
+ { top, width: sideColumnWidth },
392
+ ]}
393
+ >
394
+ {showBack ? (
395
+ <SideBarButton icon={iconBack} onPress={onPressBack} />
396
+ ) : null}
397
+ <SideBarButton icon="navigation_close" onPress={onPressClose} />
398
+ </View>
399
+ );
400
+ };
401
+
296
402
  const animatedStyle = useAnimatedStyle(() => {
297
403
  return {
298
404
  transform: [{ translateY: translateY.value }],
@@ -310,12 +416,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
310
416
  };
311
417
  });
312
418
 
313
- // landscape side buttons span the full height, so inset the content; in
314
- // portrait the side status bar only covers the top corner
315
- const needsSideInset = isDuo && !isDuoCentered && !isPortrait;
316
- const sideInsetStyle = needsSideInset && {
317
- paddingLeft: insets.left,
318
- paddingRight: insets.right,
419
+ const sideInsetStyle = isDuoFloating && {
420
+ paddingLeft: sideInsetLeft,
421
+ paddingRight: sideInsetRight,
319
422
  };
320
423
 
321
424
  const screen = (
@@ -335,6 +438,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
335
438
  }}
336
439
  style={StyleSheet.absoluteFillObject}
337
440
  isModalKit={true}
441
+ supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
338
442
  >
339
443
  <KeyboardAvoidingView
340
444
  behavior={Platform.select({
@@ -357,18 +461,28 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
357
461
  </Pressable>
358
462
 
359
463
  <Animated.View
464
+ onLayout={
465
+ isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
466
+ }
360
467
  style={[
361
468
  animatedStyle,
362
469
  {
363
470
  backgroundColor,
364
- borderTopLeftRadius: Radius.M,
365
- borderTopRightRadius: Radius.M,
471
+ borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
472
+ borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
366
473
  overflow: 'hidden',
367
474
  },
368
475
  isDuo && {
369
476
  maxHeight: windowSize.height - topGap - keyboardLift,
370
477
  marginBottom: keyboardLift,
371
478
  },
479
+ fitsResizedRoot && styles.shrink,
480
+ fitsResizedRoot && { marginTop: topGap },
481
+ isDuoFloating && { marginHorizontal: Spacing.M },
482
+ sideColumn && {
483
+ minHeight:
484
+ SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
485
+ },
372
486
  isDuoCentered && {
373
487
  // keeps the portrait width in landscape, like system sheets
374
488
  width: Math.min(
@@ -415,6 +529,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
415
529
  }}
416
530
  />
417
531
  )}
532
+ {renderSideColumn()}
418
533
  </Animated.View>
419
534
  </KeyboardAvoidingView>
420
535
  </Container>
@@ -440,10 +555,12 @@ const styles = StyleSheet.create({
440
555
  top: Spacing.S,
441
556
  },
442
557
  header: {
443
- height: 56,
558
+ height: HEADER_HEIGHT,
444
559
  flexDirection: 'row',
445
560
  alignItems: 'center',
446
561
  },
562
+ title: { textAlign: 'center' },
563
+ titleInset: { paddingHorizontal: Spacing.M },
447
564
  iconButton: {
448
565
  width: 24,
449
566
  height: 24,
@@ -452,6 +569,12 @@ const styles = StyleSheet.create({
452
569
  marginHorizontal: Spacing.M,
453
570
  },
454
571
  shrink: { flexShrink: 1 },
572
+ sideColumn: {
573
+ position: 'absolute',
574
+ gap: Spacing.M,
575
+ },
576
+ alignRight: { right: 0 },
577
+ alignLeft: { left: 0 },
455
578
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
456
579
  });
457
580