@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.
@@ -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, Radius, Spacing } from '../../Consts';
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 and its label turns active
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 { pressed, buttonStyle, onPressIn, onPressOut };
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 { pressed, buttonStyle, onPressIn, onPressOut } =
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={pressed ? Colors.black_01 : theme.colors.text.secondary}
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 './useLiquidSelection';
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 { pressed, buttonStyle, onPressIn, onPressOut } =
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={pressed ? Colors.black_01 : theme.colors.text.secondary}
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 { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
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 { NativeModules, Platform, StatusBar, StyleSheet } from 'react-native';
16
- import Animated, {
17
- useAnimatedStyle,
18
- useSharedValue,
19
- withTiming,
20
- } from 'react-native-reanimated';
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
- <Animated.View
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
- </Animated.View>
179
+ </View>
170
180
  </BottomTabInsetContext.Provider>
171
181
  );
172
182
  }
173
183
 
174
184
  return (
175
185
  <BottomTabInsetContext.Provider value={tabInsetValue}>
176
- <Animated.View
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
- </Animated.View>
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 ? (
@@ -34,7 +34,6 @@ const TabBarMetrics = {
34
34
  ctaLift: 22,
35
35
  ctaLabelGap: Spacing.M,
36
36
  ctaLabelHeight: 18,
37
- railWidth: 84,
38
37
  railContainerWidth: 56,
39
38
  railPaddingHorizontal: Spacing.XS,
40
39
  railPaddingBottom: Spacing.S,
@@ -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 };
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/foundation",
3
- "version": "0.168.1-beta.25",
3
+ "version": "0.168.1-beta.26",
4
4
  "description": "React Native Component Kits",
5
5
  "main": "index.ts",
6
6
  "scripts": {},
@@ -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 };