@momo-kits/foundation 0.168.1-beta.15 → 0.168.1-beta.17
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/BottomSheet.tsx +142 -37
- package/Application/BottomTab/DuoTabBar.tsx +191 -151
- package/Application/BottomTab/useLiquidSelection.ts +374 -0
- package/Application/ModalScreen.tsx +12 -8
- package/Application/device.ts +24 -1
- package/Layout/utils.ts +1 -0
- package/Popup/PopupNotify.tsx +20 -10
- package/Popup/utils.ts +9 -15
- package/package.json +1 -1
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
PanResponder,
|
|
17
17
|
Platform,
|
|
18
18
|
Pressable,
|
|
19
|
+
StatusBar,
|
|
19
20
|
StyleSheet,
|
|
20
21
|
TouchableOpacity,
|
|
21
22
|
useWindowDimensions,
|
|
@@ -39,18 +40,37 @@ import Animated, {
|
|
|
39
40
|
} from 'react-native-reanimated';
|
|
40
41
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
41
42
|
import layoutStyles from '../Layout/styles';
|
|
43
|
+
import { DuoMetrics, InnerEdgeStyles, useScreenLayout } from '../Layout/utils';
|
|
42
44
|
import { getDuoTopInset, isIPhoneDuo } from './device';
|
|
45
|
+
import { SideBarButton } from './Components/SideBarButton';
|
|
43
46
|
|
|
44
47
|
// iPhone Duo inner display: center the sheet instead of spanning the width
|
|
45
48
|
const DUO_REGULAR_MIN_WIDTH = 600;
|
|
46
49
|
|
|
50
|
+
const INDICATOR_HEIGHT = 16;
|
|
51
|
+
const HEADER_HEIGHT = 56;
|
|
52
|
+
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
53
|
+
const SIDE_COLUMN_OFFSET =
|
|
54
|
+
INDICATOR_HEIGHT + (HEADER_HEIGHT - DuoMetrics.controlSize) / 2;
|
|
55
|
+
|
|
47
56
|
const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
48
57
|
const { theme, navigator } = useContext(ApplicationContext);
|
|
49
58
|
const context = useContext<any>(MiniAppContext);
|
|
50
59
|
const windowSize = useWindowDimensions();
|
|
51
60
|
const isDuo = isIPhoneDuo();
|
|
52
|
-
const isDuoCentered =
|
|
53
|
-
|
|
61
|
+
const isDuoCentered =
|
|
62
|
+
isDuo &&
|
|
63
|
+
Math.min(windowSize.width, windowSize.height) >= DUO_REGULAR_MIN_WIDTH;
|
|
64
|
+
const isDuoFloating = isDuo && !isDuoCentered;
|
|
65
|
+
const { sideBar } = useScreenLayout();
|
|
66
|
+
const sideColumnWidth = (sideBar?.width ?? 0) - Spacing.M;
|
|
67
|
+
const sideColumn =
|
|
68
|
+
isDuoFloating &&
|
|
69
|
+
sideColumnWidth >= DuoMetrics.controlSize + Spacing.M &&
|
|
70
|
+
sideBar
|
|
71
|
+
? sideBar
|
|
72
|
+
: undefined;
|
|
73
|
+
const hasSideColumn = !!sideColumn;
|
|
54
74
|
// 'screen' is the outer display on iPhone Duo, even when unfolded
|
|
55
75
|
const screenHeight = Dimensions.get('screen').height;
|
|
56
76
|
const heightDevice = isDuo ? windowSize.height : screenHeight;
|
|
@@ -58,7 +78,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
58
78
|
const insets = useSafeAreaInsets();
|
|
59
79
|
const heightHeader = useHeaderHeight();
|
|
60
80
|
const topGap = Math.max(
|
|
61
|
-
|
|
81
|
+
sideColumn
|
|
82
|
+
? insets.top
|
|
83
|
+
: getDuoTopInset(insets, windowSize.width, windowSize.height),
|
|
62
84
|
Spacing.XL,
|
|
63
85
|
);
|
|
64
86
|
const isIOS = Platform.OS === 'ios';
|
|
@@ -83,7 +105,10 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
83
105
|
}: BottomSheetParams = props.route.params;
|
|
84
106
|
|
|
85
107
|
const translateY = useSharedValue(heightDevice);
|
|
108
|
+
const onDismissRef = useRef(props.route.params?.onDismiss);
|
|
109
|
+
onDismissRef.current = props.route.params?.onDismiss;
|
|
86
110
|
const [keyboardLift, setKeyboardLift] = useState(0);
|
|
111
|
+
const [sheetTop, setSheetTop] = useState(windowSize.height);
|
|
87
112
|
|
|
88
113
|
// KeyboardAvoidingView lifts the whole sheet past the top on iPhone Duo,
|
|
89
114
|
// so lift it here and shrink maxHeight by the same amount instead
|
|
@@ -179,15 +204,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
179
204
|
}
|
|
180
205
|
|
|
181
206
|
/**
|
|
182
|
-
* emit dismiss event
|
|
207
|
+
* emit dismiss event, once: folding or rotating iPhone Duo changes the
|
|
208
|
+
* window height while the sheet stays open
|
|
183
209
|
*/
|
|
184
210
|
useEffect(() => {
|
|
185
|
-
translateY.value = heightDevice;
|
|
186
211
|
openAnimation();
|
|
187
212
|
return () => {
|
|
188
|
-
|
|
213
|
+
onDismissRef.current?.(action.current);
|
|
189
214
|
};
|
|
190
|
-
}, [
|
|
215
|
+
}, [openAnimation]);
|
|
191
216
|
|
|
192
217
|
/**
|
|
193
218
|
* handler dismiss
|
|
@@ -228,13 +253,42 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
228
253
|
return () => backHandler.remove();
|
|
229
254
|
}, [barrierDismissible, onDismiss]);
|
|
230
255
|
|
|
256
|
+
const onPressBack = useCallback(() => {
|
|
257
|
+
onDismiss(true, leftOptions?.onPressIconLeft);
|
|
258
|
+
}, [leftOptions, onDismiss]);
|
|
259
|
+
|
|
260
|
+
const onPressClose = useCallback(() => {
|
|
261
|
+
action.current = 'icon_close';
|
|
262
|
+
onDismiss(true);
|
|
263
|
+
}, [onDismiss]);
|
|
264
|
+
|
|
265
|
+
const showBack = !!leftOptions?.onPressIconLeft;
|
|
266
|
+
const iconBack = leftOptions?.iconLeft ?? 'ic_back';
|
|
267
|
+
const sideColumnCount = showBack ? 2 : 1;
|
|
268
|
+
const sideColumnHeight =
|
|
269
|
+
sideColumnCount * DuoMetrics.controlSize +
|
|
270
|
+
(sideColumnCount - 1) * Spacing.M;
|
|
271
|
+
const coversStatusBar = !!sideColumn && sheetTop < sideColumn.top;
|
|
272
|
+
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!coversStatusBar) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const entry = StatusBar.pushStackEntry({
|
|
278
|
+
barStyle: theme.dark ? 'light-content' : 'dark-content',
|
|
279
|
+
animated: true,
|
|
280
|
+
});
|
|
281
|
+
return () => StatusBar.popStackEntry(entry);
|
|
282
|
+
}, [coversStatusBar, theme.dark]);
|
|
283
|
+
|
|
231
284
|
/**
|
|
232
285
|
* render header
|
|
233
286
|
*/
|
|
234
287
|
const renderHeader = useCallback(() => {
|
|
288
|
+
const showButtons = !hasSideColumn;
|
|
235
289
|
return (
|
|
236
290
|
<View {...panResponder.panHandlers}>
|
|
237
|
-
<View style={{ height:
|
|
291
|
+
<View style={{ height: INDICATOR_HEIGHT }}>
|
|
238
292
|
<View style={styles.indicator} />
|
|
239
293
|
</View>
|
|
240
294
|
<View
|
|
@@ -246,14 +300,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
246
300
|
},
|
|
247
301
|
]}
|
|
248
302
|
>
|
|
249
|
-
{
|
|
250
|
-
<TouchableOpacity
|
|
251
|
-
|
|
252
|
-
onPress={() => {
|
|
253
|
-
onDismiss(true, leftOptions.onPressIconLeft);
|
|
254
|
-
}}
|
|
255
|
-
>
|
|
256
|
-
<Icon source={leftOptions.iconLeft ?? 'ic_back'} />
|
|
303
|
+
{showButtons && showBack ? (
|
|
304
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressBack}>
|
|
305
|
+
<Icon source={iconBack} />
|
|
257
306
|
</TouchableOpacity>
|
|
258
307
|
) : null}
|
|
259
308
|
|
|
@@ -261,9 +310,15 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
261
310
|
<View style={Styles.flex}>{options.header}</View>
|
|
262
311
|
) : (
|
|
263
312
|
<>
|
|
264
|
-
{!leftOptions ?
|
|
313
|
+
{showButtons && !leftOptions ? (
|
|
314
|
+
<View style={styles.iconButton} />
|
|
315
|
+
) : null}
|
|
265
316
|
<Text
|
|
266
|
-
style={[
|
|
317
|
+
style={[
|
|
318
|
+
Styles.flex,
|
|
319
|
+
styles.title,
|
|
320
|
+
!showButtons && styles.titleInset,
|
|
321
|
+
]}
|
|
267
322
|
numberOfLines={1}
|
|
268
323
|
typography="header_default_bold"
|
|
269
324
|
>
|
|
@@ -271,28 +326,63 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
271
326
|
</Text>
|
|
272
327
|
</>
|
|
273
328
|
)}
|
|
274
|
-
|
|
275
|
-
style={styles.iconButton}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}}
|
|
280
|
-
>
|
|
281
|
-
<Icon source="navigation_close" />
|
|
282
|
-
</TouchableOpacity>
|
|
329
|
+
{showButtons ? (
|
|
330
|
+
<TouchableOpacity style={styles.iconButton} onPress={onPressClose}>
|
|
331
|
+
<Icon source="navigation_close" />
|
|
332
|
+
</TouchableOpacity>
|
|
333
|
+
) : null}
|
|
283
334
|
</View>
|
|
284
335
|
</View>
|
|
285
336
|
);
|
|
286
337
|
}, [
|
|
287
|
-
|
|
338
|
+
hasSideColumn,
|
|
339
|
+
iconBack,
|
|
288
340
|
leftOptions,
|
|
341
|
+
onPressBack,
|
|
342
|
+
onPressClose,
|
|
289
343
|
options.header,
|
|
290
344
|
options.title,
|
|
291
345
|
panResponder.panHandlers,
|
|
346
|
+
showBack,
|
|
292
347
|
theme.colors.border.default,
|
|
293
348
|
useDivider,
|
|
294
349
|
]);
|
|
295
350
|
|
|
351
|
+
/**
|
|
352
|
+
* iPhone Duo folded: back and close move to the side bar column, drawn on
|
|
353
|
+
* the part of the sheet under the side safe area. They line up with the
|
|
354
|
+
* header row, stay below the status bar cluster on a tall sheet and above a
|
|
355
|
+
* camera at the bottom end of the strip.
|
|
356
|
+
*/
|
|
357
|
+
const renderSideColumn = () => {
|
|
358
|
+
if (!sideColumn) {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
const lowestTop =
|
|
362
|
+
windowSize.height - sideColumn.bottom - sideColumnHeight - sheetTop;
|
|
363
|
+
const top = Math.min(
|
|
364
|
+
Math.max(sideColumn.top - sheetTop, SIDE_COLUMN_OFFSET),
|
|
365
|
+
lowestTop,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
return (
|
|
369
|
+
<View
|
|
370
|
+
pointerEvents={'box-none'}
|
|
371
|
+
style={[
|
|
372
|
+
styles.sideColumn,
|
|
373
|
+
sideColumn.edge === 'right' ? styles.alignRight : styles.alignLeft,
|
|
374
|
+
InnerEdgeStyles[sideColumn.edge],
|
|
375
|
+
{ top, width: sideColumnWidth },
|
|
376
|
+
]}
|
|
377
|
+
>
|
|
378
|
+
{showBack ? (
|
|
379
|
+
<SideBarButton icon={iconBack} onPress={onPressBack} />
|
|
380
|
+
) : null}
|
|
381
|
+
<SideBarButton icon="navigation_close" onPress={onPressClose} />
|
|
382
|
+
</View>
|
|
383
|
+
);
|
|
384
|
+
};
|
|
385
|
+
|
|
296
386
|
const animatedStyle = useAnimatedStyle(() => {
|
|
297
387
|
return {
|
|
298
388
|
transform: [{ translateY: translateY.value }],
|
|
@@ -310,12 +400,9 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
310
400
|
};
|
|
311
401
|
});
|
|
312
402
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const sideInsetStyle = needsSideInset && {
|
|
317
|
-
paddingLeft: insets.left,
|
|
318
|
-
paddingRight: insets.right,
|
|
403
|
+
const sideInsetStyle = isDuoFloating && {
|
|
404
|
+
paddingLeft: Math.max(insets.left - Spacing.M, 0),
|
|
405
|
+
paddingRight: Math.max(insets.right - Spacing.M, 0),
|
|
319
406
|
};
|
|
320
407
|
|
|
321
408
|
const screen = (
|
|
@@ -335,6 +422,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
335
422
|
}}
|
|
336
423
|
style={StyleSheet.absoluteFillObject}
|
|
337
424
|
isModalKit={true}
|
|
425
|
+
supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
|
|
338
426
|
>
|
|
339
427
|
<KeyboardAvoidingView
|
|
340
428
|
behavior={Platform.select({
|
|
@@ -357,18 +445,26 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
357
445
|
</Pressable>
|
|
358
446
|
|
|
359
447
|
<Animated.View
|
|
448
|
+
onLayout={
|
|
449
|
+
isDuoFloating ? e => setSheetTop(e.nativeEvent.layout.y) : undefined
|
|
450
|
+
}
|
|
360
451
|
style={[
|
|
361
452
|
animatedStyle,
|
|
362
453
|
{
|
|
363
454
|
backgroundColor,
|
|
364
|
-
borderTopLeftRadius: Radius.M,
|
|
365
|
-
borderTopRightRadius: Radius.M,
|
|
455
|
+
borderTopLeftRadius: isDuo ? Radius.XL : Radius.M,
|
|
456
|
+
borderTopRightRadius: isDuo ? Radius.XL : Radius.M,
|
|
366
457
|
overflow: 'hidden',
|
|
367
458
|
},
|
|
368
459
|
isDuo && {
|
|
369
460
|
maxHeight: windowSize.height - topGap - keyboardLift,
|
|
370
461
|
marginBottom: keyboardLift,
|
|
371
462
|
},
|
|
463
|
+
isDuoFloating && { marginHorizontal: Spacing.M },
|
|
464
|
+
sideColumn && {
|
|
465
|
+
minHeight:
|
|
466
|
+
SIDE_COLUMN_OFFSET + sideColumnHeight + sideColumn.bottom,
|
|
467
|
+
},
|
|
372
468
|
isDuoCentered && {
|
|
373
469
|
// keeps the portrait width in landscape, like system sheets
|
|
374
470
|
width: Math.min(
|
|
@@ -415,6 +511,7 @@ const BottomSheet: React.FC<BottomSheetParams> = props => {
|
|
|
415
511
|
}}
|
|
416
512
|
/>
|
|
417
513
|
)}
|
|
514
|
+
{renderSideColumn()}
|
|
418
515
|
</Animated.View>
|
|
419
516
|
</KeyboardAvoidingView>
|
|
420
517
|
</Container>
|
|
@@ -440,10 +537,12 @@ const styles = StyleSheet.create({
|
|
|
440
537
|
top: Spacing.S,
|
|
441
538
|
},
|
|
442
539
|
header: {
|
|
443
|
-
height:
|
|
540
|
+
height: HEADER_HEIGHT,
|
|
444
541
|
flexDirection: 'row',
|
|
445
542
|
alignItems: 'center',
|
|
446
543
|
},
|
|
544
|
+
title: { textAlign: 'center' },
|
|
545
|
+
titleInset: { paddingHorizontal: Spacing.M },
|
|
447
546
|
iconButton: {
|
|
448
547
|
width: 24,
|
|
449
548
|
height: 24,
|
|
@@ -452,6 +551,12 @@ const styles = StyleSheet.create({
|
|
|
452
551
|
marginHorizontal: Spacing.M,
|
|
453
552
|
},
|
|
454
553
|
shrink: { flexShrink: 1 },
|
|
554
|
+
sideColumn: {
|
|
555
|
+
position: 'absolute',
|
|
556
|
+
gap: Spacing.M,
|
|
557
|
+
},
|
|
558
|
+
alignRight: { right: 0 },
|
|
559
|
+
alignLeft: { left: 0 },
|
|
455
560
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
456
561
|
});
|
|
457
562
|
|
|
@@ -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,
|
|
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
|
|
11
|
-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
<
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
pointerEvents={'none'}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
{
|
|
348
|
-
|
|
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
|
-
<
|
|
379
|
-
{
|
|
380
|
-
|
|
381
|
-
pointerEvents={'none'}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
</
|
|
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
|
|
@@ -0,0 +1,374 @@
|
|
|
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 };
|
|
@@ -18,7 +18,7 @@ import { Styles } from '../Consts';
|
|
|
18
18
|
import Navigation from './Navigation';
|
|
19
19
|
import { ModalParams } from './types';
|
|
20
20
|
import BottomSheet from './BottomSheet';
|
|
21
|
-
import {
|
|
21
|
+
import { getDuoModalBounds, isIPhoneDuo } from './device';
|
|
22
22
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
23
23
|
|
|
24
24
|
const ModalScreen: React.FC<any> = props => {
|
|
@@ -42,6 +42,7 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
42
42
|
const Component = useRef(screen).current;
|
|
43
43
|
const insets = useSafeAreaInsets();
|
|
44
44
|
const windowSize = useWindowDimensions();
|
|
45
|
+
const isDuo = isIPhoneDuo();
|
|
45
46
|
|
|
46
47
|
/**
|
|
47
48
|
* Fire auto_popup_displayed tracking event on mount
|
|
@@ -135,6 +136,7 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
135
136
|
onRequestClose={onDismiss}
|
|
136
137
|
style={StyleSheet.absoluteFillObject}
|
|
137
138
|
isModalKit={true}
|
|
139
|
+
supportedOrientations={isDuo ? DUO_ORIENTATIONS : undefined}
|
|
138
140
|
>
|
|
139
141
|
<KeyboardAvoidingView
|
|
140
142
|
style={Styles.flexCenter}
|
|
@@ -152,14 +154,13 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
152
154
|
opacity,
|
|
153
155
|
transform: [{ scale }],
|
|
154
156
|
},
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
marginTop:
|
|
158
|
-
insets,
|
|
159
|
-
windowSize.width,
|
|
160
|
-
windowSize.height,
|
|
161
|
-
),
|
|
157
|
+
isDuo && styles.shrink,
|
|
158
|
+
isDuo && {
|
|
159
|
+
marginTop: insets.top,
|
|
162
160
|
marginBottom: insets.bottom,
|
|
161
|
+
marginLeft: insets.left,
|
|
162
|
+
marginRight: insets.right,
|
|
163
|
+
...getDuoModalBounds(insets, windowSize.width, windowSize.height),
|
|
163
164
|
},
|
|
164
165
|
modalStyle,
|
|
165
166
|
]}
|
|
@@ -171,11 +172,14 @@ const Modal: React.FC<ModalParams> = props => {
|
|
|
171
172
|
);
|
|
172
173
|
};
|
|
173
174
|
|
|
175
|
+
const DUO_ORIENTATIONS = ['portrait', 'landscape'];
|
|
176
|
+
|
|
174
177
|
const styles = StyleSheet.create({
|
|
175
178
|
overlayContent: {
|
|
176
179
|
flex: 1,
|
|
177
180
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
178
181
|
},
|
|
182
|
+
shrink: { flexShrink: 1 },
|
|
179
183
|
});
|
|
180
184
|
|
|
181
185
|
export default ModalScreen;
|
package/Application/device.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Dimensions, Platform, ScaledSize } from 'react-native';
|
|
2
2
|
import type { EdgeInsets } from 'react-native-safe-area-context';
|
|
3
|
+
import { Spacing } from '../Consts';
|
|
3
4
|
|
|
4
5
|
// widest short side of any single-display iPhone
|
|
5
6
|
const IPHONE_MAX_SHORT_SIDE = 440;
|
|
6
7
|
|
|
8
|
+
const DUO_MODAL_MAX_WIDTH = 440;
|
|
9
|
+
const DUO_MODAL_MAX_HEIGHT = 640;
|
|
10
|
+
|
|
7
11
|
// iPhone Duo reports idiom "phone"; 'screen' stays on the outer display, so it
|
|
8
12
|
// still matches when Split View narrows the window
|
|
9
13
|
const isIPhoneDuo = () => {
|
|
@@ -26,4 +30,23 @@ const getDuoTopInset = (insets: EdgeInsets, width: number, height: number) =>
|
|
|
26
30
|
height > width ? Math.max(insets.left, insets.right) * 2 : 0,
|
|
27
31
|
);
|
|
28
32
|
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Max modal size on iPhone Duo: inside the safe area, so clear of the side
|
|
35
|
+
* toolbar strip, and capped so it stays a dialog on the inner display.
|
|
36
|
+
*/
|
|
37
|
+
const getDuoModalBounds = (
|
|
38
|
+
insets: EdgeInsets,
|
|
39
|
+
width: number,
|
|
40
|
+
height: number,
|
|
41
|
+
) => ({
|
|
42
|
+
maxWidth: Math.min(
|
|
43
|
+
width - insets.left - insets.right - Spacing.XL * 2,
|
|
44
|
+
DUO_MODAL_MAX_WIDTH,
|
|
45
|
+
),
|
|
46
|
+
maxHeight: Math.min(
|
|
47
|
+
height - insets.top - insets.bottom - Spacing.XL * 2,
|
|
48
|
+
DUO_MODAL_MAX_HEIGHT,
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export { isIPhoneDuo, getDuoTopInset, getDuoModalBounds };
|
package/Layout/utils.ts
CHANGED
package/Popup/PopupNotify.tsx
CHANGED
|
@@ -8,7 +8,7 @@ import { Image } from '../Image';
|
|
|
8
8
|
import { Text, useScaleSize } from '../Text';
|
|
9
9
|
import { PopupNotifyProps } from './types';
|
|
10
10
|
import { scheduleOnRN } from 'react-native-worklets';
|
|
11
|
-
import { usePopupMaxHeight, usePopupWidth } from './utils';
|
|
11
|
+
import { getPopupRadius, usePopupMaxHeight, usePopupWidth } from './utils';
|
|
12
12
|
|
|
13
13
|
const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
14
14
|
image,
|
|
@@ -26,6 +26,7 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
26
26
|
const [scrollContent, setScrollContent] = useState(false);
|
|
27
27
|
const popupWidth = usePopupWidth();
|
|
28
28
|
const maxHeight = usePopupMaxHeight();
|
|
29
|
+
const radius = getPopupRadius();
|
|
29
30
|
const scaledMaxHeight = useScaleSize(172);
|
|
30
31
|
|
|
31
32
|
const showBaseLineDebug = context?.features?.showBaseLineDebug ?? false;
|
|
@@ -170,7 +171,15 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
170
171
|
|
|
171
172
|
const body = (
|
|
172
173
|
<>
|
|
173
|
-
{!!image &&
|
|
174
|
+
{!!image && (
|
|
175
|
+
<Image
|
|
176
|
+
source={{ uri: image }}
|
|
177
|
+
style={[
|
|
178
|
+
styles.image,
|
|
179
|
+
{ borderTopLeftRadius: radius, borderTopRightRadius: radius },
|
|
180
|
+
]}
|
|
181
|
+
/>
|
|
182
|
+
)}
|
|
174
183
|
<View style={styles.content}>
|
|
175
184
|
<Text
|
|
176
185
|
typography={'header_default_bold'}
|
|
@@ -218,9 +227,9 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
218
227
|
<View
|
|
219
228
|
accessibilityLabel={'popup_notify'}
|
|
220
229
|
style={[
|
|
221
|
-
styles.container,
|
|
222
230
|
{
|
|
223
231
|
backgroundColor: theme.colors.background.surface,
|
|
232
|
+
borderRadius: radius,
|
|
224
233
|
width: popupWidth,
|
|
225
234
|
maxHeight,
|
|
226
235
|
},
|
|
@@ -231,7 +240,13 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
231
240
|
body
|
|
232
241
|
) : (
|
|
233
242
|
// scrolls when iPhone Duo landscape is too short; actions stay visible
|
|
234
|
-
<ScrollView
|
|
243
|
+
<ScrollView
|
|
244
|
+
style={[
|
|
245
|
+
styles.body,
|
|
246
|
+
{ borderTopLeftRadius: radius, borderTopRightRadius: radius },
|
|
247
|
+
]}
|
|
248
|
+
bounces={false}
|
|
249
|
+
>
|
|
235
250
|
{body}
|
|
236
251
|
</ScrollView>
|
|
237
252
|
)}
|
|
@@ -242,17 +257,12 @@ const PopupNotify: React.FC<PopupNotifyProps> = ({
|
|
|
242
257
|
};
|
|
243
258
|
|
|
244
259
|
const styles = StyleSheet.create({
|
|
245
|
-
container: {
|
|
246
|
-
borderRadius: Radius.L,
|
|
247
|
-
},
|
|
248
260
|
debugBaseLine: { borderWidth: 1, borderColor: Colors.green_06 },
|
|
249
261
|
image: {
|
|
250
|
-
borderTopLeftRadius: Radius.L,
|
|
251
|
-
borderTopRightRadius: Radius.L,
|
|
252
262
|
aspectRatio: 1.7777,
|
|
253
263
|
overflow: 'hidden',
|
|
254
264
|
},
|
|
255
|
-
body: { flexShrink: 1 },
|
|
265
|
+
body: { flexShrink: 1, overflow: 'hidden' },
|
|
256
266
|
content: { padding: Spacing.XL },
|
|
257
267
|
description: {
|
|
258
268
|
marginTop: Spacing.S,
|
package/Popup/utils.ts
CHANGED
|
@@ -1,32 +1,26 @@
|
|
|
1
1
|
import { Dimensions, useWindowDimensions } from 'react-native';
|
|
2
2
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
3
|
-
import { Spacing } from '../Consts';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
// width of a system alert on the iPhone Duo inner display, in both orientations
|
|
7
|
-
const DUO_POPUP_MAX_WIDTH = 440;
|
|
3
|
+
import { Radius, Spacing } from '../Consts';
|
|
4
|
+
import { getDuoModalBounds, isIPhoneDuo } from '../Application/device';
|
|
8
5
|
|
|
9
6
|
const usePopupWidth = () => {
|
|
10
|
-
const
|
|
7
|
+
const insets = useSafeAreaInsets();
|
|
8
|
+
const { width, height } = useWindowDimensions();
|
|
11
9
|
if (!isIPhoneDuo()) {
|
|
12
10
|
return Dimensions.get('window').width - Spacing.XL * 2;
|
|
13
11
|
}
|
|
14
|
-
return
|
|
12
|
+
return getDuoModalBounds(insets, width, height).maxWidth;
|
|
15
13
|
};
|
|
16
14
|
|
|
17
|
-
// room left for a popup on iPhone Duo, where landscape is too short for it
|
|
18
15
|
const usePopupMaxHeight = () => {
|
|
19
16
|
const insets = useSafeAreaInsets();
|
|
20
17
|
const { width, height } = useWindowDimensions();
|
|
21
18
|
if (!isIPhoneDuo()) {
|
|
22
19
|
return undefined;
|
|
23
20
|
}
|
|
24
|
-
return (
|
|
25
|
-
height -
|
|
26
|
-
getDuoTopInset(insets, width, height) -
|
|
27
|
-
insets.bottom -
|
|
28
|
-
Spacing.XL * 2
|
|
29
|
-
);
|
|
21
|
+
return getDuoModalBounds(insets, width, height).maxHeight;
|
|
30
22
|
};
|
|
31
23
|
|
|
32
|
-
|
|
24
|
+
const getPopupRadius = () => (isIPhoneDuo() ? Radius.XL : Radius.L);
|
|
25
|
+
|
|
26
|
+
export { usePopupWidth, usePopupMaxHeight, getPopupRadius };
|