@momo-kits/foundation 0.167.1-duo.2 → 0.167.1-duo.4

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.
@@ -27,6 +27,8 @@ 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';
31
+ import { DuoBarCompressionProvider } from './DuoBarCompression';
30
32
  import { ApplicationContext, MiniAppContext } from '../../Context';
31
33
  import { useScreenLayout } from '../../Layout';
32
34
 
@@ -204,8 +206,10 @@ const BottomTab: React.FC<BottomTabProps> = ({
204
206
  navigation,
205
207
  initialRouteName,
206
208
  floatingButton,
209
+ compressionBehavior,
207
210
  }) => {
208
211
  const { theme, navigator } = useContext(ApplicationContext);
212
+ const { isIPhoneDuo } = useScreenLayout();
209
213
  const dimensions = useWindowDimensions();
210
214
  const insets = useSafeAreaInsets();
211
215
  const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
@@ -279,138 +283,156 @@ const BottomTab: React.FC<BottomTabProps> = ({
279
283
  };
280
284
 
281
285
  return (
282
- <Tab.Navigator
283
- screenOptions={{
284
- headerShown: false,
285
- headerTransparent: true,
286
- headerBackground: undefined,
287
- tabBarLabelPosition: 'below-icon',
288
- tabBarBackground: () => (
289
- <View
290
- style={[
291
- styles.container,
292
- {
293
- backgroundColor: theme.colors.background.surface,
294
- },
295
- ]}
296
- >
297
- <Animated.View style={[styles.indicatorContainer, indicatorStyle]}>
298
- <View
299
- style={[
300
- styles.indicator,
301
- { backgroundColor: theme.colors.primary },
302
- ]}
303
- />
304
- </Animated.View>
305
- </View>
306
- ),
307
- }}
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
- )}
325
- initialRouteName={initialRouteName}
286
+ <DuoBarCompressionProvider
287
+ behavior={compressionBehavior}
288
+ tabCount={tabs.length}
289
+ hasQr={!!floatingButton}
326
290
  >
327
- {tabs.map((item, index) => {
328
- const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
329
- const isDot = typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
330
- const isNumZero = isNum && Number(item.badgeLabel) === 0;
331
-
332
- let badgeLabel = item?.badgeLabel;
333
- if (isNumZero) {
334
- badgeLabel = undefined;
291
+ <Tab.Navigator
292
+ screenOptions={{
293
+ headerShown: false,
294
+ headerTransparent: true,
295
+ headerBackground: undefined,
296
+ tabBarLabelPosition: 'below-icon',
297
+ tabBarBackground: () => (
298
+ <View
299
+ style={[
300
+ styles.container,
301
+ {
302
+ backgroundColor: theme.colors.background.surface,
303
+ },
304
+ ]}
305
+ >
306
+ <Animated.View
307
+ style={[styles.indicatorContainer, indicatorStyle]}
308
+ >
309
+ <View
310
+ style={[
311
+ styles.indicator,
312
+ { backgroundColor: theme.colors.primary },
313
+ ]}
314
+ />
315
+ </Animated.View>
316
+ </View>
317
+ ),
318
+ }}
319
+ tabBar={props =>
320
+ isIPhoneDuo ? (
321
+ <DuoTabBar
322
+ {...props}
323
+ floatingButton={floatingButton}
324
+ inactiveTintColor={theme.colors.text.default}
325
+ activeTintColor={theme.colors.primary}
326
+ />
327
+ ) : (
328
+ <BottomTabBar
329
+ {...props}
330
+ floatingButton={floatingButton}
331
+ inactiveTintColor={theme.colors.text.hint}
332
+ activeTintColor={theme.colors.primary}
333
+ style={[
334
+ {
335
+ height:
336
+ 64 +
337
+ (Platform.OS === 'ios'
338
+ ? Math.min(insets.bottom, 21)
339
+ : insets.bottom),
340
+ },
341
+ ]}
342
+ />
343
+ )
335
344
  }
345
+ initialRouteName={initialRouteName}
346
+ >
347
+ {tabs.map((item, index) => {
348
+ const isNum = !isNaN(parseInt(`${item?.badgeLabel}`, 10));
349
+ const isDot =
350
+ typeof item?.badgeLabel === 'string' && !item?.badgeLabel;
351
+ const isNumZero = isNum && Number(item.badgeLabel) === 0;
336
352
 
337
- let badgeStyle: {
338
- marginRight: number;
339
- top: number;
340
- backgroundColor: string;
341
- width?: number;
342
- } = {
343
- marginRight: -8,
344
- top: -4,
345
- backgroundColor: Colors.orange_03,
346
- };
353
+ let badgeLabel = item?.badgeLabel;
354
+ if (isNumZero) {
355
+ badgeLabel = undefined;
356
+ }
347
357
 
348
- if (isNum) {
349
- badgeStyle = {
350
- marginRight: -4,
358
+ let badgeStyle: {
359
+ marginRight: number;
360
+ top: number;
361
+ backgroundColor: string;
362
+ width?: number;
363
+ } = {
364
+ marginRight: -8,
351
365
  top: -4,
352
- backgroundColor: Colors.red_03,
353
- };
354
- }
355
- if (isDot) {
356
- badgeStyle = {
357
- marginRight: -2,
358
- top: -2,
359
366
  backgroundColor: Colors.orange_03,
360
- width: 12,
361
367
  };
362
- }
363
368
 
364
- return (
365
- <Tab.Screen
366
- key={`BottomTab-${item.name}`}
367
- name={item.name}
368
- component={TabScreen}
369
- initialParams={{ ...item, nested, index }}
370
- listeners={handler}
371
- options={{
372
- tabBarLabel: ({ focused, color }) => {
373
- return (
374
- <Text
375
- numberOfLines={1}
376
- typography={
377
- focused ? 'label_xs_medium' : 'description_xs_regular'
378
- }
379
- color={color}
380
- >
381
- {item.label}
382
- </Text>
383
- );
384
- },
385
- title: item.label,
386
- tabBarStyle: [
387
- {
388
- height: 64,
389
- },
390
- styles.tabStyle,
391
- ],
392
- tabBarBadge: badgeLabel,
393
- tabBarBadgeStyle: [
394
- {
395
- borderColor: 'white',
396
- borderWidth: 2,
397
- fontSize: 10,
398
- lineHeight: 14,
399
- fontWeight: 'bold',
400
- alignSelf: 'center',
401
- fontFamily: exportFontFamily('bold'),
369
+ if (isNum) {
370
+ badgeStyle = {
371
+ marginRight: -4,
372
+ top: -4,
373
+ backgroundColor: Colors.red_03,
374
+ };
375
+ }
376
+ if (isDot) {
377
+ badgeStyle = {
378
+ marginRight: -2,
379
+ top: -2,
380
+ backgroundColor: Colors.orange_03,
381
+ width: 12,
382
+ };
383
+ }
384
+
385
+ return (
386
+ <Tab.Screen
387
+ key={`BottomTab-${item.name}`}
388
+ name={item.name}
389
+ component={TabScreen}
390
+ initialParams={{ ...item, nested, index }}
391
+ listeners={handler}
392
+ options={{
393
+ tabBarLabel: ({ focused, color }) => {
394
+ return (
395
+ <Text
396
+ numberOfLines={1}
397
+ typography={
398
+ focused ? 'label_xs_medium' : 'description_xs_regular'
399
+ }
400
+ color={color}
401
+ >
402
+ {item.label}
403
+ </Text>
404
+ );
402
405
  },
403
- badgeStyle,
404
- ],
405
- tabBarIcon: ({ color }) => (
406
- <Icon source={item.icon} color={color} size={28} />
407
- ),
408
- ...item,
409
- }}
410
- />
411
- );
412
- })}
413
- </Tab.Navigator>
406
+ title: item.label,
407
+ tabBarStyle: [
408
+ {
409
+ height: 64,
410
+ },
411
+ styles.tabStyle,
412
+ ],
413
+ tabBarBadge: badgeLabel,
414
+ tabBarBadgeStyle: [
415
+ {
416
+ borderColor: 'white',
417
+ borderWidth: 2,
418
+ fontSize: 10,
419
+ lineHeight: 14,
420
+ fontWeight: 'bold',
421
+ alignSelf: 'center',
422
+ fontFamily: exportFontFamily('bold'),
423
+ },
424
+ badgeStyle,
425
+ ],
426
+ tabBarIcon: ({ color }) => (
427
+ <Icon source={item.icon} color={color} size={28} />
428
+ ),
429
+ ...item,
430
+ }}
431
+ />
432
+ );
433
+ })}
434
+ </Tab.Navigator>
435
+ </DuoBarCompressionProvider>
414
436
  );
415
437
  };
416
438
 
@@ -0,0 +1,179 @@
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, fading out on scroll as main's extended
86
+ * header does: header-high for default headers, 52 taller (collapsing) for extended
87
+ */
88
+ const DuoScrollGradient: React.FC<{
89
+ animatedValue?: AnimatedCompatValue;
90
+ gradientColor?: string;
91
+ headerBackground?: string;
92
+ heightHeader: number;
93
+ extended?: boolean;
94
+ // quick return header: the gradient leaves with the content instead of fading
95
+ followScroll?: boolean;
96
+ }> = ({
97
+ animatedValue,
98
+ gradientColor,
99
+ headerBackground,
100
+ heightHeader,
101
+ extended,
102
+ followScroll,
103
+ }) => {
104
+ const { theme } = useContext(ApplicationContext);
105
+ const sharedValue = useNormalizedSharedValue(animatedValue);
106
+ const color = gradientColor ?? theme.colors.gradient;
107
+ const image = headerBackground ?? theme.assets?.headerBackground;
108
+
109
+ const scrollStyle = useAnimatedStyle(() => {
110
+ if (followScroll) {
111
+ return {
112
+ height: heightHeader + (extended ? EXTENDED_GRADIENT_EXTRA : 0),
113
+ transform: [{ translateY: -Math.max(sharedValue.value, 0) }],
114
+ };
115
+ }
116
+ return {
117
+ height: extended
118
+ ? interpolate(
119
+ sharedValue.value,
120
+ [0, EXTENDED_COLLAPSE_DISTANCE],
121
+ [heightHeader + EXTENDED_GRADIENT_EXTRA, heightHeader],
122
+ Extrapolation.CLAMP,
123
+ )
124
+ : heightHeader,
125
+ opacity: interpolate(
126
+ sharedValue.value,
127
+ [0, EXTENDED_GRADIENT_EXTRA],
128
+ [1, 0],
129
+ Extrapolation.CLAMP,
130
+ ),
131
+ };
132
+ });
133
+
134
+ if (!color) {
135
+ return null;
136
+ }
137
+
138
+ return (
139
+ <Animated.View
140
+ pointerEvents={'none'}
141
+ style={[styles.gradient, scrollStyle]}
142
+ >
143
+ <LinearGradient
144
+ colors={[color, color + '00']}
145
+ style={StyleSheet.absoluteFill}
146
+ >
147
+ {!!image && (
148
+ <Image
149
+ style={styles.gradientFill}
150
+ source={{ uri: image }}
151
+ loading={false}
152
+ />
153
+ )}
154
+ </LinearGradient>
155
+ </Animated.View>
156
+ );
157
+ };
158
+
159
+ const styles = StyleSheet.create({
160
+ translucentBar: {
161
+ borderBottomWidth: StyleSheet.hairlineWidth,
162
+ ...Shadow.Light,
163
+ },
164
+ gradient: {
165
+ position: 'absolute',
166
+ top: 0,
167
+ left: 0,
168
+ right: 0,
169
+ overflow: 'hidden',
170
+ },
171
+ // banner image keeps its 375:154 shape, the gradient box clips it
172
+ gradientFill: {
173
+ width: '100%',
174
+ position: 'absolute',
175
+ aspectRatio: 375 / 154,
176
+ },
177
+ });
178
+
179
+ export { DuoGlassBackground, DuoScrollGradient };
@@ -0,0 +1,42 @@
1
+ import React, { createContext, useContext } from 'react';
2
+ import { StackHeaderProps, Header } from '@react-navigation/stack';
3
+ import Animated, {
4
+ SharedValue,
5
+ useAnimatedStyle,
6
+ } from 'react-native-reanimated';
7
+
8
+ // how far the header is pushed above the top edge
9
+ const DuoHeaderOffsetContext = createContext<SharedValue<number> | undefined>(
10
+ undefined,
11
+ );
12
+
13
+ /**
14
+ * iPhone Duo header that leaves with the content and comes back when scrolling back
15
+ */
16
+ const DuoQuickReturnHeader: React.FC<
17
+ StackHeaderProps & { offset: SharedValue<number> }
18
+ > = ({ offset, ...props }) => {
19
+ const style = useAnimatedStyle(() => ({
20
+ transform: [{ translateY: -offset.value }],
21
+ }));
22
+
23
+ return (
24
+ <DuoHeaderOffsetContext.Provider value={offset}>
25
+ <Animated.View pointerEvents={'box-none'} style={style}>
26
+ <Header {...props} />
27
+ </Animated.View>
28
+ </DuoHeaderOffsetContext.Provider>
29
+ );
30
+ };
31
+
32
+ /**
33
+ * side bar controls render inside the header; this cancels its offset so they stay put
34
+ */
35
+ const useSideBarCounterStyle = () => {
36
+ const offset = useContext(DuoHeaderOffsetContext);
37
+ return useAnimatedStyle(() => ({
38
+ transform: [{ translateY: offset ? offset.value : 0 }],
39
+ }));
40
+ };
41
+
42
+ export { DuoQuickReturnHeader, useSideBarCounterStyle };
@@ -5,7 +5,9 @@ import { ApplicationContext, MiniAppContext } from '../../Context';
5
5
  import { PopupNotify } from '../../Popup';
6
6
  import { NavigationButton } from './NavigationButton';
7
7
  import { SideBarButton } from './SideBarButton';
8
+ import Animated from 'react-native-reanimated';
8
9
  import { useSideBarItemStyle } from '../../Layout/utils';
10
+ import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
9
11
 
10
12
  /**
11
13
  * default header left used for nav
@@ -20,6 +22,7 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
20
22
  const context = useContext<any>(MiniAppContext);
21
23
  const { navigator } = useContext(ApplicationContext);
22
24
  const sideBarStyle = useSideBarItemStyle('left', 0);
25
+ const counterStyle = useSideBarCounterStyle();
23
26
 
24
27
  const goBack = useCallback(() => {
25
28
  const canGoBack = navigator?.ref?.current?.canGoBack?.();
@@ -78,14 +81,17 @@ const HeaderLeft: React.FC<HeaderBackProps> = ({
78
81
 
79
82
  if (floating && sideBarStyle) {
80
83
  return (
81
- <View style={sideBarStyle} pointerEvents={'box-none'}>
84
+ <Animated.View
85
+ style={[sideBarStyle, counterStyle]}
86
+ pointerEvents={'box-none'}
87
+ >
82
88
  <SideBarButton
83
89
  accessibilityLabel="btn_navigation_back"
84
90
  icon="arrow-back"
85
91
  tintColor={tintColor}
86
92
  onPress={goBackSafe}
87
93
  />
88
- </View>
94
+ </Animated.View>
89
95
  );
90
96
  }
91
97
 
@@ -13,8 +13,12 @@ import { PopupNotify } from '../../Popup';
13
13
  import { Tool, ToolGroup } from '../types';
14
14
  import { Icon } from '../../Icon';
15
15
  import { Text } from '../../Text';
16
+ import Animated from 'react-native-reanimated';
16
17
  import { useSideBarItemStyle } from '../../Layout/utils';
18
+ import { useSideBarCounterStyle } from './DuoQuickReturnHeader';
17
19
  import { SideBarButton, SideBarGroup, SideBarGroupItem } from './SideBarButton';
20
+ import { useDuoBarCompression } from '../BottomTab/DuoBarCompression';
21
+ import { DuoMetrics } from '../../Layout/utils';
18
22
 
19
23
  const DID_SYNC_NEW_HOME = 'did_sync_new_home';
20
24
 
@@ -26,6 +30,7 @@ const HeaderRight: React.FC<any> = props => {
26
30
  const { headerRight = {}, floating = false } = props;
27
31
  const { useOnBoarding = false, buttonOnBoarding, onPress } = headerRight;
28
32
  const sideBarStyle = useSideBarItemStyle('right', 1);
33
+ const counterStyle = useSideBarCounterStyle();
29
34
 
30
35
  if (typeof props.headerRight === 'function') {
31
36
  return props.headerRight(props);
@@ -43,13 +48,16 @@ const HeaderRight: React.FC<any> = props => {
43
48
 
44
49
  if (floating && sideBarStyle) {
45
50
  return (
46
- <View style={sideBarStyle} pointerEvents={'box-none'}>
51
+ <Animated.View
52
+ style={[sideBarStyle, counterStyle]}
53
+ pointerEvents={'box-none'}
54
+ >
47
55
  <HeaderToolkitAction
48
56
  {...props.headerRight}
49
57
  tintColor={props.tintColor}
50
58
  vertical
51
59
  />
52
- </View>
60
+ </Animated.View>
53
61
  );
54
62
  }
55
63
 
@@ -75,6 +83,8 @@ const HeaderToolkitAction: React.FC<any> = ({
75
83
  }) => {
76
84
  const { navigator } = useContext(ApplicationContext);
77
85
  const context = useContext<any>(MiniAppContext);
86
+ const { toolbarCompressed, toolbarHidden, expandToolbar, setToolbarHeight } =
87
+ useDuoBarCompression();
78
88
 
79
89
  const [isFavorite, setIsFavorite] = useState<boolean>(false);
80
90
  const [isLoading, setIsLoading] = useState(false);
@@ -281,6 +291,41 @@ const HeaderToolkitAction: React.FC<any> = ({
281
291
  ? '16_navigation_close_circle'
282
292
  : '16_basic_home';
283
293
 
294
+ const groupCount = useHelpCenter ? 2 : 1;
295
+ // Back slot, the optional shortcut, then the help/home capsule
296
+ const fullToolbarHeight =
297
+ DuoMetrics.controlSize +
298
+ Spacing.M +
299
+ (useShortcut ? DuoMetrics.controlSize + Spacing.M : 0) +
300
+ (groupCount > 1
301
+ ? groupCount * DuoMetrics.itemPitch
302
+ : DuoMetrics.controlSize);
303
+
304
+ useEffect(() => {
305
+ if (vertical) {
306
+ setToolbarHeight?.(fullToolbarHeight);
307
+ }
308
+ }, [vertical, fullToolbarHeight, setToolbarHeight]);
309
+
310
+ // an open tab rail takes this slot for a moment
311
+ if (vertical && toolbarHidden) {
312
+ return null;
313
+ }
314
+
315
+ // HIG "Toolbar compressed": tools fold into one button; tapping it expands the
316
+ // toolbar and compresses the tab bar instead. Not the useMore icon, to tell them apart
317
+ if (vertical && toolbarCompressed) {
318
+ return (
319
+ <SideBarButton
320
+ accessibilityLabel={'btn_navigation_toolbar'}
321
+ icon={'navigation_menu'}
322
+ tintColor={tintColor}
323
+ showBadge={showBadge}
324
+ onPress={expandToolbar}
325
+ />
326
+ );
327
+ }
328
+
284
329
  if (vertical) {
285
330
  return (
286
331
  <View style={styles.sideBarColumn}>
@@ -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 };