@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.
@@ -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
- slideIndex.value = withSpring(activeIndex, SLIDE_SPRING);
164
- }, [activeIndex, slideIndex, isDragging]);
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
- slideIndex.value = withSpring(index, SLIDE_SPRING);
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
- opacity: slideIndex ? 1 - Math.min(Math.abs(slideIndex.value - index), 1) : focused ? 1 : 0,
456
- }),
457
- [slideIndex, index, focused],
458
- );
459
-
460
- const labelStyle = useAnimatedStyle(
461
- () => ({
462
- opacity: interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP),
463
- color: slideIndex
464
- ? interpolateColor(
465
- Math.min(Math.abs(slideIndex.value - index), 1),
466
- [0, 1],
467
- [theme.activeTint, theme.inactiveTint],
468
- )
469
- : focused
470
- ? theme.activeTint
471
- : theme.inactiveTint,
472
- }),
473
- [progress, slideIndex, index, focused, theme],
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) bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
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
@@ -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
  *
@@ -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
  /**