@oxyhq/bloom 0.54.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 +101 -16
- 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 +103 -18
- 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 +12 -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 +12 -0
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +1 -1
- 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 +113 -31
- package/src/tab-bar/shared.ts +12 -0
- package/src/tab-bar/types.ts +12 -0
|
@@ -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;
|
|
@@ -107,10 +115,34 @@ function TabBarBody({
|
|
|
107
115
|
const { width: windowWidth } = useWindowDimensions();
|
|
108
116
|
const minimized = useMinimizeState();
|
|
109
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
|
+
|
|
110
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);
|
|
111
144
|
const isDragging = useSharedValue(false);
|
|
112
145
|
const lastTicked = useSharedValue(-1);
|
|
113
|
-
const tabCount = Math.max(Children.count(children), 1);
|
|
114
146
|
const theme = useTabBarTheme(themeOverrides);
|
|
115
147
|
const impact = useHaptics();
|
|
116
148
|
|
|
@@ -160,8 +192,26 @@ function TabBarBody({
|
|
|
160
192
|
if (activeIndex === undefined) return;
|
|
161
193
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
162
194
|
if (isDragging.value) return;
|
|
163
|
-
|
|
164
|
-
|
|
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]);
|
|
165
215
|
|
|
166
216
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
167
217
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
@@ -192,8 +242,17 @@ function TabBarBody({
|
|
|
192
242
|
const pan = Gesture.Pan()
|
|
193
243
|
.activeOffsetX([-6, 6])
|
|
194
244
|
.failOffsetY([-14, 14])
|
|
195
|
-
.onStart(() => {
|
|
245
|
+
.onStart((event) => {
|
|
196
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);
|
|
197
256
|
lastTicked.value = Math.round(slideIndex.value);
|
|
198
257
|
// Scrubbing is a deliberate bar interaction — surface the labels.
|
|
199
258
|
setMinimized(minimized, 0);
|
|
@@ -230,7 +289,11 @@ function TabBarBody({
|
|
|
230
289
|
return;
|
|
231
290
|
}
|
|
232
291
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
233
|
-
|
|
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);
|
|
234
297
|
setMinimized(minimized, 0);
|
|
235
298
|
runOnJS(selectIndex)(index);
|
|
236
299
|
});
|
|
@@ -263,6 +326,7 @@ function TabBarBody({
|
|
|
263
326
|
isDragging,
|
|
264
327
|
lastTicked,
|
|
265
328
|
slideIndex,
|
|
329
|
+
highlightOpacity,
|
|
266
330
|
minimized,
|
|
267
331
|
progress,
|
|
268
332
|
]);
|
|
@@ -344,9 +408,14 @@ function TabBarBody({
|
|
|
344
408
|
width: itemWidth,
|
|
345
409
|
borderRadius: height / 2,
|
|
346
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,
|
|
347
416
|
transform: [{ translateX: ROW_PAD_H + itemWidth * slideIndex.value }],
|
|
348
417
|
};
|
|
349
|
-
}, [progress, slideIndex, barOuterWidth, tabCount]);
|
|
418
|
+
}, [progress, slideIndex, highlightOpacity, barOuterWidth, tabCount]);
|
|
350
419
|
|
|
351
420
|
// Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
|
|
352
421
|
// its own layout can never drift from where the bar actually sits.
|
|
@@ -366,8 +435,8 @@ function TabBarBody({
|
|
|
366
435
|
const constrainedWrapStyle: ViewStyle | null =
|
|
367
436
|
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
368
437
|
const barContext = useMemo(
|
|
369
|
-
() => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
|
|
370
|
-
[slideIndex, isDragging, theme, activeIndex, selectIndex],
|
|
438
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
439
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
371
440
|
);
|
|
372
441
|
|
|
373
442
|
return (
|
|
@@ -431,6 +500,7 @@ function TabBarButtonBody({
|
|
|
431
500
|
const standaloneTheme = useTabBarTheme();
|
|
432
501
|
const theme = bar?.theme ?? standaloneTheme;
|
|
433
502
|
const slideIndex = bar?.slideIndex;
|
|
503
|
+
const highlightOpacity = bar?.highlightOpacity;
|
|
434
504
|
// The two paths meet here: an explicit `isFocused` (router adapter) wins;
|
|
435
505
|
// otherwise focus comes from the bar's controlled `activeIndex`.
|
|
436
506
|
const focused = isFocused ?? (bar?.activeIndex === index);
|
|
@@ -449,29 +519,35 @@ function TabBarButtonBody({
|
|
|
449
519
|
// Tint follows the sliding highlight, not navigation focus: whatever the pill
|
|
450
520
|
// is over lights up — live while scrubbing, traveling on taps. Without a bar
|
|
451
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.
|
|
452
527
|
// (Deps: see the CRITICAL note in `TabBarBody`.)
|
|
453
|
-
const activeGlyphStyle = useAnimatedStyle(
|
|
454
|
-
()
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
()
|
|
462
|
-
opacity:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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]);
|
|
533
|
+
|
|
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]);
|
|
475
551
|
|
|
476
552
|
// Height is animated EXPLICITLY (not derived from children) so the icon stays
|
|
477
553
|
// perfectly centered every frame — layout-driven sizing lags a frame behind
|
|
@@ -499,7 +575,13 @@ function TabBarButtonBody({
|
|
|
499
575
|
onPress={(event) => {
|
|
500
576
|
// The bar's GestureDetector normally consumes touches; this still fires
|
|
501
577
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
502
|
-
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
|
+
}
|
|
503
585
|
setMinimized(minimized, 0);
|
|
504
586
|
// Controlled path only. On the focus-driven path the trigger's own
|
|
505
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
|
/**
|