@momo-kits/foundation 0.168.1-beta.10 → 0.168.1-beta.13

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.
@@ -1,17 +1,16 @@
1
1
  import { BottomTabBarProps } from '@react-navigation/bottom-tabs';
2
2
  import { CommonActions, Route } from '@react-navigation/native';
3
- import React, { useContext, useEffect, useRef, useState } from 'react';
3
+ import React, { ReactNode, useContext, useMemo, useRef, useState } from 'react';
4
4
  import {
5
5
  LayoutChangeEvent,
6
+ StyleProp,
6
7
  StyleSheet,
7
8
  TouchableOpacity,
8
9
  View,
10
+ ViewStyle,
9
11
  } from 'react-native';
10
- import Animated, {
11
- useAnimatedStyle,
12
- useSharedValue,
13
- withSpring,
14
- } from 'react-native-reanimated';
12
+ import { GestureDetector } from 'react-native-gesture-handler';
13
+ import Animated from 'react-native-reanimated';
15
14
  import { Colors, Radius, Spacing } from '../../Consts';
16
15
  import { ApplicationContext, MiniAppContext } from '../../Context';
17
16
  import { Icon } from '../../Icon';
@@ -20,6 +19,12 @@ import { Text } from '../../Text';
20
19
  import { useDuoBarCompression } from './DuoBarCompression';
21
20
  import { FloatingButtonProps } from '../types';
22
21
  import { TabBarIcon } from './TabBarIcon';
22
+ import {
23
+ LiquidSelection,
24
+ useLiquidItem,
25
+ useLiquidSelection,
26
+ usePressLift,
27
+ } from './useLiquidSelection';
23
28
 
24
29
  type Props = BottomTabBarProps & {
25
30
  activeTintColor: string;
@@ -40,11 +45,7 @@ const FLOATING_HEIGHT = DuoMetrics.floatingTabBarHeight;
40
45
  const FLOATING_PADDING = Spacing.XS;
41
46
  // the system bar sizes its pill to the tabs instead of stretching it across the display
42
47
  const FLOATING_ITEM_WIDTH = 80;
43
- // while pressed the selection bubble grows into a lens past the pill's edges, wider and taller (80x54 to about 96x70)
44
- const LENS_SCALE_X = 1.2;
45
- const LENS_SCALE_Y = 1.3;
46
48
  const FLOATING_BUBBLE_RADIUS = (FLOATING_HEIGHT - 2 * FLOATING_PADDING) / 2;
47
- const SPRING = { damping: 18, stiffness: 220, mass: 0.8 };
48
49
 
49
50
  /**
50
51
  * distance from the screen bottom to the floating tab bar, overlapping the home
@@ -121,11 +122,7 @@ const TabLabel: React.FC<{
121
122
  fontScale: { useOSScaleRate: false, userScaleRate: 1 },
122
123
  }}
123
124
  >
124
- <Text
125
- numberOfLines={1}
126
- typography={typography}
127
- color={color}
128
- >
125
+ <Text numberOfLines={1} typography={typography} color={color}>
129
126
  {label}
130
127
  </Text>
131
128
  </MiniAppContext.Provider>
@@ -162,21 +159,22 @@ const getTabItems = ({
162
159
  navigation.emit({ type: 'tabLongPress', target: route.key });
163
160
  };
164
161
 
165
- const icon = options.tabBarIcon && (
166
- <TabBarIcon
167
- route={route}
168
- badge={options.tabBarBadge}
169
- badgeStyle={options.tabBarBadgeStyle}
170
- activeOpacity={focused ? 1 : 0}
171
- inactiveOpacity={focused ? 0 : 1}
172
- activeTintColor={activeTintColor}
173
- inactiveTintColor={inactiveTintColor}
174
- renderIcon={options.tabBarIcon}
175
- style={styles.icon}
176
- />
177
- );
178
-
179
- return { route, focused, label, onPress, onLongPress, icon };
162
+ const renderIcon = (active: boolean) =>
163
+ options.tabBarIcon && (
164
+ <TabBarIcon
165
+ route={route}
166
+ badge={options.tabBarBadge}
167
+ badgeStyle={options.tabBarBadgeStyle}
168
+ activeOpacity={active ? 1 : 0}
169
+ inactiveOpacity={active ? 0 : 1}
170
+ activeTintColor={activeTintColor}
171
+ inactiveTintColor={inactiveTintColor}
172
+ renderIcon={options.tabBarIcon}
173
+ style={styles.icon}
174
+ />
175
+ );
176
+
177
+ return { route, focused, label, onPress, onLongPress, renderIcon };
180
178
  });
181
179
  };
182
180
 
@@ -192,6 +190,58 @@ const QrIcon: React.FC<{ icon: FloatingButtonProps['icon']; size: number }> = ({
192
190
 
193
191
  type TabItem = ReturnType<typeof getTabItems>[number];
194
192
 
193
+ /**
194
+ * tab item: a tap is handled here as before, a drag over the bar is handled by the bar's
195
+ * gesture. The active layer fades in as the lens passes over it
196
+ */
197
+ const LiquidTabItem: React.FC<{
198
+ item: TabItem;
199
+ itemIndex: number;
200
+ selection: LiquidSelection;
201
+ onSelect: () => void;
202
+ renderContent: (active: boolean) => ReactNode;
203
+ style: StyleProp<ViewStyle>;
204
+ }> = ({ item, itemIndex, selection, onSelect, renderContent, style }) => {
205
+ const { activeStyle, popStyle } = useLiquidItem(selection, itemIndex);
206
+ const activeLayerStyle = useMemo(
207
+ () => [StyleSheet.absoluteFill, styles.itemLayer, activeStyle],
208
+ [activeStyle],
209
+ );
210
+ // like the system tab bar, a held tab is still picked when released
211
+ const longPressed = useRef(false);
212
+
213
+ return (
214
+ <TouchableOpacity
215
+ accessibilityRole={'tab'}
216
+ accessibilityLabel={item.label}
217
+ accessibilityState={{ selected: item.focused }}
218
+ activeOpacity={1}
219
+ onPressIn={() => {
220
+ longPressed.current = false;
221
+ selection.pressIn(itemIndex);
222
+ }}
223
+ onPressOut={() => {
224
+ if (selection.pressOut() && longPressed.current) {
225
+ onSelect();
226
+ }
227
+ }}
228
+ onPress={onSelect}
229
+ onLongPress={() => {
230
+ longPressed.current = true;
231
+ item.onLongPress();
232
+ }}
233
+ style={style}
234
+ >
235
+ <Animated.View style={popStyle}>
236
+ <View style={styles.itemLayer}>{renderContent(false)}</View>
237
+ <Animated.View style={activeLayerStyle}>
238
+ {renderContent(true)}
239
+ </Animated.View>
240
+ </Animated.View>
241
+ </TouchableOpacity>
242
+ );
243
+ };
244
+
195
245
  /**
196
246
  * column of icon-only tab items with the sliding selection bubble
197
247
  */
@@ -200,56 +250,47 @@ const Rail: React.FC<{
200
250
  index: number;
201
251
  onItemPress?: () => void;
202
252
  }> = ({ items, index, onItemPress }) => {
203
- const bubbleOffset = useSharedValue(index * RAIL_PITCH);
204
- const lens = useSharedValue(0);
205
-
206
- useEffect(() => {
207
- bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
208
- }, [index, bubbleOffset]);
209
-
210
- const bubbleStyle = useAnimatedStyle(() => ({
211
- transform: [
212
- { translateY: bubbleOffset.value },
213
- { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
214
- { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
215
- ],
216
- }));
253
+ const onSelect = (itemIndex: number) => {
254
+ items[itemIndex]?.onPress();
255
+ onItemPress?.();
256
+ };
257
+ const selection = useLiquidSelection({
258
+ count: items.length,
259
+ pitch: RAIL_PITCH,
260
+ firstCenter: RAIL_PADDING + ITEM_SIZE / 2,
261
+ axis: 'y',
262
+ index,
263
+ onSelect,
264
+ });
265
+ const { barStyle, bubbleStyle } = selection;
266
+ // stable style arrays: the tab switch re-renders the rail while the lens is still
267
+ // springing back, a new array would re-attach the animated style mid animation
268
+ const railStyle = useMemo(() => [styles.rail, barStyle], [barStyle]);
269
+ const railBubbleStyle = useMemo(
270
+ () => [styles.railBubble, bubbleStyle],
271
+ [bubbleStyle],
272
+ );
217
273
 
218
274
  return (
219
- <View style={styles.rail} accessibilityLabel={'bottom_tab_bar'}>
220
- <BarMaterial radius={ITEM_SIZE / 2} />
221
- <Animated.View
222
- pointerEvents={'none'}
223
- style={[styles.railBubble, bubbleStyle]}
224
- >
225
- <SelectionMaterial radius={RAIL_BUBBLE_WIDTH / 2} />
275
+ <GestureDetector gesture={selection.gesture}>
276
+ <Animated.View style={railStyle} accessibilityLabel={'bottom_tab_bar'}>
277
+ <BarMaterial radius={ITEM_SIZE / 2} />
278
+ <Animated.View pointerEvents={'none'} style={railBubbleStyle}>
279
+ <SelectionMaterial radius={RAIL_BUBBLE_WIDTH / 2} />
280
+ </Animated.View>
281
+ {items.map((item, itemIndex) => (
282
+ <LiquidTabItem
283
+ key={item.route.key}
284
+ item={item}
285
+ itemIndex={itemIndex}
286
+ selection={selection}
287
+ onSelect={() => onSelect(itemIndex)}
288
+ renderContent={item.renderIcon}
289
+ style={styles.railItem}
290
+ />
291
+ ))}
226
292
  </Animated.View>
227
- {items.map((item, itemIndex) => (
228
- <TouchableOpacity
229
- key={item.route.key}
230
- accessibilityRole={'tab'}
231
- accessibilityLabel={item.label}
232
- accessibilityState={{ selected: item.focused }}
233
- activeOpacity={1}
234
- onPressIn={() => {
235
- bubbleOffset.value = withSpring(itemIndex * RAIL_PITCH, SPRING);
236
- lens.value = withSpring(1, SPRING);
237
- }}
238
- onPressOut={() => {
239
- bubbleOffset.value = withSpring(index * RAIL_PITCH, SPRING);
240
- lens.value = withSpring(0, SPRING);
241
- }}
242
- onPress={() => {
243
- item.onPress();
244
- onItemPress?.();
245
- }}
246
- onLongPress={item.onLongPress}
247
- style={styles.railItem}
248
- >
249
- {item.icon}
250
- </TouchableOpacity>
251
- ))}
252
- </View>
293
+ </GestureDetector>
253
294
  );
254
295
  };
255
296
 
@@ -263,6 +304,7 @@ const DuoRailTabBar: React.FC<Props> = props => {
263
304
  const { sideBar } = useScreenLayout();
264
305
  const { tabBarCompressed, expandTabBar, onTabSelected } =
265
306
  useDuoBarCompression();
307
+ const compactLift = usePressLift();
266
308
  const { state, floatingButton } = props;
267
309
  const items = getTabItems(props);
268
310
  const current = items[state.index];
@@ -303,16 +345,21 @@ const DuoRailTabBar: React.FC<Props> = props => {
303
345
 
304
346
  return (
305
347
  <View pointerEvents={'box-none'} style={columnStyle}>
306
- <TouchableOpacity
307
- accessibilityRole={'button'}
308
- accessibilityLabel={current?.label}
309
- accessibilityHint={'bottom_tab_bar'}
310
- onPress={expandTabBar}
311
- style={styles.railCompact}
312
- >
313
- <BarMaterial radius={Radius.XL} />
314
- {current?.icon}
315
- </TouchableOpacity>
348
+ <Animated.View style={compactLift.style}>
349
+ <TouchableOpacity
350
+ accessibilityRole={'button'}
351
+ accessibilityLabel={current?.label}
352
+ accessibilityHint={'bottom_tab_bar'}
353
+ activeOpacity={1}
354
+ onPressIn={compactLift.onPressIn}
355
+ onPressOut={compactLift.onPressOut}
356
+ onPress={expandTabBar}
357
+ style={styles.railCompact}
358
+ >
359
+ <BarMaterial radius={Radius.XL} />
360
+ {current?.renderIcon(true)}
361
+ </TouchableOpacity>
362
+ </Animated.View>
316
363
  {qr}
317
364
  </View>
318
365
  );
@@ -330,24 +377,29 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
330
377
  const [rowWidth, setRowWidth] = useState(0);
331
378
  const cellWidth = rowWidth / state.routes.length;
332
379
 
333
- const bubbleOffset = useSharedValue(0);
334
- const lens = useSharedValue(0);
335
- const measured = useRef(false);
336
-
337
- useEffect(() => {
338
- const target = state.index * cellWidth;
339
- // snap on first layout so the bubble doesn't slide in from the left edge
340
- bubbleOffset.value = measured.current ? withSpring(target, SPRING) : target;
341
- measured.current = cellWidth > 0;
342
- }, [state.index, cellWidth, bubbleOffset]);
343
-
344
- const bubbleStyle = useAnimatedStyle(() => ({
345
- transform: [
346
- { translateX: bubbleOffset.value },
347
- { scaleX: 1 + lens.value * (LENS_SCALE_X - 1) },
348
- { scaleY: 1 + lens.value * (LENS_SCALE_Y - 1) },
380
+ const selection = useLiquidSelection({
381
+ count: items.length,
382
+ pitch: cellWidth,
383
+ firstCenter: cellWidth / 2,
384
+ axis: 'x',
385
+ index: state.index,
386
+ onSelect: itemIndex => items[itemIndex]?.onPress(),
387
+ });
388
+ const { barStyle, bubbleStyle } = selection;
389
+ const tabCount = state.routes.length;
390
+ // stable style arrays, see Rail
391
+ const floatingBarStyle = useMemo(
392
+ () => [
393
+ styles.floatingBar,
394
+ { width: tabCount * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING },
395
+ barStyle,
349
396
  ],
350
- }));
397
+ [tabCount, barStyle],
398
+ );
399
+ const floatingBubbleStyle = useMemo(
400
+ () => [styles.floatingBubble, { width: cellWidth }, bubbleStyle],
401
+ [cellWidth, bubbleStyle],
402
+ );
351
403
 
352
404
  const onRowLayout = (e: LayoutChangeEvent) =>
353
405
  setRowWidth(e.nativeEvent.layout.width);
@@ -364,57 +416,41 @@ const DuoFloatingTabBar: React.FC<Props> = props => {
364
416
  },
365
417
  ]}
366
418
  >
367
- <View
368
- style={[
369
- styles.floatingBar,
370
- {
371
- width:
372
- state.routes.length * FLOATING_ITEM_WIDTH + 2 * FLOATING_PADDING,
373
- },
374
- ]}
419
+ <Animated.View
420
+ style={floatingBarStyle}
375
421
  accessibilityLabel={'bottom_tab_bar'}
376
422
  >
377
423
  <BarMaterial radius={FLOATING_HEIGHT / 2} />
378
- <View style={styles.floatingRow} onLayout={onRowLayout}>
379
- {rowWidth > 0 && (
380
- <Animated.View
381
- pointerEvents={'none'}
382
- style={[styles.floatingBubble, { width: cellWidth }, bubbleStyle]}
383
- >
384
- <SelectionMaterial radius={FLOATING_BUBBLE_RADIUS} />
385
- </Animated.View>
386
- )}
387
- {items.map((item, index) => (
388
- <TouchableOpacity
389
- key={item.route.key}
390
- accessibilityRole={'tab'}
391
- accessibilityLabel={item.label}
392
- accessibilityState={{ selected: item.focused }}
393
- activeOpacity={1}
394
- onPressIn={() => {
395
- // the lens jumps to the pressed tab, not only the selected one
396
- bubbleOffset.value = withSpring(index * cellWidth, SPRING);
397
- lens.value = withSpring(1, SPRING);
398
- }}
399
- onPressOut={() => {
400
- // onPress follows synchronously, a tab switch re-targets the bubble
401
- bubbleOffset.value = withSpring(state.index * cellWidth, SPRING);
402
- lens.value = withSpring(0, SPRING);
403
- }}
404
- onPress={item.onPress}
405
- onLongPress={item.onLongPress}
406
- style={styles.floatingItem}
407
- >
408
- {item.icon}
409
- <TabLabel
410
- label={item.label}
411
- typography={'label_xs_medium'}
412
- color={item.focused ? activeTintColor : inactiveTintColor}
424
+ <GestureDetector gesture={selection.gesture}>
425
+ <View style={styles.floatingRow} onLayout={onRowLayout}>
426
+ {rowWidth > 0 && (
427
+ <Animated.View pointerEvents={'none'} style={floatingBubbleStyle}>
428
+ <SelectionMaterial radius={FLOATING_BUBBLE_RADIUS} />
429
+ </Animated.View>
430
+ )}
431
+ {items.map((item, index) => (
432
+ <LiquidTabItem
433
+ key={item.route.key}
434
+ item={item}
435
+ itemIndex={index}
436
+ selection={selection}
437
+ onSelect={item.onPress}
438
+ renderContent={active => (
439
+ <>
440
+ {item.renderIcon(active)}
441
+ <TabLabel
442
+ label={item.label}
443
+ typography={'label_xs_medium'}
444
+ color={active ? activeTintColor : inactiveTintColor}
445
+ />
446
+ </>
447
+ )}
448
+ style={styles.floatingItem}
413
449
  />
414
- </TouchableOpacity>
415
- ))}
416
- </View>
417
- </View>
450
+ ))}
451
+ </View>
452
+ </GestureDetector>
453
+ </Animated.View>
418
454
  {floatingButton && (
419
455
  <TouchableOpacity
420
456
  accessibilityRole={'button'}
@@ -518,6 +554,10 @@ const styles = StyleSheet.create({
518
554
  flex: 1,
519
555
  alignItems: 'center',
520
556
  justifyContent: 'center',
557
+ },
558
+ itemLayer: {
559
+ alignItems: 'center',
560
+ justifyContent: 'center',
521
561
  gap: Spacing.XXS,
522
562
  },
523
563
  // pink fills the whole pinned platter