bring back anim task bar

This commit is contained in:
Boof2015
2026-08-02 18:36:43 -04:00
parent cf4f09c077
commit 4c6566b442
6 changed files with 447 additions and 30 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts",
"test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/components/selectionSlideMath.test.mts",
"test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts",
+48 -14
View File
@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import {
Pressable,
StyleSheet,
View
View,
type LayoutChangeEvent
} from 'react-native';
import Animated, {
interpolateColor,
@@ -18,7 +19,10 @@ import { createThemedStyles, useColors } from '@/theme/themed';
import { useRipple } from '@/theme/ripple';
import { motion } from '@/theme/motion';
import { playHaptic } from '@/lib/haptics';
import { useSelectionSlide } from './selectionSlide.ts';
/** The pill's breathing room inside the track. Now the track's own padding, so
* the pill can be positioned in the same box its segments are measured in. */
const THUMB_INSET = 3;
export interface Segment {
@@ -33,23 +37,43 @@ interface SegmentedControlProps {
}
/**
* Equal-width segmented control with a selection pill anchored inside the
* active segment. The structural tie to `focused` keeps the pill and content
* selection synchronized; labels still cross-fade via Animated.Text.
* Segmented control whose selection pill slides to the chosen segment.
*
* The pill follows the segment's *measured* box rather than its index — see
* `selectionSlideMath` for why that distinction is the whole point. A `value`
* matching no segment leaves the pill hidden instead of parked on the first one,
* so it can never claim a selection the caller doesn't have. Labels still
* cross-fade via Animated.Text.
*/
export function SegmentedControl({ segments, value, onChange }: SegmentedControlProps) {
const styles = useStyles();
const slide = useSelectionSlide(
segments.some((segment) => segment.key === value) ? value : null,
'horizontal'
);
const { offset, extent, presence } = slide;
const thumbStyle = useAnimatedStyle(() => ({
opacity: presence.value,
width: extent.value,
transform: [{ translateX: offset.value }],
}));
return (
<View style={styles.track}>
{segments.map((segment) => (
<SegmentButton
key={segment.key}
label={segment.label}
focused={segment.key === value}
onPress={() => onChange(segment.key)}
/>
))}
{/* The track carries the pill's inset as padding, so this row is the box
both the pill and the segments are measured in. */}
<View style={styles.row}>
<Animated.View style={[styles.thumb, thumbStyle]} pointerEvents="none" />
{segments.map((segment) => (
<SegmentButton
key={segment.key}
label={segment.label}
focused={segment.key === value}
onLayout={slide.measure(segment.key)}
onPress={() => onChange(segment.key)}
/>
))}
</View>
</View>
);
}
@@ -58,10 +82,12 @@ function SegmentButton({
label,
focused,
onPress,
onLayout,
}: {
label: string;
focused: boolean;
onPress: () => void;
onLayout: (event: LayoutChangeEvent) => void;
}) {
const styles = useStyles();
const colors = useColors();
@@ -92,10 +118,10 @@ function SegmentButton({
android_ripple={ripple.bounded}
style={styles.segment}
onPress={handlePress}
onLayout={onLayout}
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
{focused ? <View style={styles.thumb} pointerEvents="none" /> : null}
<Animated.Text style={[styles.label, labelStyle]} numberOfLines={1}>
{label}
</Animated.Text>
@@ -112,10 +138,18 @@ const useStyles = createThemedStyles((colors) => ({
borderRadius: radius.pill,
padding: THUMB_INSET,
},
// `flex` because the segments inside share it out from a zero basis: without
// it this row sizes to their content, which is nothing, and the control
// collapses.
row: {
flex: 1,
flexDirection: 'row',
},
// Width and offset come from the focused segment's measurement; the vertical
// inset is the track's padding around this row.
thumb: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
backgroundColor: colors.glassHighlight,
+102 -15
View File
@@ -2,7 +2,8 @@ import { useEffect } from 'react';
import {
View,
Pressable,
StyleSheet
StyleSheet,
type LayoutChangeEvent
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
@@ -17,6 +18,7 @@ import Animated, {
withTiming
} from 'react-native-reanimated';
import { MiniPlayer } from './MiniPlayer';
import { useSelectionSlide, type SelectionSlide } from './selectionSlide.ts';
import {
fonts,
layout,
@@ -88,6 +90,19 @@ export function TabBar({ items, onPress, shell }: TabBarProps) {
const rail = shell.mode === 'rail';
const split = shell.mode === 'split';
// The mark follows the focused item's measured rect. It reads the same
// `focused` flag the item colours itself from, so the two cannot point at
// different tabs — and when nothing is focused there is deliberately no key to
// follow rather than a fallback slot. The mode is its shape key: changing
// shells invalidates every measurement at once, so the mark is re-placed from
// the new ones without travelling. Rotating should find it already in
// position, not watch it cross the screen diagonally.
const slide = useSelectionSlide(
tabs.find((item) => item.focused)?.key ?? null,
rail ? 'vertical' : 'horizontal',
shell.mode
);
const buttons = tabs.map((item) => {
const meta = TAB_META[item.name];
if (!meta) return null;
@@ -97,6 +112,7 @@ export function TabBar({ items, onPress, shell }: TabBarProps) {
meta={meta}
focused={item.focused}
rail={rail}
onLayout={slide.measure(item.key)}
height={rail ? shell.navItemHeight : undefined}
// Split items are sized by the shell rather than sharing the row
// equally: the mini player takes the rest, and the leftover becomes the
@@ -121,7 +137,10 @@ export function TabBar({ items, onPress, shell }: TabBarProps) {
},
]}
>
<View style={styles.railNav}>{buttons}</View>
<View style={styles.railNav}>
<SelectionMark key={shell.mode} slide={slide} rail />
{buttons}
</View>
{/* Wrapped so the rail has exactly one flexible child. MiniPlayer also
renders a PlaybackTargetPicker Modal as a sibling, and a bare
justifyContent here would have treated that as a third item and
@@ -163,7 +182,13 @@ export function TabBar({ items, onPress, shell }: TabBarProps) {
{ width: shell.splitBar.navWidth, height: shell.splitBar.height },
]}
>
{buttons}
{/* The mark is placed from the items' own measured offsets, so it
has to share a box with them that adds no padding or border of
its own — the card has both. */}
<View style={styles.splitRow}>
<SelectionMark key={shell.mode} slide={slide} />
{buttons}
</View>
</Animated.View>
{/* Docked, the player card IS the pane — showing both would be the
same track twice. It collapses its own width rather than
@@ -196,6 +221,7 @@ export function TabBar({ items, onPress, shell }: TabBarProps) {
{ paddingBottom: insets.bottom, height: layout.tabBarHeight + insets.bottom },
]}
>
<SelectionMark key={shell.mode} slide={slide} />
{buttons}
</View>
</View>
@@ -241,10 +267,52 @@ function SplitPlayerCard({ shell }: { shell: ShellLayout }) {
);
}
/**
* The accent mark, sliding to whichever destination is selected.
*
* It is a sibling of the nav items rather than a child of the focused one, which
* is the only way it can travel — but that means it has to be positioned rather
* than placed, and positioning it by *counting slots* is what made the previous
* one land on the wrong tab. It follows measured rects instead; see
* `selectionSlideMath`.
*
* Mount this with `key={shell.mode}`: the two axes animate different style
* properties, so the worklet has to be rebuilt when the shell changes shape
* instead of carrying a width into a rail that has no use for one.
*/
function SelectionMark({ slide, rail = false }: { slide: SelectionSlide; rail?: boolean }) {
const styles = useStyles();
const { offset, extent, presence } = slide;
const markStyle = useAnimatedStyle(() =>
rail
? {
opacity: presence.value,
height: extent.value,
transform: [{ translateY: offset.value }],
}
: {
opacity: presence.value,
width: extent.value,
transform: [{ translateX: offset.value }],
}
);
return (
<Animated.View
style={[rail ? styles.railIndicator : styles.indicator, markStyle]}
pointerEvents="none"
>
<View style={rail ? styles.railIndicatorBar : styles.indicatorBar} />
</Animated.View>
);
}
interface TabButtonProps {
meta: { label: string; icon: IconName };
focused: boolean;
onPress: () => void;
/** Reports this item's box so the shared mark can travel to it. */
onLayout: (event: LayoutChangeEvent) => void;
/** Rail items are fixed-height and mark selection on their leading edge. */
rail?: boolean;
height?: number;
@@ -259,8 +327,20 @@ interface TabButtonProps {
* class-wrapped Text that Animated can't drive, so colour is a cross-fade
* between a grey base icon and an accent one stacked on top. Spring-free per
* theme/motion.
*
* It does not draw the selection mark: a mark that belongs to the focused item
* can only appear and disappear, never travel. It reports its box instead, and
* `SelectionMark` slides to it.
*/
function TabButton({ meta, focused, onPress, rail = false, height, width }: TabButtonProps) {
function TabButton({
meta,
focused,
onPress,
onLayout,
rail = false,
height,
width,
}: TabButtonProps) {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
@@ -304,6 +384,7 @@ function TabButton({ meta, focused, onPress, rail = false, height, width }: TabB
width ? { width } : null,
]}
onPress={handlePress}
onLayout={onLayout}
onPressIn={() => {
press.value = withTiming(1, motion.quick);
}}
@@ -314,14 +395,6 @@ function TabButton({ meta, focused, onPress, rail = false, height, width }: TabB
accessibilityRole="tab"
accessibilityState={{ selected: focused }}
>
{focused ? (
<View
style={rail ? styles.railIndicator : styles.indicator}
pointerEvents="none"
>
<View style={rail ? styles.railIndicatorBar : styles.indicatorBar} />
</View>
) : null}
<Animated.View style={depressStyle}>
<Ionicons name={meta.icon} size={22} color={colors.textTertiary} />
<Animated.View style={[StyleSheet.absoluteFill, accentStyle]}>
@@ -378,6 +451,18 @@ const useStyles = createThemedStyles((colors) => ({
borderWidth: 1,
overflow: 'hidden',
},
// The card's padded interior as its own box, so the mark and the items it
// measures share one coordinate space. Measured offsets count from the card's
// border, an absolute child counts from inside it, and the difference would be
// a permanent 1dp lean.
// `alignSelf` because the card centres its children: without it this row would
// shrink to its content and take the items — which stretch to *it* — with it,
// dropping them off the card's padded top edge along with the mark.
splitRow: {
flex: 1,
alignSelf: 'stretch',
flexDirection: 'row',
},
// Landscape rail: destinations at the top, mini player pushed to the foot by
// `railFoot`'s auto margin. `getShellLayout` guarantees both fit.
rail: {
@@ -391,11 +476,13 @@ const useStyles = createThemedStyles((colors) => ({
railFoot: {
marginTop: 'auto',
},
// Width and offset come from the focused item's measurement, so the mark is
// exactly as wide as the tab it marks whether the row divides evenly (a
// phone's bar) or not (a split card mid-resize). The 28dp bar centres in it.
indicator: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
alignItems: 'center',
},
indicatorBar: {
@@ -405,11 +492,11 @@ const useStyles = createThemedStyles((colors) => ({
backgroundColor: colors.accent,
},
// The rail's selection mark reads down the leading edge rather than across
// the top, so it points along the rail's own axis.
// the top, so it points along the rail's own axis — and takes its height from
// the item rather than a `bottom` anchor, so it can travel between them.
railIndicator: {
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
justifyContent: 'center',
},
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useRef } from 'react';
import type { LayoutChangeEvent } from 'react-native';
import { useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated';
import { motion } from '@/theme/motion';
import { isMeasured, resolveMark, type ItemRect } from './selectionSlideMath.ts';
/** Which way the mark travels. Rails run down their leading edge. */
export type SelectionAxis = 'horizontal' | 'vertical';
/**
* Why the mark is moving, which is what decides whether it travels at all.
*
* `select` is the mark saying something — the selection changed and the trip
* between the two is the message. `correct` is the mark saying nothing: the
* layout moved underneath it and it is only staying on the item it already
* points at. Corrections land immediately, because a mark in flight is a mark
* temporarily pointing at nothing, and there is nothing here worth watching.
*/
type Travel = 'select' | 'correct';
export interface SelectionSlide {
/** `onLayout` for the item with this key. */
measure: (key: string) => (event: LayoutChangeEvent) => void;
/** Distance along the travel axis: x, or y on a rail. */
offset: SharedValue<number>;
/** Size along the travel axis: the item's width, or its height on a rail. */
extent: SharedValue<number>;
/** 0 hides the mark — nothing is selected, or nothing has measured yet. */
presence: SharedValue<number>;
}
interface SlideState {
rects: Map<string, ItemRect>;
activeKey: string | null;
/** False until the mark holds a real position, so the next landing is a cut. */
placed: boolean;
/**
* When the selection trip currently in flight lands. A correction arriving
* before then re-aims that trip instead of teleporting out of the middle of
* it.
*/
travellingUntil: number;
}
/**
* Move the mark to wherever the selected item measured, or hide it if there
* isn't one.
*
* At module scope on purpose: the compiler freezes a value once a component
* passes it to a hook, so the writes live in a plain function that only ever
* sees the shared values as arguments.
*/
function placeMark(
state: SlideState,
offset: SharedValue<number>,
extent: SharedValue<number>,
presence: SharedValue<number>,
travel: Travel
) {
const mark = resolveMark(state.rects, state.activeKey);
if (!mark) {
// Nothing to point at. Fade out where it stands and forget the spot, so
// whatever is selected next is a cut rather than a slide out of a stale
// position it was never really at.
presence.value = withTiming(0, motion.quick);
state.placed = false;
state.travellingUntil = 0;
return;
}
if (!state.placed) {
offset.value = mark.offset;
extent.value = mark.extent;
presence.value = withTiming(1, motion.quick);
state.placed = true;
state.travellingUntil = 0;
return;
}
if (travel === 'select') {
offset.value = withTiming(mark.offset, motion.quick);
extent.value = withTiming(mark.extent, motion.quick);
state.travellingUntil = Date.now() + motion.quick.duration;
return;
}
if (Date.now() < state.travellingUntil) {
// Mid-trip. Re-aim rather than cut, or a layout event landing during a tab
// switch would abort the very animation the switch is there to show.
offset.value = withTiming(mark.offset, motion.quick);
extent.value = withTiming(mark.extent, motion.quick);
return;
}
offset.value = mark.offset;
extent.value = mark.extent;
}
/**
* Slide a selection mark to whichever item is selected.
*
* Position comes from the items' own measured rects (see `selectionSlideMath`),
* so one hook serves a phone's equal-flex tab row, a rail's column and a split
* card's shell-sized items without knowing which it is in.
*
* Whether the mark travels depends on *why* it moved:
*
* - **Selection changed** — it travels, over `motion.quick`, matching both the
* tab scene's 160ms cross-fade and the icon/label colour fade in the item
* itself. The mark this replaces took 220ms, so it was still crossing the bar
* after the scene and the colours had already committed to the new tab.
* - **Layout changed** — it lands, immediately. Animating these was what made a
* single bad measurement read as a deliberate move in the wrong direction, and
* landing them also keeps the mark glued to an item that is itself animating
* (the split card's items resizing when the dock opens) rather than lagging a
* fixed duration behind it.
* - **First placement, or a new `shapeKey`** — no travel at all. A cold start or
* a rotation should find the mark already in place, not watch it fly in.
*/
export function useSelectionSlide(
activeKey: string | null,
axis: SelectionAxis,
/**
* Changing this drops every measurement, because the shape they described no
* longer exists. Pass the shell mode where the container can change shape.
*/
shapeKey: string | number = 'fixed'
): SelectionSlide {
const offset = useSharedValue(0);
const extent = useSharedValue(0);
const presence = useSharedValue(0);
const state = useRef<SlideState>({
rects: new Map(),
activeKey,
placed: false,
travellingUntil: 0,
});
// Declared first so a simultaneous shape-and-selection change clears the old
// geometry before anything is placed against it.
useEffect(() => {
const current = state.current;
current.rects.clear();
current.placed = false;
current.travellingUntil = 0;
// Hidden rather than left standing: one frame of the mark at an offset from
// a layout that no longer exists is exactly the wrong thing to highlight.
presence.value = 0;
}, [shapeKey, presence]);
useEffect(() => {
state.current.activeKey = activeKey;
placeMark(state.current, offset, extent, presence, 'select');
}, [activeKey, offset, extent, presence]);
const measure = (key: string) => (event: LayoutChangeEvent) => {
const { x, y, width, height } = event.nativeEvent.layout;
const rect: ItemRect =
axis === 'horizontal' ? { offset: x, extent: width } : { offset: y, extent: height };
// A pass that sized the item at nothing is not a measurement — see
// `isMeasured`. Recording it would collapse the mark's box and throw the
// centred bar half a tab to the left until a real one arrived.
if (!isMeasured(rect)) return;
const current = state.current;
const previous = current.rects.get(key);
if (previous && previous.offset === rect.offset && previous.extent === rect.extent) {
return;
}
current.rects.set(key, rect);
// Only the selected item moving needs anything done about it; the rest are
// being recorded against the moment they are selected.
if (key === current.activeKey) {
placeMark(current, offset, extent, presence, 'correct');
}
};
return { measure, offset, extent, presence };
}
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isMeasured, resolveMark, type ItemRect } from './selectionSlideMath.ts';
/** A phone's bottom bar: four equal tabs across 412dp. */
const EQUAL_TABS = new Map<string, ItemRect>([
['index', { offset: 0, extent: 103 }],
['library', { offset: 103, extent: 103 }],
['eq', { offset: 206, extent: 103 }],
['settings', { offset: 309, extent: 103 }],
]);
test('places the mark on the selected item', () => {
assert.deepEqual(resolveMark(EQUAL_TABS, 'eq'), { offset: 206, extent: 103 });
});
test('hides the mark when nothing is selected, rather than parking it on the first item', () => {
// The regression this module exists for: `Math.max(0, findIndex(...))` used to
// turn a -1 into slot 0, lighting Home while Home was not selected.
assert.equal(resolveMark(EQUAL_TABS, null), null);
});
test('hides the mark for a key that is not among the items', () => {
assert.equal(resolveMark(EQUAL_TABS, 'stats'), null);
});
test('hides the mark until the selected item has reported a layout', () => {
const partial = new Map<string, ItemRect>([['index', { offset: 0, extent: 103 }]]);
assert.equal(resolveMark(partial, 'settings'), null);
assert.equal(resolveMark(new Map(), 'index'), null);
});
test('rejects a zero-extent layout pass as no measurement at all', () => {
// A container reports its children at zero before it has been sized. Believing
// it collapses the mark's box, and since the bar is centred in that box the
// mark flashes half a tab to the left and slides back — the cold-boot flash.
assert.equal(isMeasured({ offset: 0, extent: 103 }), true);
assert.equal(isMeasured({ offset: 0, extent: 0 }), false);
const unsized = new Map<string, ItemRect>([['index', { offset: 0, extent: 0 }]]);
assert.equal(resolveMark(unsized, 'index'), null);
});
test('follows unequal item extents instead of dividing the container evenly', () => {
// A split card while the dock is opening: items are mid-resize and the leading
// inset is the card's own padding, so container/count would miss every slot.
const splitCard = new Map<string, ItemRect>([
['index', { offset: 8, extent: 64 }],
['library', { offset: 72, extent: 88 }],
['eq', { offset: 160, extent: 64 }],
['settings', { offset: 224, extent: 72 }],
]);
assert.deepEqual(resolveMark(splitCard, 'library'), { offset: 72, extent: 88 });
assert.deepEqual(resolveMark(splitCard, 'settings'), { offset: 224, extent: 72 });
});
test('follows a rail column the same way it follows a row', () => {
// Same contract on the other axis: offset/extent are y/height in a rail.
const rail = new Map<string, ItemRect>([
['index', { offset: 0, extent: 52 }],
['library', { offset: 52, extent: 52 }],
]);
assert.deepEqual(resolveMark(rail, 'library'), { offset: 52, extent: 52 });
});
+58
View File
@@ -0,0 +1,58 @@
/**
* Where a sliding selection mark belongs, given what the items actually
* measured.
*
* This exists because the previous mark was positioned by index arithmetic —
* `Math.max(0, findIndex(focused))` slots wide, each `containerWidth / count`
* across. Both halves of that are assumptions: the `Math.max` turns "nothing is
* selected" into "the first thing is selected", and the even division assumes
* every item is the same size and the container has no padding. The nav bar has
* three shapes now — a phone's equal-flex row, a rail's fixed-height column, and
* a split card whose item widths change when the player dock opens — so the
* division is wrong in two of them, and the fallback slot was wrong in all
* three. That combination is what "highlight the wrong thing" was.
*
* A mark placed from the measured rect of the item it points at cannot disagree
* with that item, whatever shape the shell is in. When there is nothing to point
* at this returns null, and the caller hides the mark rather than parking it
* somewhere.
*/
/** One item's box along the mark's travel axis: x/width, or y/height in a rail. */
export interface ItemRect {
offset: number;
extent: number;
}
export type MarkPlacement = ItemRect;
/**
* Whether a layout pass said anything worth recording.
*
* A container reports its children at zero extent before it has been sized —
* on the first pass after a cold start, and occasionally again mid-flight. That
* is not a position, it is the absence of one. Believing it collapses the mark's
* box, and because the bar is *centred* in that box a collapse slides the
* visible mark half a tab to the left and then back as the real width lands.
* That flash was the whole symptom.
*/
export function isMeasured(rect: ItemRect): boolean {
return rect.extent > 0;
}
/**
* The selected item's rect, or null when the mark should not be shown at all.
*
* Null covers "nothing is selected" and "selected, but it hasn't honestly
* reported a layout yet" — an unmeasured item has no position, and guessing one
* is the bug this module replaces.
*/
export function resolveMark(
rects: ReadonlyMap<string, ItemRect>,
activeKey: string | null
): MarkPlacement | null {
if (activeKey === null) return null;
const rect = rects.get(activeKey);
if (!rect || !isMeasured(rect)) return null;
return rect;
}