@momo-kits/foundation 0.168.1-beta.9 → 0.168.1-duo.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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';
43
+ import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
42
44
  import { getDuoTopInset, isIPhoneDuo } from './device';
45
+ import { SideBarButton } from './Components/SideBarButton';
43
46
 
44
47
  // iPhone Duo 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
62
  const isDuo = isIPhoneDuo();
52
- const isDuoCentered = isDuo && windowSize.width >= DUO_REGULAR_MIN_WIDTH;
53
- const isPortrait = windowSize.height > windowSize.width;
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,9 +80,14 @@ 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;
@@ -83,7 +110,10 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
83
110
  }: BottomSheetParams = props.route.params;
84
111
 
85
112
  const translateY = useSharedValue(heightDevice);
113
+ const onDismissRef = useRef(props.route.params?.onDismiss);
114
+ onDismissRef.current = props.route.params?.onDismiss;
86
115
  const [keyboardLift, setKeyboardLift] = useState(0);
116
+ const [sheetTop, setSheetTop] = useState(windowSize.height);
87
117
 
88
118
  // KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
89
119
  // so lift it here and shrink maxHeight by the same amount instead
@@ -179,15 +209,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
179
209
  }
180
210
 
181
211
  /**
182
- * emit dismiss event
212
+ * emit dismiss event, once: folding or rotating iPhone Duo changes the
213
+ * window height while the sheet stays open
183
214
  */
184
215
  useEffect(() => {
185
- translateY.value = heightDevice;
186
216
  openAnimation();
187
217
  return () => {
188
- props.route.params?.onDismiss?.(action.current);
218
+ onDismissRef.current?.(action.current);
189
219
  };
190
- }, [heightDevice, openAnimation, props.route.params, translateY]);
220
+ }, [openAnimation]);
191
221
 
192
222
  /**
193
223
  * handler dismiss
@@ -228,14 +258,50 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
228
258
  return () => backHandler.remove();
229
259
  }, [barrierDismissible, onDismiss]);
230
260
 
261
+ const onPressBack = useCallback(() => {
262
+ onDismiss(true, leftOptions?.onPressIconLeft);
263
+ }, [leftOptions, onDismiss]);
264
+
265
+ const onPressClose = useCallback(() => {
266
+ action.current = 'icon_close';
267
+ onDismiss(true);
268
+ }, [onDismiss]);
269
+
270
+ const showBack = !!leftOptions?.onPressIconLeft;
271
+ const iconBack = leftOptions?.iconLeft ?? 'ic_back';
272
+ const sideColumnCount = showBack ? 2 : 1;
273
+ const sideColumnHeight =
274
+ sideColumnCount * DuoMetrics.controlSize +
275
+ (sideColumnCount - 1) * Spacing.M;
276
+ const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
277
+
278
+ useEffect(() => {
279
+ if (!coversStatusBar) {
280
+ return;
281
+ }
282
+ const entry = StatusBar.pushStackEntry({
283
+ barStyle: theme.dark ? 'light-content' : 'dark-content',
284
+ animated: true,
285
+ });
286
+ return () => StatusBar.popStackEntry(entry);
287
+ }, [coversStatusBar, theme.dark]);
288
+
231
289
  /**
232
290
  * render header
233
291
  */
234
292
  const renderHeader = useCallback(() => {
293
+ const showButtons = !hasSideColumn;
235
294
  return (
236
295
  <View {...panResponder.panHandlers}>
237
- <View style={{ height: 16 }}>
238
- <View style={styles.indicator} />
296
+ <View style={{ height: INDICATOR_HEIGHT }}>
297
+ <View
298
+ style={[
299
+ styles.indicator,
300
+ indicatorShift !== 0 && {
301
+ transform: [{ translateX: indicatorShift }],
302
+ },
303
+ ]}
304
+ />
239
305
  </View>
240
306
  <View
241
307
  style={[
@@ -246,14 +312,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
246
312
  },
247
313
  ]}
248
314
  >
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'} />
315
+ {showButtons && showBack ? (
316
+ <TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
317
+ <Icon source={iconBack} />
257
318
  </TouchableOpacity>
258
319
  ) : null}
259
320
 
@@ -261,9 +322,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
261
322
  <View style={Styles.flex}>{options.header}</View>
262
323
  ) : (
263
324
  <>
264
- {!leftOptions ? <View style={styles.iconButton} /> : null}
325
+ {showButtons && !leftOptions ? (
326
+ <View style={styles.iconButton} />
327
+ ) : null}
265
328
  <Text
266
- style={[Styles.flex, { textAlign: 'center' }]}
329
+ style={[
330
+ Styles.flex,
331
+ styles.title,
332
+ !showButtons && styles.titleInset,
333
+ ]}
267
334
  numberOfLines={1}
268
335
  typography="header_default_bold"
269
336
  >
@@ -271,28 +338,64 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
271
338
  </Text>
272
339
  </>
273
340
  )}
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>
341
+ {showButtons ? (
342
+ <TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
343
+ <Icon source="navigation_close" />
344
+ </TouchableOpacity>
345
+ ) : null}
283
346
  </View>
284
347
  </View>
285
348
  );
286
349
  }, [
287
- onDismiss,
350
+ hasSideColumn,
351
+ iconBack,
352
+ indicatorShift,
288
353
  leftOptions,
354
+ onPressBack,
355
+ onPressClose,
289
356
  options.header,
290
357
  options.title,
291
358
  panResponder.panHandlers,
359
+ showBack,
292
360
  theme.colors.border.default,
293
361
  useDivider,
294
362
  ]);
295
363
 
364
+ /**
365
+ * iPhone Duo folded: back and close move to the side bar column, drawn on
366
+ * the part of the sheet under the side safe area. They line up with the
367
+ * header row, stay below the status bar cluster on a tall sheet and above a
368
+ * camera at the bottom end of the strip.
369
+ */
370
+ const renderSideColumn = () => {
371
+ if (!sideColumn) {
372
+ return null;
373
+ }
374
+ const lowestTop =
375
+ windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
376
+ const top = Math.min(
377
+ Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
378
+ lowestTop,
379
+ );
380
+
381
+ return (
382
+ <View
383
+ pointerEvents={'box-none'}
384
+ style={[
385
+ styles.sideColumn,
386
+ sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
387
+ InnerEdgeStyles[sideColumn.edge],
388
+ { top, width: sideColumnWidth },
389
+ ]}
390
+ >
391
+ {showBack ? (
392
+ <SideBarButton icon={iconBack} onPress={onPressBack} />
393
+ ) : null}
394
+ <SideBarButton icon="navigation_close" onPress={onPressClose} />
395
+ </View>
396
+ );
397
+ };
398
+
296
399
  const animatedStyle = useAnimatedStyle(() => {
297
400
  return {
298
401
  transform: [{ translateY: translateY.value }],
@@ -310,12 +413,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
310
413
  };
311
414
  });
312
415
 
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,
416
+ const sideInsetStyle = isDuoFloating && {
417
+ paddingLeft: sideInsetLeft,
418
+ paddingRight: sideInsetRight,
319
419
  };
320
420
 
321
421
  const screen = (
@@ -335,6 +435,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
335
435
  }}
336
436
  style={StyleSheet.absoluteFillObject}
337
437
  isModalKit={true}
438
+ supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
338
439
  >
339
440
  <KeyboardAvoidingView
340
441
  behavior={Platform.select({
@@ -357,18 +458,26 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
357
458
  </Pressable>
358
459
 
359
460
  <Animated.View
461
+ onLayout={
462
+ isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
463
+ }
360
464
  style={[
361
465
  animatedStyle,
362
466
  {
363
467
  backgroundColor,
364
- borderTopLeftRadius: Radius.M,
365
- borderTopRightRadius: Radius.M,
468
+ borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
469
+ borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
366
470
  overflow: 'hidden',
367
471
  },
368
472
  isDuo && {
369
473
  maxHeight: windowSize.height - topGap - keyboardLift,
370
474
  marginBottom: keyboardLift,
371
475
  },
476
+ isDuoFloating && { marginHorizontal: Spacing.M },
477
+ sideColumn && {
478
+ minHeight:
479
+ SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
480
+ },
372
481
  isDuoCentered && {
373
482
  // keeps the portrait width in landscape, like system sheets
374
483
  width: Math.min(
@@ -415,6 +524,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
415
524
  }}
416
525
  />
417
526
  )}
527
+ {renderSideColumn()}
418
528
  </Animated.View>
419
529
  </KeyboardAvoidingView>
420
530
  </Container>
@@ -440,10 +550,12 @@ const styles = StyleSheet.create({
440
550
  top: Spacing.S,
441
551
  },
442
552
  header: {
443
- height: 56,
553
+ height: HEADER_HEIGHT,
444
554
  flexDirection: 'row',
445
555
  alignItems: 'center',
446
556
  },
557
+ title: { textAlign: 'center' },
558
+ titleInset: { paddingHorizontal: Spacing.M },
447
559
  iconButton: {
448
560
  width: 24,
449
561
  height: 24,
@@ -452,6 +564,12 @@ const styles = StyleSheet.create({
452
564
  marginHorizontal: Spacing.M,
453
565
  },
454
566
  shrink: { flexShrink: 1 },
567
+ sideColumn: {
568
+ position: 'absolute',
569
+ gap: Spacing.M,
570
+ },
571
+ alignRight: { right: 0 },
572
+ alignLeft: { left: 0 },
455
573
  debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
456
574
  });
457
575
 
@@ -0,0 +1,90 @@
1
+ import React, { useContext, useEffect, useRef } from 'react';
2
+ import { StyleProp, StyleSheet, ViewStyle } from 'react-native';
3
+ import Animated, {
4
+ useAnimatedStyle,
5
+ useSharedValue,
6
+ withTiming,
7
+ } from 'react-native-reanimated';
8
+ import { ApplicationContext } from '../../Context';
9
+ import { getIndicatorShadow, TabBarMotion } from './metrics';
10
+
11
+ type Props = {
12
+ axis: 'x' | 'y';
13
+ // position of the selected item along the axis, undefined until it is measured
14
+ offset?: number;
15
+ // horizontal bar: width of the selected item
16
+ extent?: number;
17
+ radius: number;
18
+ style: StyleProp<ViewStyle>;
19
+ };
20
+
21
+ /**
22
+ * one active indicator per bar (Background/tonal with its inner shadows) sliding to the
23
+ * selected item; it lands in place on the first measure and slides from then on
24
+ */
25
+ const ActiveIndicator: React.FC<Props> = ({
26
+ axis,
27
+ offset,
28
+ extent,
29
+ radius,
30
+ style,
31
+ }) => {
32
+ const { theme } = useContext(ApplicationContext);
33
+ const position = useSharedValue(offset ?? 0);
34
+ const size = useSharedValue(extent ?? 0);
35
+ const visible = useSharedValue(offset === undefined ? 0 : 1);
36
+ const placed = useRef(offset !== undefined);
37
+
38
+ useEffect(() => {
39
+ if (offset === undefined) {
40
+ return;
41
+ }
42
+ size.value = extent ?? 0;
43
+ if (!placed.current) {
44
+ placed.current = true;
45
+ position.value = offset;
46
+ visible.value = 1;
47
+ return;
48
+ }
49
+ position.value = withTiming(offset, TabBarMotion.indicator);
50
+ }, [offset, extent, position, size, visible]);
51
+
52
+ const animatedStyle = useAnimatedStyle(() =>
53
+ axis === 'x'
54
+ ? {
55
+ opacity: visible.value,
56
+ width: size.value,
57
+ transform: [{ translateX: position.value }],
58
+ }
59
+ : {
60
+ opacity: visible.value,
61
+ transform: [{ translateY: position.value }],
62
+ },
63
+ );
64
+
65
+ return (
66
+ <Animated.View
67
+ pointerEvents={'none'}
68
+ style={[
69
+ styles.indicator,
70
+ {
71
+ borderRadius: radius,
72
+ backgroundColor: theme.colors.background.tonal,
73
+ boxShadow: getIndicatorShadow(theme.colors.primary),
74
+ },
75
+ style,
76
+ animatedStyle,
77
+ ]}
78
+ />
79
+ );
80
+ };
81
+
82
+ const styles = StyleSheet.create({
83
+ indicator: {
84
+ position: 'absolute',
85
+ left: 0,
86
+ top: 0,
87
+ },
88
+ });
89
+
90
+ export { ActiveIndicator };