@momo-kits/foundation 0.169.1 → 0.169.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.
Files changed (38) hide show
  1. package/Application/BottomSheet.hooks.ts +17 -37
  2. package/Application/BottomSheet.parts.tsx +81 -165
  3. package/Application/BottomSheet.styles.ts +7 -23
  4. package/Application/BottomSheet.tsx +15 -67
  5. package/Application/BottomTab/ActiveIndicator.tsx +90 -0
  6. package/Application/BottomTab/BottomTabBar.styles.ts +48 -43
  7. package/Application/BottomTab/BottomTabBar.tsx +229 -223
  8. package/Application/BottomTab/BottomTabInset.tsx +14 -0
  9. package/Application/BottomTab/CurvedContainer.tsx +153 -0
  10. package/Application/BottomTab/CustomBottomTabItem.tsx +132 -108
  11. package/Application/BottomTab/DuoBarCompression.tsx +4 -12
  12. package/Application/BottomTab/DuoRailTabBar.tsx +174 -96
  13. package/Application/BottomTab/DuoTabBar.styles.ts +42 -84
  14. package/Application/BottomTab/DuoTabBar.tsx +35 -115
  15. package/Application/BottomTab/TabBarIcon.tsx +160 -72
  16. package/Application/BottomTab/TabScreen.tsx +48 -61
  17. package/Application/BottomTab/index.tsx +85 -125
  18. package/Application/BottomTab/metrics.ts +164 -0
  19. package/Application/BottomTab/tabItems.ts +46 -0
  20. package/Application/BottomTab/usePressLift.ts +32 -0
  21. package/Application/ModalIconButton.tsx +66 -0
  22. package/Application/ModalScreen.tsx +32 -15
  23. package/Application/__tests__/modalLayout.test.ts +128 -0
  24. package/Application/index.ts +10 -0
  25. package/Application/modalLayout.ts +177 -0
  26. package/Application/types.tabs.ts +5 -0
  27. package/Application/utils.ts +0 -36
  28. package/Layout/ReservedRegions.tsx +21 -4
  29. package/Layout/Screen.tsx +11 -2
  30. package/Layout/index.ts +2 -0
  31. package/Popup/PopupNotify.tsx +38 -68
  32. package/Popup/PopupPromotion.tsx +5 -4
  33. package/Popup/utils.ts +1 -26
  34. package/package.json +2 -1
  35. package/Application/BottomTab/DuoTabBar.parts.tsx +0 -202
  36. package/Application/BottomTab/badge.ts +0 -41
  37. package/Application/BottomTab/hooks.ts +0 -358
  38. package/Application/BottomTab/index.styles.ts +0 -30
@@ -4,39 +4,40 @@ import {
4
4
  useFocusEffect,
5
5
  } from '@react-navigation/native';
6
6
  import { createStackNavigator } from '@react-navigation/stack';
7
- import React, { useContext, useEffect } from 'react';
8
- import Animated, {
9
- useAnimatedStyle,
10
- useSharedValue,
11
- withTiming,
12
- } from 'react-native-reanimated';
7
+ import React, { useContext, useEffect, useMemo, useState } from 'react';
8
+ import { View } from 'react-native';
13
9
  import StackScreen from '../StackScreen';
14
10
  import { getOptions, getStackOptions } from '../navigationOptions';
15
11
  import { useAppState } from '../hooks';
16
12
  import { ApplicationContext, MiniAppContext } from '../../Context';
13
+ import { Styles } from '../../Consts';
17
14
  import { useScreenLayout } from '../../Layout';
18
15
  import { getHeaderRowOptions } from '../../Layout/utils';
19
- import { styles } from './index.styles';
16
+ import { BottomTabInsetContext } from './BottomTabInset';
20
17
 
21
18
  const Stack = createStackNavigator();
22
19
 
20
+ /**
21
+ * Content of one tab: its own stack, padded clear of the floating tab bar
22
+ * until a Screen inside claims that inset, plus screen_navigated tracking.
23
+ */
23
24
  const TabScreen: React.FC<any> = ({ route, navigation }) => {
24
25
  const { navigator } = useContext(ApplicationContext);
25
26
  const context = useContext<any>(MiniAppContext);
26
27
  const { nested, options, screen, initialParams } = route?.params;
27
28
  const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
28
29
 
29
- const opacityValue = 0.3;
30
- const scaleValue = 0.97;
31
- const opacity = useSharedValue(opacityValue);
32
- const scale = useSharedValue(scaleValue);
33
30
  const screenName = screen?.name || screen?.type?.name || 'Invalid';
34
31
  const { isBackgroundToForeground } = useAppState();
35
-
36
- const tabAnimatedStyle = useAnimatedStyle(() => ({
37
- opacity: opacity.value,
38
- transform: [{ scale: scale.value }],
39
- }));
32
+ // the floating tab bar covers the bottom of the tab: pad the content unless a Screen in it
33
+ // keeps clear of the bar itself (scroll content running under the bar)
34
+ const { inset: tabBarInset } = useContext(BottomTabInsetContext);
35
+ const [insetClaimed, setInsetClaimed] = useState(false);
36
+ const tabInsetValue = useMemo(
37
+ () => ({ inset: tabBarInset, claim: () => setInsetClaimed(true) }),
38
+ [tabBarInset],
39
+ );
40
+ const tabInsetStyle = { paddingBottom: insetClaimed ? 0 : tabBarInset };
40
41
 
41
42
  const onScreenNavigated = (pre: string, current: string) => {
42
43
  if (!isBackgroundToForeground) {
@@ -67,9 +68,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
67
68
  useFocusEffect(
68
69
  React.useCallback(
69
70
  () => {
70
- opacity.value = withTiming(1, { duration: 200 });
71
- scale.value = withTiming(1, { duration: 200 });
72
-
73
71
  setTimeout(() => {
74
72
  navigator?.maxApi?.getDataObserver?.(
75
73
  'current_screen',
@@ -81,13 +79,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
81
79
  },
82
80
  );
83
81
  }, 100);
84
-
85
- return () => {
86
- if (navigation.getState().index !== route?.params.index) {
87
- opacity.value = withTiming(opacityValue, { duration: 200 });
88
- scale.value = withTiming(scaleValue, { duration: 200 });
89
- }
90
- };
91
82
  },
92
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
93
84
  [],
@@ -125,36 +116,8 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
125
116
 
126
117
  if (nested) {
127
118
  return (
128
- <Animated.View style={[styles.container, tabAnimatedStyle]}>
129
- <Stack.Navigator screenOptions={headerStyleOptions}>
130
- <Stack.Screen
131
- name="Stack"
132
- component={StackScreen}
133
- initialParams={{
134
- screen,
135
- options: stackOptions,
136
- initialParams,
137
- bottomTab: 'nested',
138
- }}
139
- options={{ ...getStackOptions() }}
140
- />
141
- </Stack.Navigator>
142
- </Animated.View>
143
- );
144
- }
145
-
146
- return (
147
- <Animated.View
148
- style={[
149
- styles.container,
150
- {
151
- opacity,
152
- transform: [{ scale }],
153
- },
154
- ]}
155
- >
156
- <NavigationIndependentTree>
157
- <NavigationContainer>
119
+ <BottomTabInsetContext.Provider value={tabInsetValue}>
120
+ <View style={[Styles.flex, tabInsetStyle]}>
158
121
  <Stack.Navigator screenOptions={headerStyleOptions}>
159
122
  <Stack.Screen
160
123
  name="Stack"
@@ -163,14 +126,38 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
163
126
  screen,
164
127
  options: stackOptions,
165
128
  initialParams,
166
- bottomTab: 'default',
129
+ bottomTab: 'nested',
167
130
  }}
168
- options={{ ...getStackOptions(), ...options }}
131
+ options={{ ...getStackOptions() }}
169
132
  />
170
133
  </Stack.Navigator>
171
- </NavigationContainer>
172
- </NavigationIndependentTree>
173
- </Animated.View>
134
+ </View>
135
+ </BottomTabInsetContext.Provider>
136
+ );
137
+ }
138
+
139
+ return (
140
+ <BottomTabInsetContext.Provider value={tabInsetValue}>
141
+ <View style={[Styles.flex, tabInsetStyle]}>
142
+ <NavigationIndependentTree>
143
+ <NavigationContainer>
144
+ <Stack.Navigator screenOptions={headerStyleOptions}>
145
+ <Stack.Screen
146
+ name="Stack"
147
+ component={StackScreen}
148
+ initialParams={{
149
+ screen,
150
+ options: stackOptions,
151
+ initialParams,
152
+ bottomTab: 'default',
153
+ }}
154
+ options={{ ...getStackOptions(), ...options }}
155
+ />
156
+ </Stack.Navigator>
157
+ </NavigationContainer>
158
+ </NavigationIndependentTree>
159
+ </View>
160
+ </BottomTabInsetContext.Provider>
174
161
  );
175
162
  };
176
163
 
@@ -1,33 +1,59 @@
1
- import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
1
+ import {
2
+ BottomTabNavigationOptions,
3
+ createBottomTabNavigator,
4
+ } from '@react-navigation/bottom-tabs';
2
5
  import { useFocusEffect } from '@react-navigation/native';
3
- import React, { useContext, useEffect, useLayoutEffect } from 'react';
4
- import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
+ import React, { useContext, useLayoutEffect } from 'react';
5
7
  import {
8
+ Easing,
6
9
  NativeModules,
7
10
  Platform,
8
11
  StatusBar,
9
12
  useWindowDimensions,
10
- View,
11
13
  } from 'react-native';
12
- import Animated, {
13
- useAnimatedStyle,
14
- useSharedValue,
15
- withTiming,
16
- } from 'react-native-reanimated';
17
14
  import { Icon } from '../../Icon';
18
- import { exportFontFamily, Text } from '../../Text';
19
15
  import { BottomTabProps } from '../types';
20
- import BottomTabBar from './BottomTabBar';
21
- import { DuoTabBar } from './DuoTabBar';
16
+ import BottomTabBar, { hasCenterAction } from './BottomTabBar';
17
+ import { TabBarMetrics } from './metrics';
18
+ import { getTabBadge } from './TabBarIcon';
19
+ import { DuoTabBar, useDuoTabBarInset } from './DuoTabBar';
20
+ import { BottomTabInsetContext } from './BottomTabInset';
22
21
  import { DuoBarCompressionProvider } from './DuoBarCompression';
22
+ import TabScreen from './TabScreen';
23
23
  import { ApplicationContext } from '../../Context';
24
24
  import { useScreenLayout } from '../../Layout';
25
- import TabScreen from './TabScreen';
26
- import { getBadgeProps } from './badge';
27
- import { styles } from './index.styles';
28
25
 
29
26
  const Tab = createBottomTabNavigator();
30
27
 
28
+ // the tab screens slide in index order over 280ms, CSS `ease`
29
+ const TAB_SWITCH_SPEC = {
30
+ animation: 'timing' as const,
31
+ config: { duration: 280, easing: Easing.bezier(0.25, 0.1, 0.25, 1) },
32
+ };
33
+
34
+ /**
35
+ * scene progress is -1 for tabs before the active one, 1 for tabs after it: they wait a full
36
+ * width off screen on their side
37
+ */
38
+ const getTabSlide =
39
+ (width: number): BottomTabNavigationOptions['sceneStyleInterpolator'] =>
40
+ ({ current }) => ({
41
+ sceneStyle: {
42
+ transform: [
43
+ {
44
+ translateX: current.progress.interpolate({
45
+ inputRange: [-1, 0, 1],
46
+ outputRange: [-width, 0, width],
47
+ }),
48
+ },
49
+ ],
50
+ },
51
+ });
52
+
53
+ /**
54
+ * App tab navigator: the floating bar on phones, the rail or floating bar on
55
+ * iPhone Duo, tabs sliding in index order. Shares the bar inset with its tabs.
56
+ */
31
57
  const BottomTab: React.FC<BottomTabProps> = ({
32
58
  nested,
33
59
  tabs,
@@ -39,17 +65,15 @@ const BottomTab: React.FC<BottomTabProps> = ({
39
65
  }) => {
40
66
  const { theme, navigator } = useContext(ApplicationContext);
41
67
  const { isIPhoneDuo } = useScreenLayout();
42
- const dimensions = useWindowDimensions();
43
- const insets = useSafeAreaInsets();
68
+ const { width: windowWidth } = useWindowDimensions();
69
+ const barInset = useDuoTabBarInset();
70
+ // the center action's button rises above the bar, tab content leaves room for it too
71
+ const tabBarInset =
72
+ barInset && hasCenterAction(floatingButton, tabs.length)
73
+ ? barInset + TabBarMetrics.ctaLift
74
+ : barInset;
44
75
  const initialIndex = tabs.findIndex(i => i.name === initialRouteName);
45
- const indicatorAnimated = useSharedValue(0);
46
76
  const activeIndex = React.useRef(initialIndex > 0 ? initialIndex : 0);
47
- const itemWidth = dimensions.width / tabs.length;
48
-
49
- const indicatorStyle = useAnimatedStyle(() => ({
50
- width: itemWidth,
51
- transform: [{ translateX: indicatorAnimated.value }],
52
- }));
53
77
 
54
78
  useLayoutEffect(() => {
55
79
  navigation?.setOptions({
@@ -59,12 +83,6 @@ const BottomTab: React.FC<BottomTabProps> = ({
59
83
  });
60
84
  }, [navigation]);
61
85
 
62
- useEffect(() => {
63
- indicatorAnimated.value = withTiming(itemWidth * activeIndex.current, {
64
- duration: 200,
65
- });
66
- }, [itemWidth, indicatorAnimated]);
67
-
68
86
  // Focus, not mount: pushing a screen from a tab leaves BottomTab mounted but blurred, and
69
87
  // onFocus only fires on tab changes.
70
88
  useFocusEffect(
@@ -79,9 +97,6 @@ const BottomTab: React.FC<BottomTabProps> = ({
79
97
 
80
98
  const onFocus = (e: any) => {
81
99
  activeIndex.current = tabs.findIndex(i => e.target.includes(i.name));
82
- indicatorAnimated.value = withTiming(itemWidth * activeIndex.current, {
83
- duration: 200,
84
- });
85
100
 
86
101
  const isFirstTab = activeIndex.current === 0;
87
102
  const gestureEnabled = Platform.OS === 'ios' && isFirstTab;
@@ -113,67 +128,43 @@ const BottomTab: React.FC<BottomTabProps> = ({
113
128
  },
114
129
  };
115
130
 
116
- const isIOS = Platform.OS === 'ios';
117
- const bottomInset = isIOS ? Math.min(insets.bottom, 21) : insets.bottom;
118
-
119
131
  return (
120
132
  <DuoBarCompressionProvider
121
133
  behavior={compressionBehavior}
122
134
  tabCount={tabs.length}
123
135
  hasQr={!!floatingButton}
124
136
  >
125
- <Tab.Navigator
126
- screenOptions={{
127
- headerShown: false,
128
- headerTransparent: true,
129
- headerBackground: undefined,
130
- tabBarLabelPosition: 'below-icon',
131
- tabBarBackground: () => (
132
- <View
133
- style={[
134
- styles.container,
135
- {
136
- backgroundColor: theme.colors.background.surface,
137
- },
138
- ]}
139
- >
140
- <Animated.View
141
- style={[styles.indicatorContainer, indicatorStyle]}
142
- >
143
- <View
144
- style={[
145
- styles.indicator,
146
- { backgroundColor: theme.colors.primary },
147
- ]}
148
- />
149
- </Animated.View>
150
- </View>
151
- ),
152
- }}
153
- tabBar={props =>
154
- isIPhoneDuo ? (
155
- <DuoTabBar
156
- {...props}
157
- floatingButton={floatingButton}
158
- inactiveTintColor={theme.colors.text.hint}
159
- activeTintColor={theme.colors.primary}
160
- />
161
- ) : (
162
- <BottomTabBar
163
- {...props}
164
- floatingButton={floatingButton}
165
- inactiveTintColor={theme.colors.text.hint}
166
- activeTintColor={theme.colors.primary}
167
- style={[{ height: 64 + bottomInset }]}
168
- />
169
- )
170
- }
171
- initialRouteName={initialRouteName}
172
- >
173
- {tabs.map((item, index) => {
174
- const { badgeLabel, badgeStyle } = getBadgeProps(item?.badgeLabel);
175
-
176
- return (
137
+ <BottomTabInsetContext.Provider value={{ inset: tabBarInset }}>
138
+ <Tab.Navigator
139
+ screenOptions={{
140
+ headerShown: false,
141
+ headerTransparent: true,
142
+ headerBackground: undefined,
143
+ tabBarLabelPosition: 'below-icon',
144
+ animation: 'shift',
145
+ transitionSpec: TAB_SWITCH_SPEC,
146
+ sceneStyleInterpolator: getTabSlide(windowWidth),
147
+ }}
148
+ tabBar={props =>
149
+ isIPhoneDuo ? (
150
+ <DuoTabBar
151
+ {...props}
152
+ floatingButton={floatingButton}
153
+ inactiveTintColor={theme.colors.text.secondary}
154
+ activeTintColor={theme.colors.primary}
155
+ />
156
+ ) : (
157
+ <BottomTabBar
158
+ {...props}
159
+ floatingButton={floatingButton}
160
+ inactiveTintColor={theme.colors.text.secondary}
161
+ activeTintColor={theme.colors.primary}
162
+ />
163
+ )
164
+ }
165
+ initialRouteName={initialRouteName}
166
+ >
167
+ {tabs.map((item, index) => (
177
168
  <Tab.Screen
178
169
  key={`BottomTab-${item.name}`}
179
170
  name={item.name}
@@ -181,48 +172,17 @@ const BottomTab: React.FC<BottomTabProps> = ({
181
172
  initialParams={{ ...item, nested, index }}
182
173
  listeners={handler}
183
174
  options={{
184
- tabBarLabel: ({ focused, color }) => {
185
- return (
186
- <Text
187
- numberOfLines={1}
188
- typography={
189
- focused ? 'label_xs_medium' : 'description_xs_regular'
190
- }
191
- color={color}
192
- >
193
- {item.label}
194
- </Text>
195
- );
196
- },
197
175
  title: item.label,
198
- tabBarStyle: [
199
- {
200
- height: 64,
201
- },
202
- styles.tabStyle,
203
- ],
204
- tabBarBadge: badgeLabel,
205
- tabBarBadgeStyle: [
206
- {
207
- borderColor: 'white',
208
- borderWidth: 2,
209
- fontSize: 10,
210
- lineHeight: 14,
211
- fontWeight: 'bold',
212
- alignSelf: 'center',
213
- fontFamily: exportFontFamily('bold'),
214
- },
215
- badgeStyle,
216
- ],
217
- tabBarIcon: ({ color }) => (
218
- <Icon source={item.icon} color={color} size={28} />
176
+ tabBarBadge: getTabBadge(item.badgeLabel, item.showDot),
177
+ tabBarIcon: ({ color, size }) => (
178
+ <Icon source={item.icon} color={color} size={size} />
219
179
  ),
220
180
  ...item,
221
181
  }}
222
182
  />
223
- );
224
- })}
225
- </Tab.Navigator>
183
+ ))}
184
+ </Tab.Navigator>
185
+ </BottomTabInsetContext.Provider>
226
186
  </DuoBarCompressionProvider>
227
187
  );
228
188
  };
@@ -0,0 +1,164 @@
1
+ import { BoxShadowValue } from 'react-native';
2
+ import { Easing } from 'react-native-reanimated';
3
+ import { Colors, Radius, Spacing } from '../../Consts';
4
+
5
+ /**
6
+ * Navigation bar measurements (Figma "Specs Navigation", DS-984), kept in sync with
7
+ * momo-native-kits `TabBarMetrics`
8
+ * - horizontal: floating container with items split evenly, center action in a notch
9
+ * - vertical: iPhone Duo rail, center action under the container
10
+ */
11
+ const TabBarMetrics = {
12
+ height: 68,
13
+ margin: Spacing.S,
14
+ paddingVertical: Spacing.S,
15
+ paddingHorizontal: Spacing.M,
16
+ itemGap: Spacing.XS,
17
+ iconSize: 24,
18
+ // active indicator behind the icon: 24 icon + XS·M padding
19
+ indicatorPaddingVertical: Spacing.XS,
20
+ indicatorPaddingHorizontal: Spacing.M,
21
+ indicatorRadius: Radius.XL,
22
+ indicatorHeight: 32,
23
+ // home indicator / gesture bar area under the container
24
+ systemIndicatorHeight: 24,
25
+ // unfolded foldables (short side from 600, the smallest in the spec) use 65% of the short
26
+ // side, centered; phones and folded outer displays fill the window
27
+ foldableMinShortSide: 600,
28
+ foldableWidthRatio: 0.65,
29
+ ctaSlotWidth: 92,
30
+ ctaSize: 48,
31
+ // the icon sits 10 inside the 48 button (prototype .cta: 28 icon), 12 inside the 52 rail one
32
+ ctaIconSize: 28,
33
+ // the button rises 22 above the container top edge
34
+ ctaLift: 22,
35
+ ctaLabelGap: Spacing.M,
36
+ ctaLabelHeight: 18,
37
+ railContainerWidth: 56,
38
+ railPaddingHorizontal: Spacing.XS,
39
+ railPaddingBottom: Spacing.S,
40
+ railItemWidth: 48,
41
+ railItemHeight: 62,
42
+ railIndicatorHeight: 40,
43
+ railIndicatorPaddingVertical: Spacing.S,
44
+ railIndicatorRadius: 20,
45
+ railCtaGap: Spacing.L,
46
+ railCtaSize: 52,
47
+ railCtaLabelGap: Spacing.XS,
48
+ railCtaWidth: 68,
49
+ railCtaHeight: 70,
50
+ badgeMaxWidth: 58,
51
+ };
52
+
53
+ /**
54
+ * motion of the navigation bar (MoMo Navigation Prototype):
55
+ * - one active indicator slides to the selected item, overshooting a little
56
+ * - icons cross-fade and the label color follows
57
+ * - the center action shrinks while pressed
58
+ */
59
+ const TabBarMotion = {
60
+ indicator: { duration: 550, easing: Easing.bezier(0.34, 1.4, 0.5, 1) },
61
+ tint: { duration: 250 },
62
+ ctaPress: { duration: 150 },
63
+ ctaPressedScale: 0.92,
64
+ };
65
+
66
+ /**
67
+ * Figma Effect/Popover on the center action button: 8% y8 blur 24 spread -12, 4% y12 blur 32
68
+ * spread -16
69
+ */
70
+ const CTA_SHADOW: BoxShadowValue[] = [
71
+ {
72
+ offsetX: 0,
73
+ offsetY: 8,
74
+ blurRadius: 24,
75
+ spreadDistance: -12,
76
+ color: `${Colors.black_20}14`,
77
+ },
78
+ {
79
+ offsetX: 0,
80
+ offsetY: 12,
81
+ blurRadius: 32,
82
+ spreadDistance: -16,
83
+ color: `${Colors.black_20}0A`,
84
+ },
85
+ ];
86
+
87
+ /**
88
+ * inner light and tint of the active indicator: white 12% from below, primary 12% from above
89
+ */
90
+ const getIndicatorShadow = (primary: string): BoxShadowValue[] => [
91
+ {
92
+ offsetX: 0,
93
+ offsetY: -2,
94
+ blurRadius: 2,
95
+ color: `${Colors.black_01}1F`,
96
+ inset: true,
97
+ },
98
+ { offsetX: 0, offsetY: 1, blurRadius: 2, color: `${primary}1F`, inset: true },
99
+ ];
100
+
101
+ /**
102
+ * notch dipped into the container top edge under the center action (Figma "Backgroud" path):
103
+ * starts NOTCH_HALF_WIDTH left of the center, then cubic segments as [control1, control2, end],
104
+ * each point [dx from the center, dy below the top edge]
105
+ */
106
+ const NOTCH_HALF_WIDTH = 50.758;
107
+ const NOTCH_CURVES: [number, number][][] = [
108
+ [
109
+ [-39.749, 0],
110
+ [-31.231, 9.765],
111
+ [-24.563, 18.526],
112
+ ],
113
+ [
114
+ [-18.928, 25.93],
115
+ [-10.022, 30.709],
116
+ [0, 30.709],
117
+ ],
118
+ [
119
+ [10.022, 30.709],
120
+ [18.928, 25.93],
121
+ [24.563, 18.526],
122
+ ],
123
+ [
124
+ [31.231, 9.765],
125
+ [39.749, 0],
126
+ [NOTCH_HALF_WIDTH, 0],
127
+ ],
128
+ ];
129
+
130
+ /**
131
+ * width of the horizontal bar: 65% of the short side on an unfolded foldable, otherwise the
132
+ * window minus the side safe areas and margins
133
+ */
134
+ const getHorizontalBarWidth = (
135
+ width: number,
136
+ height: number,
137
+ insetLeft: number,
138
+ insetRight: number,
139
+ ) => {
140
+ const shortSide = Math.min(width, height);
141
+ if (shortSide >= TabBarMetrics.foldableMinShortSide) {
142
+ return shortSide * TabBarMetrics.foldableWidthRatio;
143
+ }
144
+ return width - insetLeft - insetRight - 2 * TabBarMetrics.margin;
145
+ };
146
+
147
+ /**
148
+ * height of the vertical rail container, plus the center action under it
149
+ */
150
+ const getRailHeight = (tabCount: number, hasCta: boolean) =>
151
+ tabCount * TabBarMetrics.railItemHeight +
152
+ TabBarMetrics.railPaddingBottom +
153
+ (hasCta ? TabBarMetrics.railCtaGap + TabBarMetrics.railCtaHeight : 0);
154
+
155
+ export {
156
+ TabBarMetrics,
157
+ TabBarMotion,
158
+ getHorizontalBarWidth,
159
+ getRailHeight,
160
+ getIndicatorShadow,
161
+ CTA_SHADOW,
162
+ NOTCH_CURVES,
163
+ NOTCH_HALF_WIDTH,
164
+ };
@@ -0,0 +1,46 @@
1
+ import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
2
+ import { CommonActions, Route } from '@react-navigation/native';
3
+
4
+ /**
5
+ * navigation items of the tab bar, with the press handlers of the system tab bar
6
+ */
7
+ const getTabItems = ({ state, descriptors, navigation }: BottomTabBarProps) => {
8
+ return state.routes.map((route: Route<string>, index: number) => {
9
+ const { options } = descriptors[route.key];
10
+ const focused = index === state.index;
11
+ const label = `${options.title ?? route.name}`;
12
+
13
+ const onPress = () => {
14
+ const event = navigation.emit({
15
+ type: 'tabPress',
16
+ target: route.key,
17
+ canPreventDefault: true,
18
+ });
19
+ if (!focused && !event.defaultPrevented) {
20
+ navigation.dispatch({
21
+ ...CommonActions.navigate(route.name),
22
+ target: state.key,
23
+ });
24
+ }
25
+ };
26
+
27
+ const onLongPress = () => {
28
+ navigation.emit({ type: 'tabLongPress', target: route.key });
29
+ };
30
+
31
+ return {
32
+ route,
33
+ focused,
34
+ label,
35
+ icon: options.tabBarIcon as any,
36
+ badge: options.tabBarBadge,
37
+ onPress,
38
+ onLongPress,
39
+ };
40
+ });
41
+ };
42
+
43
+ type TabItem = ReturnType<typeof getTabItems>[number];
44
+
45
+ export { getTabItems };
46
+ export type { TabItem };
@@ -0,0 +1,32 @@
1
+ import {
2
+ useAnimatedStyle,
3
+ useReducedMotion,
4
+ useSharedValue,
5
+ withSpring,
6
+ } from 'react-native-reanimated';
7
+
8
+ const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
9
+ // the button grows by a tenth while held
10
+ const PRESSED_SCALE = 1.1;
11
+
12
+ /**
13
+ * lift for a single button, e.g. the compressed rail
14
+ */
15
+ const usePressLift = () => {
16
+ const reduceMotion = useReducedMotion();
17
+ const lift = useSharedValue(0);
18
+ const style = useAnimatedStyle(() => ({
19
+ transform: [{ scale: 1 + lift.value * (PRESSED_SCALE - 1) }],
20
+ }));
21
+ const onPressIn = () => {
22
+ if (!reduceMotion) {
23
+ lift.value = withSpring(1, SPRING);
24
+ }
25
+ };
26
+ const onPressOut = () => {
27
+ lift.value = withSpring(0, SPRING);
28
+ };
29
+ return { style, onPressIn, onPressOut };
30
+ };
31
+
32
+ export { usePressLift };