@momo-kits/foundation 0.168.1-beta.25 → 0.168.1-beta.26
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.
- package/Application/BottomTab/BottomTabBar.tsx +18 -18
- package/Application/BottomTab/DuoTabBar.tsx +15 -11
- package/Application/BottomTab/index.tsx +46 -41
- package/Application/BottomTab/metrics.ts +0 -1
- package/Application/BottomTab/usePressLift.ts +32 -0
- package/Application/types.ts +5 -0
- package/package.json +1 -1
- package/Application/BottomTab/useLiquidSelection.ts +0 -374
|
@@ -13,7 +13,7 @@ import Animated, {
|
|
|
13
13
|
withTiming,
|
|
14
14
|
} from 'react-native-reanimated';
|
|
15
15
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
16
|
-
import { Colors,
|
|
16
|
+
import { Colors, Spacing } from '../../Consts';
|
|
17
17
|
import { Icon } from '../../Icon';
|
|
18
18
|
import { Text } from '../../Text';
|
|
19
19
|
import { FloatingButtonProps } from '../types';
|
|
@@ -54,27 +54,23 @@ const getSystemIndicatorHeight = (bottomInset: number) =>
|
|
|
54
54
|
);
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* center action press: the button shrinks while held
|
|
58
|
-
* (label in a Background/primary pill, white text)
|
|
57
|
+
* center action press: the button shrinks while held; the label stays plain text
|
|
59
58
|
*/
|
|
60
59
|
const useCenterActionPress = () => {
|
|
61
|
-
const [pressed, setPressed] = useState(false);
|
|
62
60
|
const scale = useSharedValue(1);
|
|
63
61
|
const buttonStyle = useAnimatedStyle(() => ({
|
|
64
62
|
transform: [{ scale: scale.value }],
|
|
65
63
|
}));
|
|
66
64
|
const onPressIn = () => {
|
|
67
|
-
setPressed(true);
|
|
68
65
|
scale.value = withTiming(
|
|
69
66
|
TabBarMotion.ctaPressedScale,
|
|
70
67
|
TabBarMotion.ctaPress,
|
|
71
68
|
);
|
|
72
69
|
};
|
|
73
70
|
const onPressOut = () => {
|
|
74
|
-
setPressed(false);
|
|
75
71
|
scale.value = withTiming(1, TabBarMotion.ctaPress);
|
|
76
72
|
};
|
|
77
|
-
return {
|
|
73
|
+
return { buttonStyle, onPressIn, onPressOut };
|
|
78
74
|
};
|
|
79
75
|
|
|
80
76
|
/**
|
|
@@ -87,14 +83,13 @@ const CenterAction: React.FC<{
|
|
|
87
83
|
const { theme } = useContext(ApplicationContext);
|
|
88
84
|
const context = useContext<any>(MiniAppContext);
|
|
89
85
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
90
|
-
const { icon, label, onPress } = floatingButton;
|
|
91
|
-
const {
|
|
92
|
-
useCenterActionPress();
|
|
86
|
+
const { icon, label, onPress, labelContent } = floatingButton;
|
|
87
|
+
const { buttonStyle, onPressIn, onPressOut } = useCenterActionPress();
|
|
93
88
|
const labelText = (
|
|
94
89
|
<Text
|
|
95
90
|
numberOfLines={1}
|
|
96
91
|
typography={'label_s_medium'}
|
|
97
|
-
color={
|
|
92
|
+
color={theme.colors.text.secondary}
|
|
98
93
|
>
|
|
99
94
|
{label}
|
|
100
95
|
</Text>
|
|
@@ -127,17 +122,17 @@ const CenterAction: React.FC<{
|
|
|
127
122
|
icon
|
|
128
123
|
)}
|
|
129
124
|
</Animated.View>
|
|
130
|
-
<View
|
|
131
|
-
style={[
|
|
132
|
-
styles.ctaLabel,
|
|
133
|
-
pressed && { backgroundColor: theme.colors.primary },
|
|
134
|
-
]}
|
|
135
|
-
>
|
|
125
|
+
<View style={styles.ctaLabel}>
|
|
136
126
|
{fixedFontScale ? (
|
|
137
127
|
<FixedScaleText>{labelText}</FixedScaleText>
|
|
138
128
|
) : (
|
|
139
129
|
labelText
|
|
140
130
|
)}
|
|
131
|
+
{labelContent && (
|
|
132
|
+
<View pointerEvents={'none'} style={styles.ctaLabelOverlay}>
|
|
133
|
+
{labelContent}
|
|
134
|
+
</View>
|
|
135
|
+
)}
|
|
141
136
|
</View>
|
|
142
137
|
</Pressable>
|
|
143
138
|
</View>
|
|
@@ -326,11 +321,16 @@ const styles = StyleSheet.create({
|
|
|
326
321
|
alignItems: 'center',
|
|
327
322
|
justifyContent: 'center',
|
|
328
323
|
},
|
|
324
|
+
// custom label content over the label text, centered and unclipped
|
|
325
|
+
ctaLabelOverlay: {
|
|
326
|
+
...StyleSheet.absoluteFillObject,
|
|
327
|
+
alignItems: 'center',
|
|
328
|
+
justifyContent: 'center',
|
|
329
|
+
},
|
|
329
330
|
ctaLabel: {
|
|
330
331
|
maxWidth: TabBarMetrics.ctaSlotWidth,
|
|
331
332
|
minHeight: TabBarMetrics.ctaLabelHeight,
|
|
332
333
|
paddingHorizontal: Spacing.XXS,
|
|
333
|
-
borderRadius: Radius.XL,
|
|
334
334
|
justifyContent: 'center',
|
|
335
335
|
},
|
|
336
336
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
@@ -25,7 +25,7 @@ import { CurvedContainer } from './CurvedContainer';
|
|
|
25
25
|
import { CTA_SHADOW, getHorizontalBarWidth, TabBarMetrics } from './metrics';
|
|
26
26
|
import { getTabItems, TabItem } from './tabItems';
|
|
27
27
|
import { FixedScaleText } from './TabBarIcon';
|
|
28
|
-
import { usePressLift } from './
|
|
28
|
+
import { usePressLift } from './usePressLift';
|
|
29
29
|
import { ActiveIndicator } from './ActiveIndicator';
|
|
30
30
|
|
|
31
31
|
type Props = BottomTabBarProps & {
|
|
@@ -120,8 +120,7 @@ const RailCenterAction: React.FC<{ floatingButton: FloatingButtonProps }> = ({
|
|
|
120
120
|
floatingButton,
|
|
121
121
|
}) => {
|
|
122
122
|
const { theme } = useContext(ApplicationContext);
|
|
123
|
-
const {
|
|
124
|
-
useCenterActionPress();
|
|
123
|
+
const { buttonStyle, onPressIn, onPressOut } = useCenterActionPress();
|
|
125
124
|
return (
|
|
126
125
|
<Pressable
|
|
127
126
|
accessibilityRole={'button'}
|
|
@@ -140,21 +139,21 @@ const RailCenterAction: React.FC<{ floatingButton: FloatingButtonProps }> = ({
|
|
|
140
139
|
>
|
|
141
140
|
<CtaIcon icon={floatingButton.icon} />
|
|
142
141
|
</Animated.View>
|
|
143
|
-
<View
|
|
144
|
-
style={[
|
|
145
|
-
styles.railCtaLabel,
|
|
146
|
-
pressed && { backgroundColor: theme.colors.primary },
|
|
147
|
-
]}
|
|
148
|
-
>
|
|
142
|
+
<View style={styles.railCtaLabel}>
|
|
149
143
|
<FixedScaleText>
|
|
150
144
|
<Text
|
|
151
145
|
numberOfLines={1}
|
|
152
146
|
typography={'label_xs_medium'}
|
|
153
|
-
color={
|
|
147
|
+
color={theme.colors.text.secondary}
|
|
154
148
|
>
|
|
155
149
|
{floatingButton.label}
|
|
156
150
|
</Text>
|
|
157
151
|
</FixedScaleText>
|
|
152
|
+
{floatingButton.labelContent && (
|
|
153
|
+
<View pointerEvents={'none'} style={styles.railCtaLabelOverlay}>
|
|
154
|
+
{floatingButton.labelContent}
|
|
155
|
+
</View>
|
|
156
|
+
)}
|
|
158
157
|
</View>
|
|
159
158
|
</Pressable>
|
|
160
159
|
);
|
|
@@ -337,9 +336,14 @@ const styles = StyleSheet.create({
|
|
|
337
336
|
justifyContent: 'center',
|
|
338
337
|
gap: TabBarMetrics.railCtaLabelGap,
|
|
339
338
|
},
|
|
339
|
+
// custom label content over the label text, centered and unclipped
|
|
340
|
+
railCtaLabelOverlay: {
|
|
341
|
+
...StyleSheet.absoluteFillObject,
|
|
342
|
+
alignItems: 'center',
|
|
343
|
+
justifyContent: 'center',
|
|
344
|
+
},
|
|
340
345
|
railCtaLabel: {
|
|
341
346
|
paddingHorizontal: Spacing.XS,
|
|
342
|
-
borderRadius: Radius.XL,
|
|
343
347
|
},
|
|
344
348
|
railCtaButton: {
|
|
345
349
|
boxShadow: CTA_SHADOW,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
BottomTabNavigationOptions,
|
|
3
|
+
createBottomTabNavigator,
|
|
4
|
+
} from '@react-navigation/bottom-tabs';
|
|
2
5
|
import {
|
|
3
6
|
NavigationContainer,
|
|
4
7
|
NavigationIndependentTree,
|
|
@@ -12,12 +15,15 @@ import React, {
|
|
|
12
15
|
useMemo,
|
|
13
16
|
useState,
|
|
14
17
|
} from 'react';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
import {
|
|
19
|
+
Easing,
|
|
20
|
+
NativeModules,
|
|
21
|
+
Platform,
|
|
22
|
+
StatusBar,
|
|
23
|
+
StyleSheet,
|
|
24
|
+
useWindowDimensions,
|
|
25
|
+
View,
|
|
26
|
+
} from 'react-native';
|
|
21
27
|
import { Icon } from '../../Icon';
|
|
22
28
|
import StackScreen from '../StackScreen';
|
|
23
29
|
import { BottomTabProps } from '../types';
|
|
@@ -33,6 +39,31 @@ import { useScreenLayout } from '../../Layout';
|
|
|
33
39
|
import { getHeaderRowOptions } from '../../Layout/utils';
|
|
34
40
|
|
|
35
41
|
const Tab = createBottomTabNavigator();
|
|
42
|
+
|
|
43
|
+
// the tab screens slide in index order over 280ms, CSS `ease`
|
|
44
|
+
const TAB_SWITCH_SPEC = {
|
|
45
|
+
animation: 'timing' as const,
|
|
46
|
+
config: { duration: 280, easing: Easing.bezier(0.25, 0.1, 0.25, 1) },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* scene progress is -1 for tabs before the active one, 1 for tabs after it: they wait a full
|
|
51
|
+
* width off screen on their side
|
|
52
|
+
*/
|
|
53
|
+
const getTabSlide =
|
|
54
|
+
(width: number): BottomTabNavigationOptions['sceneStyleInterpolator'] =>
|
|
55
|
+
({ current }) => ({
|
|
56
|
+
sceneStyle: {
|
|
57
|
+
transform: [
|
|
58
|
+
{
|
|
59
|
+
translateX: current.progress.interpolate({
|
|
60
|
+
inputRange: [-1, 0, 1],
|
|
61
|
+
outputRange: [-width, 0, width],
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
36
67
|
const Stack = createStackNavigator();
|
|
37
68
|
|
|
38
69
|
const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
@@ -41,10 +72,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
41
72
|
const { nested, options, screen, initialParams } = route?.params;
|
|
42
73
|
const headerStyleOptions = getHeaderRowOptions(useScreenLayout());
|
|
43
74
|
|
|
44
|
-
const opacityValue = 0.3;
|
|
45
|
-
const scaleValue = 0.97;
|
|
46
|
-
const opacity = useSharedValue(opacityValue);
|
|
47
|
-
const scale = useSharedValue(scaleValue);
|
|
48
75
|
const screenName = screen?.name || screen?.type?.name || 'Invalid';
|
|
49
76
|
const { isBackgroundToForeground } = useAppState();
|
|
50
77
|
// the floating tab bar covers the bottom of the tab: pad the content unless a Screen in it
|
|
@@ -57,11 +84,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
57
84
|
);
|
|
58
85
|
const tabInsetStyle = { paddingBottom: insetClaimed ? 0 : tabBarInset };
|
|
59
86
|
|
|
60
|
-
const tabAnimatedStyle = useAnimatedStyle(() => ({
|
|
61
|
-
opacity: opacity.value,
|
|
62
|
-
transform: [{ scale: scale.value }],
|
|
63
|
-
}));
|
|
64
|
-
|
|
65
87
|
const onScreenNavigated = (pre: string, current: string) => {
|
|
66
88
|
if (!isBackgroundToForeground) {
|
|
67
89
|
const data: any = {
|
|
@@ -91,9 +113,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
91
113
|
useFocusEffect(
|
|
92
114
|
React.useCallback(
|
|
93
115
|
() => {
|
|
94
|
-
opacity.value = withTiming(1, { duration: 200 });
|
|
95
|
-
scale.value = withTiming(1, { duration: 200 });
|
|
96
|
-
|
|
97
116
|
setTimeout(() => {
|
|
98
117
|
navigator?.maxApi?.getDataObserver?.(
|
|
99
118
|
'current_screen',
|
|
@@ -105,13 +124,6 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
105
124
|
},
|
|
106
125
|
);
|
|
107
126
|
}, 100);
|
|
108
|
-
|
|
109
|
-
return () => {
|
|
110
|
-
if (navigation.getState().index !== route?.params.index) {
|
|
111
|
-
opacity.value = withTiming(opacityValue, { duration: 200 });
|
|
112
|
-
scale.value = withTiming(scaleValue, { duration: 200 });
|
|
113
|
-
}
|
|
114
|
-
};
|
|
115
127
|
},
|
|
116
128
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
117
129
|
[],
|
|
@@ -150,9 +162,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
150
162
|
if (nested) {
|
|
151
163
|
return (
|
|
152
164
|
<BottomTabInsetContext.Provider value={tabInsetValue}>
|
|
153
|
-
<
|
|
154
|
-
style={[styles.container, tabInsetStyle, tabAnimatedStyle]}
|
|
155
|
-
>
|
|
165
|
+
<View style={[styles.container, tabInsetStyle]}>
|
|
156
166
|
<Stack.Navigator screenOptions={headerStyleOptions}>
|
|
157
167
|
<Stack.Screen
|
|
158
168
|
name="Stack"
|
|
@@ -166,23 +176,14 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
166
176
|
options={{ ...getStackOptions() }}
|
|
167
177
|
/>
|
|
168
178
|
</Stack.Navigator>
|
|
169
|
-
</
|
|
179
|
+
</View>
|
|
170
180
|
</BottomTabInsetContext.Provider>
|
|
171
181
|
);
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
return (
|
|
175
185
|
<BottomTabInsetContext.Provider value={tabInsetValue}>
|
|
176
|
-
<
|
|
177
|
-
style={[
|
|
178
|
-
styles.container,
|
|
179
|
-
tabInsetStyle,
|
|
180
|
-
{
|
|
181
|
-
opacity,
|
|
182
|
-
transform: [{ scale }],
|
|
183
|
-
},
|
|
184
|
-
]}
|
|
185
|
-
>
|
|
186
|
+
<View style={[styles.container, tabInsetStyle]}>
|
|
186
187
|
<NavigationIndependentTree>
|
|
187
188
|
<NavigationContainer>
|
|
188
189
|
<Stack.Navigator screenOptions={headerStyleOptions}>
|
|
@@ -200,7 +201,7 @@ const TabScreen: React.FC<any> = ({ route, navigation }) => {
|
|
|
200
201
|
</Stack.Navigator>
|
|
201
202
|
</NavigationContainer>
|
|
202
203
|
</NavigationIndependentTree>
|
|
203
|
-
</
|
|
204
|
+
</View>
|
|
204
205
|
</BottomTabInsetContext.Provider>
|
|
205
206
|
);
|
|
206
207
|
};
|
|
@@ -216,6 +217,7 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
216
217
|
}) => {
|
|
217
218
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
218
219
|
const { isIPhoneDuo } = useScreenLayout();
|
|
220
|
+
const { width: windowWidth } = useWindowDimensions();
|
|
219
221
|
const barInset = useDuoTabBarInset();
|
|
220
222
|
// the center action's button rises above the bar, tab content leaves room for it too
|
|
221
223
|
const tabBarInset =
|
|
@@ -291,6 +293,9 @@ const BottomTab: React.FC<BottomTabProps> = ({
|
|
|
291
293
|
headerTransparent: true,
|
|
292
294
|
headerBackground: undefined,
|
|
293
295
|
tabBarLabelPosition: 'below-icon',
|
|
296
|
+
animation: 'shift',
|
|
297
|
+
transitionSpec: TAB_SWITCH_SPEC,
|
|
298
|
+
sceneStyleInterpolator: getTabSlide(windowWidth),
|
|
294
299
|
}}
|
|
295
300
|
tabBar={props =>
|
|
296
301
|
isIPhoneDuo ? (
|
|
@@ -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 };
|
package/Application/types.ts
CHANGED
|
@@ -345,6 +345,11 @@ export type FloatingButtonProps = {
|
|
|
345
345
|
icon: string | React.ReactNode;
|
|
346
346
|
label: string;
|
|
347
347
|
onPress: () => void;
|
|
348
|
+
/**
|
|
349
|
+
* Drawn over the label text, which stays, e.g. a Lottie of the label; centered on the
|
|
350
|
+
* label and unclipped. `icon` takes the button part (inside the 48 button)
|
|
351
|
+
*/
|
|
352
|
+
labelContent?: React.ReactNode;
|
|
348
353
|
};
|
|
349
354
|
|
|
350
355
|
export type AnimatedHeader = {
|
package/package.json
CHANGED
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import { useCallback, useContext, useEffect, useMemo, useRef } from 'react';
|
|
2
|
-
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
-
import {
|
|
4
|
-
clamp,
|
|
5
|
-
useAnimatedReaction,
|
|
6
|
-
useAnimatedStyle,
|
|
7
|
-
useReducedMotion,
|
|
8
|
-
useSharedValue,
|
|
9
|
-
withSequence,
|
|
10
|
-
withSpring,
|
|
11
|
-
withTiming,
|
|
12
|
-
} from 'react-native-reanimated';
|
|
13
|
-
import { scheduleOnRN } from 'react-native-worklets';
|
|
14
|
-
import { ApplicationContext } from '../../Context';
|
|
15
|
-
|
|
16
|
-
type Axis = 'x' | 'y';
|
|
17
|
-
|
|
18
|
-
type Options = {
|
|
19
|
-
count: number;
|
|
20
|
-
// distance between two neighbouring tab centers
|
|
21
|
-
pitch: number;
|
|
22
|
-
// position of the first tab's center inside the gesture view
|
|
23
|
-
firstCenter: number;
|
|
24
|
-
axis: Axis;
|
|
25
|
-
index: number;
|
|
26
|
-
onSelect: (index: number) => void;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
|
|
30
|
-
// the lens trails the finger a little while dragged, like the system tab bar
|
|
31
|
-
const DRAG_SPRING = { damping: 24, stiffness: 600, mass: 0.6 };
|
|
32
|
-
const REDUCED_TIMING = { duration: 200 };
|
|
33
|
-
// while pressed the selection bubble grows into a lens past the bar's edges (80x54 to about 96x70)
|
|
34
|
-
const LENS_SCALE_X = 1.2;
|
|
35
|
-
const LENS_SCALE_Y = 1.3;
|
|
36
|
-
// a moving lens stretches along its path and thins across it, eased per frame so the
|
|
37
|
-
// shape follows the speed smoothly
|
|
38
|
-
const STRETCH_PER_POINT = 0.012;
|
|
39
|
-
const MAX_STRETCH = 0.25;
|
|
40
|
-
const STRETCH_EASING = 0.25;
|
|
41
|
-
// once eased below this the lens is round again
|
|
42
|
-
const REST_STRETCH = 0.002;
|
|
43
|
-
// a tap is shorter than the lift: the lens finishes rising, then settles, one smooth pulse
|
|
44
|
-
const TAP_LIFT_DURATION = 120;
|
|
45
|
-
// the bar swells a little under the finger
|
|
46
|
-
const BAR_SWELL = 0.04;
|
|
47
|
-
// the tab the lens lands on pops
|
|
48
|
-
const ICON_POP = 1.15;
|
|
49
|
-
// movement before a press turns into a drag, below it the tab's touchable handles the tap
|
|
50
|
-
const DRAG_SLOP = 10;
|
|
51
|
-
// UIScrollView rubber band constant
|
|
52
|
-
const RUBBER_BAND = 0.55;
|
|
53
|
-
// a cancelled press or a prevented switch: the lens returns to the selected tab after this
|
|
54
|
-
const RESYNC_DELAY = 100;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* resistance past the first and last tab, the further the finger goes the less the lens follows
|
|
58
|
-
*/
|
|
59
|
-
const rubberBand = (value: number, max: number, dimension: number) => {
|
|
60
|
-
'worklet';
|
|
61
|
-
const band = (overshoot: number) =>
|
|
62
|
-
(1 - 1 / ((overshoot * RUBBER_BAND) / dimension + 1)) * dimension;
|
|
63
|
-
if (value < 0) {
|
|
64
|
-
return -band(-value);
|
|
65
|
-
}
|
|
66
|
-
if (value > max) {
|
|
67
|
-
return max + band(value - max);
|
|
68
|
-
}
|
|
69
|
-
return value;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* iOS 26 tab bar selection motion, without the glass material:
|
|
74
|
-
* - press lifts the selection into a lens over the pressed tab and swells the bar, release
|
|
75
|
-
* settles it back (the tab's touchable drives this through pressIn / pressOut)
|
|
76
|
-
* - dragging slides the lens under the finger, stretched by its speed and rubber banded
|
|
77
|
-
* past the ends, with a tick on each tab it enters; releasing selects the tab under it.
|
|
78
|
-
* The drag cancels the touchable once it activates, so a press is either a tap or a drag
|
|
79
|
-
*/
|
|
80
|
-
const useLiquidSelection = ({
|
|
81
|
-
count,
|
|
82
|
-
pitch,
|
|
83
|
-
firstCenter,
|
|
84
|
-
axis,
|
|
85
|
-
index,
|
|
86
|
-
onSelect,
|
|
87
|
-
}: Options) => {
|
|
88
|
-
const { navigator } = useContext<any>(ApplicationContext);
|
|
89
|
-
const reduceMotion = useReducedMotion();
|
|
90
|
-
|
|
91
|
-
const offset = useSharedValue(index * pitch);
|
|
92
|
-
// where the lens is heading, so a re-render doesn't restart a spring already going there
|
|
93
|
-
const destination = useSharedValue(index * pitch);
|
|
94
|
-
const selected = useSharedValue(index);
|
|
95
|
-
const hovered = useSharedValue(index);
|
|
96
|
-
const lift = useSharedValue(0);
|
|
97
|
-
const dragging = useSharedValue(false);
|
|
98
|
-
const stretch = useSharedValue(0);
|
|
99
|
-
const metrics = useSharedValue({ count, pitch, firstCenter });
|
|
100
|
-
const measured = useRef(pitch > 0);
|
|
101
|
-
|
|
102
|
-
const indexRef = useRef(index);
|
|
103
|
-
const pitchRef = useRef(pitch);
|
|
104
|
-
// read through refs so the gesture is never rebuilt mid drag
|
|
105
|
-
const callbacks = useRef({ onSelect, navigator });
|
|
106
|
-
indexRef.current = index;
|
|
107
|
-
pitchRef.current = pitch;
|
|
108
|
-
callbacks.current = { onSelect, navigator };
|
|
109
|
-
|
|
110
|
-
useEffect(() => {
|
|
111
|
-
metrics.value = { count, pitch, firstCenter };
|
|
112
|
-
}, [count, pitch, firstCenter, metrics]);
|
|
113
|
-
|
|
114
|
-
const move = useCallback(
|
|
115
|
-
(target: number, velocity: number, config: typeof SPRING) => {
|
|
116
|
-
'worklet';
|
|
117
|
-
if (destination.value === target && velocity === 0) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
destination.value = target;
|
|
121
|
-
offset.value = reduceMotion
|
|
122
|
-
? withTiming(target, REDUCED_TIMING)
|
|
123
|
-
: withSpring(target, { ...config, velocity });
|
|
124
|
-
},
|
|
125
|
-
[offset, destination, reduceMotion],
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
const target = index * pitch;
|
|
130
|
-
selected.value = index;
|
|
131
|
-
// snap on first layout so the lens doesn't slide in from the leading edge
|
|
132
|
-
if (measured.current) {
|
|
133
|
-
move(target, 0, SPRING);
|
|
134
|
-
} else {
|
|
135
|
-
destination.value = target;
|
|
136
|
-
offset.value = target;
|
|
137
|
-
}
|
|
138
|
-
measured.current = pitch > 0;
|
|
139
|
-
}, [index, pitch, offset, destination, selected, move]);
|
|
140
|
-
|
|
141
|
-
useAnimatedReaction(
|
|
142
|
-
() => offset.value,
|
|
143
|
-
(current, previous) => {
|
|
144
|
-
if (previous === null || reduceMotion) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
const target = Math.min(
|
|
148
|
-
Math.abs(current - previous) * STRETCH_PER_POINT,
|
|
149
|
-
MAX_STRETCH,
|
|
150
|
-
);
|
|
151
|
-
// eased both ways: a slow frame (e.g. the spring turning at its overshoot) must not
|
|
152
|
-
// snap the shape
|
|
153
|
-
const next = stretch.value + (target - stretch.value) * STRETCH_EASING;
|
|
154
|
-
stretch.value = next < REST_STRETCH ? 0 : next;
|
|
155
|
-
},
|
|
156
|
-
[reduceMotion],
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* returns the lens to the selected tab unless a switch to where it is heading happened
|
|
161
|
-
*/
|
|
162
|
-
const resync = useCallback(() => {
|
|
163
|
-
setTimeout(() => {
|
|
164
|
-
const target = indexRef.current * pitchRef.current;
|
|
165
|
-
if (!dragging.value && destination.value !== target) {
|
|
166
|
-
move(target, 0, SPRING);
|
|
167
|
-
}
|
|
168
|
-
}, RESYNC_DELAY);
|
|
169
|
-
}, [destination, dragging, move]);
|
|
170
|
-
|
|
171
|
-
const pressIn = (itemIndex: number) => {
|
|
172
|
-
// the lens heads to the pressed tab, not only the selected one
|
|
173
|
-
move(itemIndex * pitch, 0, SPRING);
|
|
174
|
-
if (!reduceMotion) {
|
|
175
|
-
lift.value = withSpring(1, SPRING);
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* false when a drag took the press over
|
|
181
|
-
*/
|
|
182
|
-
const pressOut = () => {
|
|
183
|
-
// a drag taking over cancels the touchable, the drag owns the lens from there
|
|
184
|
-
if (dragging.value) {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
if (!reduceMotion) {
|
|
188
|
-
lift.value = withSequence(
|
|
189
|
-
withTiming(1, { duration: TAP_LIFT_DURATION }),
|
|
190
|
-
withSpring(0, SPRING),
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
// the lens keeps heading to the pressed tab: onPress switches to it, and a cancelled
|
|
194
|
-
// press brings it back
|
|
195
|
-
resync();
|
|
196
|
-
return true;
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
const tick = useCallback(() => {
|
|
200
|
-
callbacks.current.navigator?.maxApi?.triggerEventVibration?.('light');
|
|
201
|
-
}, []);
|
|
202
|
-
|
|
203
|
-
const select = useCallback(
|
|
204
|
-
(target: number) => {
|
|
205
|
-
callbacks.current.onSelect(target);
|
|
206
|
-
resync();
|
|
207
|
-
},
|
|
208
|
-
[resync],
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
const gesture = useMemo(() => {
|
|
212
|
-
const position = (e: { x: number; y: number }) => {
|
|
213
|
-
'worklet';
|
|
214
|
-
return (axis === 'x' ? e.x : e.y) - metrics.value.firstCenter;
|
|
215
|
-
};
|
|
216
|
-
const nearest = (value: number) => {
|
|
217
|
-
'worklet';
|
|
218
|
-
const { count: tabs, pitch: step } = metrics.value;
|
|
219
|
-
return step > 0 ? clamp(Math.round(value / step), 0, tabs - 1) : 0;
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
return Gesture.Pan()
|
|
223
|
-
.minDistance(DRAG_SLOP)
|
|
224
|
-
.shouldCancelWhenOutside(false)
|
|
225
|
-
.onStart(e => {
|
|
226
|
-
dragging.value = true;
|
|
227
|
-
hovered.value = nearest(position(e));
|
|
228
|
-
if (!reduceMotion) {
|
|
229
|
-
lift.value = withSpring(1, SPRING);
|
|
230
|
-
}
|
|
231
|
-
})
|
|
232
|
-
.onUpdate(e => {
|
|
233
|
-
const { count: tabs, pitch: step } = metrics.value;
|
|
234
|
-
const value = position(e);
|
|
235
|
-
const target = nearest(value);
|
|
236
|
-
if (target !== hovered.value) {
|
|
237
|
-
hovered.value = target;
|
|
238
|
-
scheduleOnRN(tick);
|
|
239
|
-
}
|
|
240
|
-
const follow = rubberBand(value, (tabs - 1) * step, step);
|
|
241
|
-
if (reduceMotion) {
|
|
242
|
-
destination.value = follow;
|
|
243
|
-
offset.value = follow;
|
|
244
|
-
} else {
|
|
245
|
-
move(follow, 0, DRAG_SPRING);
|
|
246
|
-
}
|
|
247
|
-
})
|
|
248
|
-
.onEnd((e, success) => {
|
|
249
|
-
dragging.value = false;
|
|
250
|
-
lift.value = reduceMotion ? 0 : withSpring(0, SPRING);
|
|
251
|
-
const raw = axis === 'x' ? e.velocityX : e.velocityY;
|
|
252
|
-
const velocity = Number.isFinite(raw) ? raw : 0;
|
|
253
|
-
if (!success) {
|
|
254
|
-
move(selected.value * metrics.value.pitch, velocity, SPRING);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
const target = nearest(position(e));
|
|
258
|
-
move(target * metrics.value.pitch, velocity, SPRING);
|
|
259
|
-
scheduleOnRN(select, target);
|
|
260
|
-
});
|
|
261
|
-
}, [
|
|
262
|
-
axis,
|
|
263
|
-
reduceMotion,
|
|
264
|
-
offset,
|
|
265
|
-
destination,
|
|
266
|
-
selected,
|
|
267
|
-
hovered,
|
|
268
|
-
lift,
|
|
269
|
-
dragging,
|
|
270
|
-
metrics,
|
|
271
|
-
move,
|
|
272
|
-
tick,
|
|
273
|
-
select,
|
|
274
|
-
]);
|
|
275
|
-
|
|
276
|
-
const bubbleStyle = useAnimatedStyle(() => {
|
|
277
|
-
const along = 1 + stretch.value;
|
|
278
|
-
const across = 1 - stretch.value * 0.5;
|
|
279
|
-
const lensX = 1 + lift.value * (LENS_SCALE_X - 1);
|
|
280
|
-
const lensY = 1 + lift.value * (LENS_SCALE_Y - 1);
|
|
281
|
-
return {
|
|
282
|
-
transform:
|
|
283
|
-
axis === 'x'
|
|
284
|
-
? [
|
|
285
|
-
{ translateX: offset.value },
|
|
286
|
-
{ scaleX: lensX * along },
|
|
287
|
-
{ scaleY: lensY * across },
|
|
288
|
-
]
|
|
289
|
-
: [
|
|
290
|
-
{ translateY: offset.value },
|
|
291
|
-
{ scaleX: lensX * across },
|
|
292
|
-
{ scaleY: lensY * along },
|
|
293
|
-
],
|
|
294
|
-
};
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
const barStyle = useAnimatedStyle(() => ({
|
|
298
|
-
transform: [{ scale: 1 + lift.value * BAR_SWELL }],
|
|
299
|
-
}));
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
gesture,
|
|
303
|
-
bubbleStyle,
|
|
304
|
-
barStyle,
|
|
305
|
-
pressIn,
|
|
306
|
-
pressOut,
|
|
307
|
-
offset,
|
|
308
|
-
selected,
|
|
309
|
-
pitch,
|
|
310
|
-
reduceMotion,
|
|
311
|
-
};
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
type LiquidSelection = ReturnType<typeof useLiquidSelection>;
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* active tint of a tab follows the lens over it, and the tab the lens lands on pops
|
|
318
|
-
*/
|
|
319
|
-
const useLiquidItem = (
|
|
320
|
-
{ offset, selected, pitch, reduceMotion }: LiquidSelection,
|
|
321
|
-
itemIndex: number,
|
|
322
|
-
) => {
|
|
323
|
-
const pop = useSharedValue(1);
|
|
324
|
-
|
|
325
|
-
useAnimatedReaction(
|
|
326
|
-
() => selected.value === itemIndex,
|
|
327
|
-
(isSelected, wasSelected) => {
|
|
328
|
-
if (isSelected && wasSelected === false && !reduceMotion) {
|
|
329
|
-
pop.value = withSequence(
|
|
330
|
-
withTiming(ICON_POP, { duration: 120 }),
|
|
331
|
-
withSpring(1, SPRING),
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
},
|
|
335
|
-
[itemIndex, reduceMotion],
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
const activeStyle = useAnimatedStyle(() => {
|
|
339
|
-
if (pitch <= 0) {
|
|
340
|
-
return { opacity: selected.value === itemIndex ? 1 : 0 };
|
|
341
|
-
}
|
|
342
|
-
const distance = Math.abs(offset.value - itemIndex * pitch) / pitch;
|
|
343
|
-
return { opacity: clamp(1 - distance, 0, 1) };
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
const popStyle = useAnimatedStyle(() => ({
|
|
347
|
-
transform: [{ scale: pop.value }],
|
|
348
|
-
}));
|
|
349
|
-
|
|
350
|
-
return { activeStyle, popStyle };
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* lift for a single button, e.g. the compressed rail
|
|
355
|
-
*/
|
|
356
|
-
const usePressLift = () => {
|
|
357
|
-
const reduceMotion = useReducedMotion();
|
|
358
|
-
const lift = useSharedValue(0);
|
|
359
|
-
const style = useAnimatedStyle(() => ({
|
|
360
|
-
transform: [{ scale: 1 + lift.value * (LENS_SCALE_X - 1) * 0.5 }],
|
|
361
|
-
}));
|
|
362
|
-
const onPressIn = () => {
|
|
363
|
-
if (!reduceMotion) {
|
|
364
|
-
lift.value = withSpring(1, SPRING);
|
|
365
|
-
}
|
|
366
|
-
};
|
|
367
|
-
const onPressOut = () => {
|
|
368
|
-
lift.value = withSpring(0, SPRING);
|
|
369
|
-
};
|
|
370
|
-
return { style, onPressIn, onPressOut };
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
export type { LiquidSelection };
|
|
374
|
-
export { useLiquidSelection, useLiquidItem, usePressLift };
|