mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
bring back anim task bar
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user