@oxyhq/bloom 0.53.0 → 0.54.1
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/lib/commonjs/tab-bar/TabBarBase.js +145 -20
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +15 -1
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +147 -22
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/shared.js +14 -0
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +52 -0
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +52 -0
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/TabBarBlur.test.tsx +154 -0
- package/src/__tests__/TabBarMaxWidth.test.tsx +413 -0
- package/src/__tests__/TabBarNoSelection.test.tsx +458 -0
- package/src/__tests__/TabBarReanimatedDeps.test.ts +28 -4
- package/src/__tests__/animated-style-deps.test.ts +30 -6
- package/src/tab-bar/TabBar.stories.tsx +52 -1
- package/src/tab-bar/TabBarBase.tsx +178 -46
- package/src/tab-bar/shared.ts +12 -0
- package/src/tab-bar/types.ts +50 -0
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
useMemo,
|
|
23
23
|
type ComponentType,
|
|
24
24
|
} from 'react';
|
|
25
|
-
import { Pressable, StyleSheet, useWindowDimensions, View } from 'react-native';
|
|
25
|
+
import { Pressable, StyleSheet, useWindowDimensions, View, type ViewStyle } from 'react-native';
|
|
26
26
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
27
27
|
import Animated, {
|
|
28
28
|
Extrapolation,
|
|
@@ -32,6 +32,7 @@ import Animated, {
|
|
|
32
32
|
useAnimatedStyle,
|
|
33
33
|
useSharedValue,
|
|
34
34
|
withSpring,
|
|
35
|
+
withTiming,
|
|
35
36
|
type SharedValue,
|
|
36
37
|
} from 'react-native-reanimated';
|
|
37
38
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
BLUR_BLEED,
|
|
45
46
|
EXPANDED_HEIGHT,
|
|
46
47
|
HIGHLIGHT_EXPANDED,
|
|
48
|
+
HIGHLIGHT_FADE,
|
|
47
49
|
HIGHLIGHT_MINIMIZED,
|
|
48
50
|
ICON_SIZE,
|
|
49
51
|
ITEM_GAP,
|
|
@@ -69,6 +71,12 @@ import type { TabBarButtonProps, TabBarProps, TabBarTheme } from './types';
|
|
|
69
71
|
type BarContextValue = {
|
|
70
72
|
/** Live highlight position, in tab units. Fractional while scrubbing. */
|
|
71
73
|
slideIndex: SharedValue<number>;
|
|
74
|
+
/**
|
|
75
|
+
* Highlight visibility, 0–1. Below 1 only when the bar has NO selection (an
|
|
76
|
+
* `activeIndex` that names no tab). Buttons fold it into their own active
|
|
77
|
+
* tint so a faded-out capsule leaves no glyph and no label lit.
|
|
78
|
+
*/
|
|
79
|
+
highlightOpacity: SharedValue<number>;
|
|
72
80
|
/** True while a scrub is in progress — the finger owns the highlight. */
|
|
73
81
|
isDragging: SharedValue<boolean>;
|
|
74
82
|
theme: TabBarTheme;
|
|
@@ -98,6 +106,8 @@ function TabBarBody({
|
|
|
98
106
|
onIndexLongPress,
|
|
99
107
|
theme: themeOverrides,
|
|
100
108
|
haptics = true,
|
|
109
|
+
blur = true,
|
|
110
|
+
maxWidth,
|
|
101
111
|
style,
|
|
102
112
|
...viewProps
|
|
103
113
|
}: TabBarBodyProps) {
|
|
@@ -105,13 +115,53 @@ function TabBarBody({
|
|
|
105
115
|
const { width: windowWidth } = useWindowDimensions();
|
|
106
116
|
const minimized = useMinimizeState();
|
|
107
117
|
const progress = minimized.progress;
|
|
118
|
+
const tabCount = Math.max(Children.count(children), 1);
|
|
119
|
+
|
|
120
|
+
// Is a tab selected at all?
|
|
121
|
+
//
|
|
122
|
+
// The two states this distinguishes are NOT the same thing, and collapsing
|
|
123
|
+
// them would silently kill the highlight for every router consumer:
|
|
124
|
+
//
|
|
125
|
+
// - `activeIndex === undefined` is the FOCUS-DRIVEN path. The bar is not
|
|
126
|
+
// the writer at all — each `TabBarButton` supplies `isFocused` and drives
|
|
127
|
+
// the highlight itself (that is how the adapter keeps it correct through
|
|
128
|
+
// deep links and back gestures), so from the bar's side there is always a
|
|
129
|
+
// selection and the highlight is always visible.
|
|
130
|
+
// - NO SELECTION is `activeIndex` being a number that names no tab:
|
|
131
|
+
// negative, past the last tab, or fractional. Every consumer whose route
|
|
132
|
+
// set is larger than its tab set produces one — a `usePathname()`-derived
|
|
133
|
+
// index is -1 on every screen that is not a tab — and the highlight must
|
|
134
|
+
// then be gone rather than parked outside the pill.
|
|
135
|
+
const hasSelection =
|
|
136
|
+
activeIndex === undefined ||
|
|
137
|
+
(Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < tabCount);
|
|
138
|
+
|
|
108
139
|
const slideIndex = useSharedValue(0);
|
|
140
|
+
// Seeded from the CURRENT state, not from 0: a bar that mounts with a
|
|
141
|
+
// selection must show its highlight on the first frame exactly as it always
|
|
142
|
+
// has, and one that mounts without a selection must never flash it.
|
|
143
|
+
const highlightOpacity = useSharedValue(hasSelection ? 1 : 0);
|
|
109
144
|
const isDragging = useSharedValue(false);
|
|
110
145
|
const lastTicked = useSharedValue(-1);
|
|
111
|
-
const tabCount = Math.max(Children.count(children), 1);
|
|
112
146
|
const theme = useTabBarTheme(themeOverrides);
|
|
113
147
|
const impact = useHaptics();
|
|
114
148
|
|
|
149
|
+
// The pill's OUTER width (the box the animated minimize inset is applied
|
|
150
|
+
// inside), and the single source of truth for it: the wrap's own layout, the
|
|
151
|
+
// highlight geometry and the `indexAtX` scrub worklet all derive from this one
|
|
152
|
+
// number. They have to, or a tap lands on a tab the highlight is not under —
|
|
153
|
+
// the exact failure `maxWidth` exists to prevent, since narrowing the bar from
|
|
154
|
+
// the outside with a `style` override moves only the pixels.
|
|
155
|
+
//
|
|
156
|
+
// Unconstrained it is what the stretched wrap already measures
|
|
157
|
+
// (`windowWidth - BAR_MARGIN * 2`), so an existing consumer's geometry is
|
|
158
|
+
// unchanged to the pixel. `maxWidth` is a ceiling, never a floor: on a window
|
|
159
|
+
// narrower than it, the bar stays full-bleed.
|
|
160
|
+
const barOuterWidth =
|
|
161
|
+
maxWidth === undefined
|
|
162
|
+
? windowWidth - BAR_MARGIN * 2
|
|
163
|
+
: Math.min(windowWidth - BAR_MARGIN * 2, maxWidth);
|
|
164
|
+
|
|
115
165
|
// Picker-style tick while the highlight crosses tab boundaries mid-drag.
|
|
116
166
|
// `useHaptics` already no-ops on web, when the optional `expo-haptics` peer
|
|
117
167
|
// is absent, and when a `BloomHapticsProvider` has haptics turned off — so
|
|
@@ -142,8 +192,26 @@ function TabBarBody({
|
|
|
142
192
|
if (activeIndex === undefined) return;
|
|
143
193
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
144
194
|
if (isDragging.value) return;
|
|
145
|
-
|
|
146
|
-
|
|
195
|
+
if (!hasSelection) {
|
|
196
|
+
// Fade out where it stands. `slideIndex` is deliberately left alone: it
|
|
197
|
+
// is the highlight's POSITION, and there is no position that means
|
|
198
|
+
// "nowhere" — springing it to a sentinel would drag the capsule across
|
|
199
|
+
// the bar on its way out, dragging the active tint over every tab it
|
|
200
|
+
// passed. Visibility is the thing that changed, so visibility is the only
|
|
201
|
+
// thing that animates.
|
|
202
|
+
highlightOpacity.value = withTiming(0, HIGHLIGHT_FADE);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Coming back from fully hidden the capsule APPEARS at the new tab instead
|
|
206
|
+
// of travelling to it: a slide says "the selection moved from here to
|
|
207
|
+
// there", and while it was invisible there was no "here" — sliding from the
|
|
208
|
+
// stale index would animate out of a position the user never saw, and would
|
|
209
|
+
// light up every tab in between on the way. Interrupted mid-fade it is
|
|
210
|
+
// still on screen, so from there it slides as it always does.
|
|
211
|
+
slideIndex.value =
|
|
212
|
+
highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
|
|
213
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
214
|
+
}, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
|
|
147
215
|
|
|
148
216
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
149
217
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
@@ -159,7 +227,13 @@ function TabBarBody({
|
|
|
159
227
|
[0, MINIMIZED_INSET],
|
|
160
228
|
Extrapolation.CLAMP,
|
|
161
229
|
);
|
|
162
|
-
|
|
230
|
+
// `event.x` is measured from the left edge of the view the detector is
|
|
231
|
+
// attached to — the animated pill itself, NOT the window and not the wrap
|
|
232
|
+
// around it. Its width is therefore the outer width minus the two
|
|
233
|
+
// animated margins, which is exactly what this computes, constrained or
|
|
234
|
+
// not. (Margins sit outside a view's own box, so x = 0 is the pill's left
|
|
235
|
+
// border edge either way.)
|
|
236
|
+
const barWidth = barOuterWidth - sideInset * 2;
|
|
163
237
|
const itemWidth = (barWidth - ROW_PAD_H * 2) / tabCount;
|
|
164
238
|
const raw = (x - ROW_PAD_H) / itemWidth - 0.5;
|
|
165
239
|
return Math.min(Math.max(raw, 0), tabCount - 1);
|
|
@@ -168,8 +242,17 @@ function TabBarBody({
|
|
|
168
242
|
const pan = Gesture.Pan()
|
|
169
243
|
.activeOffsetX([-6, 6])
|
|
170
244
|
.failOffsetY([-14, 14])
|
|
171
|
-
.onStart(() => {
|
|
245
|
+
.onStart((event) => {
|
|
172
246
|
isDragging.value = true;
|
|
247
|
+
// Arming from the no-selection state: a hidden capsule has no position
|
|
248
|
+
// the finger can pick up, so it starts under the finger rather than at
|
|
249
|
+
// the index it faded out on — which would also make the first boundary
|
|
250
|
+
// tick fire against a phantom position. Mid-fade it is still visible,
|
|
251
|
+
// so it keeps the position it is at, exactly as it always has.
|
|
252
|
+
if (highlightOpacity.value === 0) {
|
|
253
|
+
slideIndex.value = indexAtX(event.x, progress.value);
|
|
254
|
+
}
|
|
255
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
173
256
|
lastTicked.value = Math.round(slideIndex.value);
|
|
174
257
|
// Scrubbing is a deliberate bar interaction — surface the labels.
|
|
175
258
|
setMinimized(minimized, 0);
|
|
@@ -206,7 +289,11 @@ function TabBarBody({
|
|
|
206
289
|
return;
|
|
207
290
|
}
|
|
208
291
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
209
|
-
|
|
292
|
+
// Same rule as the controlled path above: appear at the tapped tab when
|
|
293
|
+
// hidden, slide to it when already on screen.
|
|
294
|
+
slideIndex.value =
|
|
295
|
+
highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
296
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
210
297
|
setMinimized(minimized, 0);
|
|
211
298
|
runOnJS(selectIndex)(index);
|
|
212
299
|
});
|
|
@@ -230,7 +317,7 @@ function TabBarBody({
|
|
|
230
317
|
|
|
231
318
|
return Gesture.Race(pan, tap, longPress);
|
|
232
319
|
}, [
|
|
233
|
-
|
|
320
|
+
barOuterWidth,
|
|
234
321
|
tabCount,
|
|
235
322
|
selectIndex,
|
|
236
323
|
hasLongPress,
|
|
@@ -239,6 +326,7 @@ function TabBarBody({
|
|
|
239
326
|
isDragging,
|
|
240
327
|
lastTicked,
|
|
241
328
|
slideIndex,
|
|
329
|
+
highlightOpacity,
|
|
242
330
|
minimized,
|
|
243
331
|
progress,
|
|
244
332
|
]);
|
|
@@ -311,39 +399,70 @@ function TabBarBody({
|
|
|
311
399
|
[0, MINIMIZED_INSET],
|
|
312
400
|
Extrapolation.CLAMP,
|
|
313
401
|
);
|
|
314
|
-
|
|
402
|
+
// Same `barOuterWidth` the scrub worklet resolves an index from — see the
|
|
403
|
+
// note where it is computed.
|
|
404
|
+
const barWidth = barOuterWidth - sideInset * 2;
|
|
315
405
|
const itemWidth = (barWidth - ROW_PAD_H * 2) / tabCount;
|
|
316
406
|
return {
|
|
317
407
|
height,
|
|
318
408
|
width: itemWidth,
|
|
319
409
|
borderRadius: height / 2,
|
|
320
410
|
top: (barHeight - height) / 2,
|
|
411
|
+
// With no selection there is nothing to highlight, and the capsule has to
|
|
412
|
+
// stop being drawn: `slideIndex` is a position in tab units, so an
|
|
413
|
+
// out-of-range one is a real place — one item-width to the LEFT of the
|
|
414
|
+
// first tab, i.e. half outside the pill — not an absence.
|
|
415
|
+
opacity: highlightOpacity.value,
|
|
321
416
|
transform: [{ translateX: ROW_PAD_H + itemWidth * slideIndex.value }],
|
|
322
417
|
};
|
|
323
|
-
}, [progress, slideIndex,
|
|
418
|
+
}, [progress, slideIndex, highlightOpacity, barOuterWidth, tabCount]);
|
|
324
419
|
|
|
325
420
|
// Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
|
|
326
421
|
// its own layout can never drift from where the bar actually sits.
|
|
327
422
|
const bottomOffset = tabBarBottomGap(insets.bottom);
|
|
423
|
+
|
|
424
|
+
// How centring and the animated inset compose: centring is STATIC and belongs
|
|
425
|
+
// to the wrap, the inset stays ANIMATED on the pill inside it. The wrap is
|
|
426
|
+
// centred once by layout at a definite width, and the pill's two equal
|
|
427
|
+
// margins keep it centred within that wrap at every point of the minimize
|
|
428
|
+
// animation — neither has to know about the other. Centring the animated view
|
|
429
|
+
// itself would instead mean `alignSelf: 'center'` on a node with no width of
|
|
430
|
+
// its own (its width comes from those margins), which Yoga then sizes from its
|
|
431
|
+
// CONTENT rather than from the constraint.
|
|
432
|
+
//
|
|
433
|
+
// Applied only when `maxWidth` is set: with no width and no `alignSelf` the
|
|
434
|
+
// wrap stretches, which is the original full-bleed behaviour.
|
|
435
|
+
const constrainedWrapStyle: ViewStyle | null =
|
|
436
|
+
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
328
437
|
const barContext = useMemo(
|
|
329
|
-
() => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
|
|
330
|
-
[slideIndex, isDragging, theme, activeIndex, selectIndex],
|
|
438
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
439
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
331
440
|
);
|
|
332
441
|
|
|
333
442
|
return (
|
|
334
443
|
<View {...viewProps} style={[styles.root, style]}>
|
|
335
|
-
{/* Progressive blur rising from the screen's bottom edge behind the pill.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
444
|
+
{/* Progressive blur rising from the screen's bottom edge behind the pill.
|
|
445
|
+
Rendered CONDITIONALLY, and as nothing at all when off: the band is
|
|
446
|
+
full-bleed and 114pt tall at a zero bottom inset, so it blurs whatever
|
|
447
|
+
a screen floats near the bottom edge (a scrubber, a FAB), and leaving
|
|
448
|
+
an empty absolutely-positioned view behind would keep a node — and a
|
|
449
|
+
rect — that exists to do nothing. Stays full-bleed under `maxWidth`:
|
|
450
|
+
it is the screen-edge scrim content dissolves into, not part of the
|
|
451
|
+
pill. */}
|
|
452
|
+
{blur !== false && (
|
|
453
|
+
<Blur
|
|
454
|
+
direction="bottom"
|
|
455
|
+
intensity={typeof blur === 'object' ? blur.intensity : undefined}
|
|
456
|
+
style={{
|
|
457
|
+
position: 'absolute',
|
|
458
|
+
left: 0,
|
|
459
|
+
right: 0,
|
|
460
|
+
bottom: 0,
|
|
461
|
+
height: bottomOffset + EXPANDED_HEIGHT + BLUR_BLEED,
|
|
462
|
+
}}
|
|
463
|
+
/>
|
|
464
|
+
)}
|
|
465
|
+
<View style={[styles.barWrap, { marginBottom: bottomOffset }, constrainedWrapStyle]}>
|
|
347
466
|
<GestureDetector gesture={gesture}>
|
|
348
467
|
<Animated.View style={barStyle}>
|
|
349
468
|
<Surface theme={theme} style={shapeStyle} />
|
|
@@ -381,6 +500,7 @@ function TabBarButtonBody({
|
|
|
381
500
|
const standaloneTheme = useTabBarTheme();
|
|
382
501
|
const theme = bar?.theme ?? standaloneTheme;
|
|
383
502
|
const slideIndex = bar?.slideIndex;
|
|
503
|
+
const highlightOpacity = bar?.highlightOpacity;
|
|
384
504
|
// The two paths meet here: an explicit `isFocused` (router adapter) wins;
|
|
385
505
|
// otherwise focus comes from the bar's controlled `activeIndex`.
|
|
386
506
|
const focused = isFocused ?? (bar?.activeIndex === index);
|
|
@@ -399,29 +519,35 @@ function TabBarButtonBody({
|
|
|
399
519
|
// Tint follows the sliding highlight, not navigation focus: whatever the pill
|
|
400
520
|
// is over lights up — live while scrubbing, traveling on taps. Without a bar
|
|
401
521
|
// there is nothing to follow, so it falls back to plain focus.
|
|
522
|
+
//
|
|
523
|
+
// Scaled by the highlight's own visibility, so a bar with NO selection leaves
|
|
524
|
+
// nothing lit: distance alone would keep the tab the capsule faded out on
|
|
525
|
+
// fully tinted, which is the same bug as a stray capsule wearing a different
|
|
526
|
+
// hat — a tab that looks selected while nothing is.
|
|
402
527
|
// (Deps: see the CRITICAL note in `TabBarBody`.)
|
|
403
|
-
const activeGlyphStyle = useAnimatedStyle(
|
|
404
|
-
()
|
|
405
|
-
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
);
|
|
528
|
+
const activeGlyphStyle = useAnimatedStyle(() => {
|
|
529
|
+
if (!slideIndex || !highlightOpacity) return { opacity: focused ? 1 : 0 };
|
|
530
|
+
const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
|
|
531
|
+
return { opacity: highlightOpacity.value * proximity };
|
|
532
|
+
}, [slideIndex, highlightOpacity, index, focused]);
|
|
409
533
|
|
|
410
|
-
const labelStyle = useAnimatedStyle(
|
|
411
|
-
(
|
|
412
|
-
|
|
413
|
-
color:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
534
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
535
|
+
const opacity = interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP);
|
|
536
|
+
if (!slideIndex || !highlightOpacity) {
|
|
537
|
+
return { opacity, color: focused ? theme.activeTint : theme.inactiveTint };
|
|
538
|
+
}
|
|
539
|
+
// Same quantity as the glyph crossfade above, so a label can never disagree
|
|
540
|
+
// with the icon it sits under.
|
|
541
|
+
const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
|
|
542
|
+
return {
|
|
543
|
+
opacity,
|
|
544
|
+
color: interpolateColor(
|
|
545
|
+
highlightOpacity.value * proximity,
|
|
546
|
+
[0, 1],
|
|
547
|
+
[theme.inactiveTint, theme.activeTint],
|
|
548
|
+
),
|
|
549
|
+
};
|
|
550
|
+
}, [progress, slideIndex, highlightOpacity, index, focused, theme]);
|
|
425
551
|
|
|
426
552
|
// Height is animated EXPLICITLY (not derived from children) so the icon stays
|
|
427
553
|
// perfectly centered every frame — layout-driven sizing lags a frame behind
|
|
@@ -449,7 +575,13 @@ function TabBarButtonBody({
|
|
|
449
575
|
onPress={(event) => {
|
|
450
576
|
// The bar's GestureDetector normally consumes touches; this still fires
|
|
451
577
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
452
|
-
if (bar)
|
|
578
|
+
if (bar) {
|
|
579
|
+
// Appear at the tab when hidden, slide to it when visible — the same
|
|
580
|
+
// rule the tap gesture and the controlled path follow.
|
|
581
|
+
bar.slideIndex.value =
|
|
582
|
+
bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
583
|
+
bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
584
|
+
}
|
|
453
585
|
setMinimized(minimized, 0);
|
|
454
586
|
// Controlled path only. On the focus-driven path the trigger's own
|
|
455
587
|
// `onPress` below performs the navigation, so reporting the selection
|
package/src/tab-bar/shared.ts
CHANGED
|
@@ -79,6 +79,18 @@ export function tabBarBottomGap(bottomInset: number): number {
|
|
|
79
79
|
*/
|
|
80
80
|
export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fade for the highlight coming and going when the selection does — an
|
|
84
|
+
* `activeIndex` that names no tab (see `TabBarProps.activeIndex`).
|
|
85
|
+
*
|
|
86
|
+
* A timing, not a spring, and deliberately shorter than {@link SLIDE_SPRING}:
|
|
87
|
+
* appearing and disappearing is a change of STATE, not a movement, so it must
|
|
88
|
+
* not read as a second animation racing the slide. The capsule fades where it
|
|
89
|
+
* stands — moving it out of view instead would drag it the length of the bar on
|
|
90
|
+
* its way out and sweep the active tint across every tab it passed.
|
|
91
|
+
*/
|
|
92
|
+
export const HIGHLIGHT_FADE = { duration: 160 };
|
|
93
|
+
|
|
82
94
|
/**
|
|
83
95
|
* How long a press must be held before `onIndexLongPress` fires.
|
|
84
96
|
*
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -78,6 +78,18 @@ export type TabBarProps = ViewProps & {
|
|
|
78
78
|
* highlight and springs it here whenever this changes. Use this when there is
|
|
79
79
|
* no router in play; the router adapter uses the per-button `isFocused` path
|
|
80
80
|
* instead (see {@link TabBarButtonProps.isFocused}) and must NOT pass this.
|
|
81
|
+
*
|
|
82
|
+
* An index that names NO tab — negative, past the last tab, or fractional —
|
|
83
|
+
* means no selection: the highlight fades out where it stands and no tab is
|
|
84
|
+
* left tinted. Pass one whenever the current screen is not a tab; an index
|
|
85
|
+
* derived from the route (`TABS.findIndex(…)`) is `-1` on every such screen
|
|
86
|
+
* and is correct as-is. Returning to a real index fades the highlight back in
|
|
87
|
+
* AT that tab rather than sliding to it — while it was invisible there was no
|
|
88
|
+
* position to travel from — and scrubbing still arms it under the finger.
|
|
89
|
+
*
|
|
90
|
+
* OMITTING this prop is a different thing entirely and does not hide
|
|
91
|
+
* anything: that is the focus-driven path, where each button drives the
|
|
92
|
+
* highlight from its own `isFocused`.
|
|
81
93
|
*/
|
|
82
94
|
activeIndex?: number;
|
|
83
95
|
/**
|
|
@@ -107,6 +119,44 @@ export type TabBarProps = ViewProps & {
|
|
|
107
119
|
* optional `expo-haptics` peer is absent.
|
|
108
120
|
*/
|
|
109
121
|
haptics?: boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The progressive blur rising from the bottom edge of the screen behind the
|
|
124
|
+
* pill. Defaults to `true`; `{ intensity }` tunes its strength.
|
|
125
|
+
*
|
|
126
|
+
* Pass `false` to remove it entirely — no blur, and no node left behind. The
|
|
127
|
+
* band is tall (the bar's own bottom gap plus its expanded height plus the
|
|
128
|
+
* bleed above it, 114pt at a zero bottom inset) and full-bleed, so anything a
|
|
129
|
+
* screen floats near the bottom edge — a video scrubber, a FAB — sits INSIDE
|
|
130
|
+
* it and is blurred. That cannot be undone from the outside: `zIndex` only
|
|
131
|
+
* orders siblings within one stacking context, and a screen's FAB is inside
|
|
132
|
+
* an earlier sibling of the bar's host, so no z-order a consumer can write
|
|
133
|
+
* lifts it above this band. Turning the blur off is the only fix that does
|
|
134
|
+
* not also change where the FAB paints relative to everything else.
|
|
135
|
+
*
|
|
136
|
+
* On native the band is ten stacked `expo-blur` layers plus a tail gradient;
|
|
137
|
+
* on web it is one masked `backdrop-filter`. `false` skips both.
|
|
138
|
+
*/
|
|
139
|
+
blur?: boolean | { intensity?: number };
|
|
140
|
+
/**
|
|
141
|
+
* Maximum width of the pill, in points. No default — omit it and the bar
|
|
142
|
+
* spans the window exactly as it always has.
|
|
143
|
+
*
|
|
144
|
+
* A CEILING, never a floor: on a window narrower than this the bar keeps its
|
|
145
|
+
* full-bleed width, so a phone layout is untouched by any value big enough to
|
|
146
|
+
* matter on a tablet. When it binds, the pill is constrained to this width and
|
|
147
|
+
* CENTRED, and the ITEM geometry follows it — the item width, the sliding
|
|
148
|
+
* highlight's position and the tap/scrub hit-testing all derive from the same
|
|
149
|
+
* constrained width, so a tap still lands on the tab it is visibly over.
|
|
150
|
+
*
|
|
151
|
+
* That last part is why this cannot be done from the outside. Narrowing the
|
|
152
|
+
* bar with a `style` override moves the pixels only: the highlight and the
|
|
153
|
+
* scrub worklet would still divide the WINDOW width by the tab count, leaving
|
|
154
|
+
* the highlight sized and positioned for a bar that is no longer there. On an
|
|
155
|
+
* iPad the unconstrained bar is the reason to reach for this at all — 810pt at
|
|
156
|
+
* 11" portrait, 1342pt in landscape, leaving 21pt glyphs adrift in cells
|
|
157
|
+
* hundreds of points wide.
|
|
158
|
+
*/
|
|
159
|
+
maxWidth?: number;
|
|
110
160
|
};
|
|
111
161
|
|
|
112
162
|
export type TabBarButtonProps = PressableProps & {
|