play/shuffle button on tracks screen

This commit is contained in:
Boof2015
2026-08-12 15:55:52 -04:00
parent bdf81fe408
commit 9738cbc1c7
4 changed files with 444 additions and 35 deletions
+122 -5
View File
@@ -17,7 +17,9 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { type FlashListRef } from '@shopify/flash-list';
import { useFocusEffect, useRouter } from 'expo-router';
import {
import Animated, {
FadeIn,
ReduceMotion,
runOnJS,
useAnimatedScrollHandler,
useSharedValue,
@@ -27,6 +29,7 @@ import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
import {
ScreenHeader,
ScreenHeaderAction,
ScreenHeaderExpandedAction,
useScreenHeader,
} from '@/components/ScreenHeader';
import { Text } from '@/components/Text';
@@ -43,7 +46,11 @@ import {
type FolderActionTarget,
} from '@/components/library/FoldersView';
import { LibraryContextBar } from '@/components/library/LibraryContextBar';
import { LibrarySurfaceTransition } from '@/components/library/LibrarySurfaceTransition';
import {
LIBRARY_SURFACE_ENTER_MS,
LIBRARY_SURFACE_EXIT_MS,
LibrarySurfaceTransition,
} from '@/components/library/LibrarySurfaceTransition';
import { MiniPlayerScrim } from '@/components/MiniPlayerScrim';
import {
PlaylistOverlays,
@@ -159,6 +166,11 @@ const CHROME_CONTROLS_H = 32;
const CHROME_SCAN_H = 34;
const CHROME_ERROR_H = 34;
const CHROME_GAP = spacing.sm;
const TRACK_ACTIONS_ENTER_LAG_MS = 40;
const TRACK_ACTIONS_ENTERING = FadeIn
.delay(LIBRARY_SURFACE_EXIT_MS + TRACK_ACTIONS_ENTER_LAG_MS)
.duration(LIBRARY_SURFACE_ENTER_MS)
.reduceMotion(ReduceMotion.System);
function libraryChromeHeight(options: {
scanning: boolean;
@@ -276,12 +288,19 @@ export default function LibraryScreen() {
const sortable =
viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
const showTrackPlaybackActions =
viewMode === 'tracks' && totalTrackCount > 0 && !selectMode;
const showHeaderSearch =
!phoneContextBar && showScreenTitle && !showLibraryStatus;
const header = useScreenHeader({
// In rail mode the rail names the destination, so the header contributes no
// title and no bar — only the chrome the lists still have to clear.
hasTitle: showScreenTitle,
hasBack: false,
actionCount: !phoneContextBar && showScreenTitle && !showLibraryStatus ? 1 : 0,
actionCount:
(showScreenTitle && showTrackPlaybackActions ? 2 : 0) +
(showHeaderSearch ? 1 : 0),
hasExpandedActions: showScreenTitle && showTrackPlaybackActions,
chromeHeight: showLibraryStatus || phoneContextBar
? 0
: libraryChromeHeight({
@@ -324,6 +343,16 @@ export default function LibraryScreen() {
source: { kind: 'library', label: 'Library' },
});
};
const playAllTracks = (shuffle: boolean) => {
void playLibraryQuery({
kind: 'library',
sort: trackSort,
direction: trackSortDirection,
}, {
shuffle,
source: { kind: 'library', label: 'Library' },
});
};
const openSearch = () => openQuickSearch();
// Sits at the end of the loaded window whenever more pages exist, so a fling that
@@ -761,7 +790,36 @@ export default function LibraryScreen() {
</AppPressable>
</View>
) : sortable ? (
<View style={[styles.controlsRow, { height: CHROME_CONTROLS_H }]}>
<View
style={[
styles.controlsRow,
!showScreenTitle && showTrackPlaybackActions && styles.controlsRowSplit,
{ height: CHROME_CONTROLS_H },
]}
>
{!showScreenTitle && showTrackPlaybackActions ? (
<Animated.View
entering={TRACK_ACTIONS_ENTERING}
style={styles.trackPlaybackActions}
>
<AppPressable feedback="control"
style={styles.trackPlaybackAction}
onPress={() => playAllTracks(false)}
accessibilityRole="button"
accessibilityLabel="Play all tracks"
>
<Ionicons name="play" size={20} color={colors.accent} />
</AppPressable>
<AppPressable feedback="control"
style={styles.trackPlaybackAction}
onPress={() => playAllTracks(true)}
accessibilityRole="button"
accessibilityLabel="Shuffle all tracks"
>
<Ionicons name="shuffle" size={20} color={colors.accent} />
</AppPressable>
</Animated.View>
) : null}
<AppPressable feedback="control"
style={styles.sortTrigger}
onPress={() => setSortSheetOpen(true)}
@@ -1024,8 +1082,49 @@ export default function LibraryScreen() {
header={header}
title="Library"
chrome={chrome}
expandedActions={
showScreenTitle && showTrackPlaybackActions ? (
<Animated.View
entering={TRACK_ACTIONS_ENTERING}
style={[styles.trackPlaybackActions, styles.expandedTrackPlaybackActions]}
>
<ScreenHeaderExpandedAction
variant="primary"
icon="play"
onPress={() => playAllTracks(false)}
accessibilityLabel="Play all tracks"
/>
<ScreenHeaderExpandedAction
icon="shuffle"
onPress={() => playAllTracks(true)}
accessibilityLabel="Shuffle all tracks"
/>
</Animated.View>
) : undefined
}
collapsedActions={
showScreenTitle && showTrackPlaybackActions ? (
<Animated.View
entering={TRACK_ACTIONS_ENTERING}
style={styles.trackPlaybackActions}
>
<ScreenHeaderAction
onPress={() => playAllTracks(false)}
accessibilityLabel="Play all tracks"
>
<Ionicons name="play" size={22} color={colors.accent} />
</ScreenHeaderAction>
<ScreenHeaderAction
onPress={() => playAllTracks(true)}
accessibilityLabel="Shuffle all tracks"
>
<Ionicons name="shuffle" size={22} color={colors.accent} />
</ScreenHeaderAction>
</Animated.View>
) : undefined
}
actions={
!phoneContextBar && showScreenTitle && !showLibraryStatus ? (
showHeaderSearch ? (
<ScreenHeaderAction onPress={() => openQuickSearch()} accessibilityLabel="Search library">
<Ionicons name="search" size={22} color={colors.textSecondary} />
</ScreenHeaderAction>
@@ -1233,6 +1332,24 @@ const styles = StyleSheet.create({
justifyContent: 'flex-end',
gap: spacing.md,
},
controlsRowSplit: {
justifyContent: 'space-between',
},
trackPlaybackActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
expandedTrackPlaybackActions: {
width: '100%',
height: '100%',
},
trackPlaybackAction: {
width: CHROME_CONTROLS_H,
height: CHROME_CONTROLS_H,
alignItems: 'center',
justifyContent: 'center',
},
sortTrigger: {
flexDirection: 'row',
alignItems: 'center',
+180 -10
View File
@@ -1,5 +1,11 @@
/* eslint-disable react-hooks/immutability -- Reanimated shared values are the header's scroll state. */
import { useCallback, type ReactNode } from 'react';
import {
useCallback,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import {
PixelRatio,
StyleSheet,
@@ -8,7 +14,9 @@ import {
} from 'react-native';
import Animated, {
useAnimatedScrollHandler,
useAnimatedReaction,
useAnimatedStyle,
runOnJS,
useSharedValue,
type ScrollHandlerProcessed,
type SharedValue,
@@ -18,11 +26,16 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { useShellRailPresent } from '@/navigation/shellRailContext';
import { useShellLayout } from '@/navigation/useShellLayout';
import { MAX_FONT_SCALE, fontSize, variantLineHeight } from '@/theme';
import { MAX_FONT_SCALE, fontSize, radius, variantLineHeight } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
import {
SCREEN_HEADER_ACTION_SIZE,
SCREEN_HEADER_EXPANDED_ACTION_SIZE,
compactActionsOpacityAt,
compactActionsScaleAt,
expandedActionsLiftAt,
expandedActionsOpacityAt,
barOpacityAt,
getScreenHeaderLayout,
headerHeightAt,
@@ -45,11 +58,9 @@ import {
*
* - **The list's top padding comes from `contentPaddingTop`, and nowhere else.**
* No consumer re-derives it, and nothing here measures it back.
* - **There is no React state and no effect.** `CollapsingDetail` needs latched
* booleans only to hand pointer events between big and small buttons that swap
* places; here the chevron and the actions are pinned in the bar row from the
* start and never move, so there is nothing to hand over. That also keeps the
* subtree from re-rendering while the UI-thread scroll handler drives it.
* - **React only hears about action handoff thresholds.** Motion stays on the
* UI thread; two latched booleans move pointer events from an optional large
* expanded action row to its compact bar icons.
*/
export interface ScreenHeaderController {
@@ -73,12 +84,15 @@ export function useScreenHeader({
hasSubtitle = false,
hasBack = true,
actionCount = 0,
hasExpandedActions = false,
hasTitle = true,
chromeHeight = 0,
}: {
hasSubtitle?: boolean;
hasBack?: boolean;
actionCount?: number;
/** Whether the expanded title reserves a standard labelled-action row. */
hasExpandedActions?: boolean;
/** False where the destination is already named elsewhere (Library + rail). */
hasTitle?: boolean;
/** Declared height of the pinned controls passed as `chrome`. */
@@ -105,6 +119,7 @@ export function useScreenHeader({
hasSubtitle,
hasBack,
actionCount,
hasExpandedActions,
hasTitle,
chromeHeight,
});
@@ -134,6 +149,8 @@ export function ScreenHeader({
backLabel,
onBack,
actions,
collapsedActions,
expandedActions,
chrome,
}: {
header: ScreenHeaderController;
@@ -142,7 +159,12 @@ export function ScreenHeader({
/** Where back goes, shown beside the chevron until the title claims the row. */
backLabel?: string;
onBack?: () => void;
/** Actions that stay in the bar in both expanded and collapsed states. */
actions?: ReactNode;
/** Compact counterparts to `expandedActions`, visible only after collapse. */
collapsedActions?: ReactNode;
/** Large labelled actions shown below the title while fully expanded. */
expandedActions?: ReactNode;
/**
* Pinned controls below the title. Anchored to the container's bottom edge, so
* they ride up as the title collapses and then stay put. Must be exactly
@@ -159,6 +181,42 @@ export function ScreenHeader({
// `getScreenHeaderLayout` is pure, so `layout` is a fresh identity every
// render, and Reanimated compares what the worklet captured.
const { dist, settle, travelX, travelY, titleScale, maxHeight, minHeight } = layout;
const handoffEnabled =
layout.collapsible && expandedActions != null && collapsedActions != null;
const interactionRef = useRef({ expanded: true, compact: false });
const [interaction, setInteraction] = useState({ expanded: true, compact: false });
const updateInteraction = useCallback((expanded: boolean, compact: boolean) => {
if (
interactionRef.current.expanded === expanded &&
interactionRef.current.compact === compact
) return;
interactionRef.current = { expanded, compact };
setInteraction(interactionRef.current);
}, []);
useEffect(() => {
if (!handoffEnabled) return;
const y = scrollY.value;
updateInteraction(
expandedActionsOpacityAt(y, settle) > 0,
compactActionsOpacityAt(y, settle) >= 0.5,
);
}, [handoffEnabled, scrollY, settle, updateInteraction]);
useAnimatedReaction(
() => {
if (!handoffEnabled) return 0;
const y = scrollY.value;
return (
(expandedActionsOpacityAt(y, settle) > 0 ? 1 : 0) |
(compactActionsOpacityAt(y, settle) >= 0.5 ? 2 : 0)
);
},
(flags, previous) => {
if (flags === previous) return;
runOnJS(updateInteraction)((flags & 1) !== 0, (flags & 2) !== 0);
},
);
const collapseClipStyle = useAnimatedStyle(() => ({
transform: [
@@ -187,6 +245,26 @@ export function ScreenHeader({
const barStyle = useAnimatedStyle(() => ({
opacity: barOpacityAt(scrollY.value, settle),
}));
const expandedActionsStyle = useAnimatedStyle(() => ({
opacity: layout.collapsible
? expandedActionsOpacityAt(scrollY.value, settle)
: 0,
transform: [{
translateY: layout.collapsible
? expandedActionsLiftAt(scrollY.value, settle)
: 0,
}],
}));
const compactActionsStyle = useAnimatedStyle(() => ({
opacity: layout.collapsible
? compactActionsOpacityAt(scrollY.value, settle)
: 1,
transform: [{
scale: layout.collapsible
? compactActionsScaleAt(scrollY.value, settle)
: 1,
}],
}));
// The title sizes itself rather than leaning on `Text`'s automatic scaling:
// Android applies `maxFontSizeMultiplier` to fontSize but *not* to lineHeight
@@ -223,7 +301,7 @@ export function ScreenHeader({
) : null;
const actionCluster =
actions != null ? (
actions != null || collapsedActions != null ? (
<View
style={[
styles.actions,
@@ -234,10 +312,42 @@ export function ScreenHeader({
},
]}
>
{collapsedActions != null ? (
<Animated.View
style={[styles.collapsedActions, { gap: layout.actionGap }, compactActionsStyle]}
pointerEvents={
handoffEnabled
? interaction.compact ? 'auto' : 'none'
: 'auto'
}
>
{collapsedActions}
</Animated.View>
) : null}
{actions}
</View>
) : null;
const expandedActionBlock =
expandedActions != null && layout.collapsible ? (
<Animated.View
style={[
styles.expandedActions,
{
top: insets.top + layout.expandedActionsTop,
right: layout.actionsRight,
width: layout.expandedActionsWidth,
height: layout.expandedActionsHeight,
gap: layout.actionGap,
},
expandedActionsStyle,
]}
pointerEvents={interaction.expanded ? 'auto' : 'none'}
>
{expandedActions}
</Animated.View>
) : null;
// Anchored to the container's bottom edge, so the collapse carries it up and
// it settles directly under the bar. Its height is declared, not measured —
// the caller owes `chromeHeight` and an element that is exactly that tall.
@@ -310,7 +420,7 @@ export function ScreenHeader({
{
top: insets.top + layout.titleTop,
left: layout.titleLeft,
right: layout.titleLeft,
right: layout.titleRight,
height: layout.titleLine,
},
titleStyle,
@@ -359,6 +469,8 @@ export function ScreenHeader({
</Animated.Text>
) : null}
{expandedActionBlock}
{chevron}
{actionCluster}
</Animated.View>
@@ -372,7 +484,7 @@ export function ScreenHeader({
}
/**
* A button in the collapsed bar's action row.
* A compact button in the collapsed bar's action row.
*
* Sized from the same constant the layout proves the title cannot collide with,
* so a screen can add actions without re-declaring the number that keeps them
@@ -403,6 +515,41 @@ export function ScreenHeaderAction({
);
}
export function ScreenHeaderExpandedAction({
onPress,
accessibilityLabel,
icon,
variant = 'secondary',
}: {
onPress: () => void;
accessibilityLabel: string;
icon: keyof typeof Ionicons.glyphMap;
variant?: 'primary' | 'secondary';
}) {
const styles = useStyles();
const colors = useColors();
const primary = variant === 'primary';
return (
<AppPressable
feedback={primary ? 'accent' : 'control'}
unstable_pressDelay={SCROLL_PRESS_DELAY}
style={[
styles.expandedAction,
primary ? styles.expandedActionPrimary : styles.expandedActionSecondary,
]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
>
<Ionicons
name={icon}
size={18}
color={primary ? colors.bgPrimary : colors.accent}
/>
</AppPressable>
);
}
const useStyles = createThemedStyles((colors) => ({
container: {
position: 'absolute',
@@ -470,6 +617,29 @@ const useStyles = createThemedStyles((colors) => ({
flexDirection: 'row',
alignItems: 'center',
},
collapsedActions: {
flexDirection: 'row',
alignItems: 'center',
},
expandedActions: {
position: 'absolute',
flexDirection: 'row',
},
expandedAction: {
width: SCREEN_HEADER_EXPANDED_ACTION_SIZE,
height: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.pill,
},
expandedActionPrimary: {
backgroundColor: colors.accent,
},
expandedActionSecondary: {
backgroundColor: colors.glassBg,
borderColor: colors.accent,
borderWidth: StyleSheet.hairlineWidth,
},
chrome: {
position: 'absolute',
left: 0,
+50 -14
View File
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
SCREEN_BAR_H,
SCREEN_HEADER_EXPANDED_ACTION_SIZE,
SCREEN_HEADER_GUTTER,
compactActionsOpacityAt,
expandedActionsOpacityAt,
getScreenHeaderLayout,
headerHeightAt,
labelOpacityAt,
@@ -21,7 +24,7 @@ const WINDOWS = [
] as const;
const FONT_SCALES = [0.85, 1, 1.15, 1.2, 1.3, 2] as const;
const ACTION_COUNTS = [0, 1, 2] as const;
const ACTION_COUNTS = [0, 1, 2, 3] as const;
/** How much of the width `Screen` can take away: nothing, a cutout, a docked player. */
const WIDTH_LOSSES = [0, 24, 48] as const;
/** No pinned chrome, and Library's switcher + sort row. */
@@ -43,19 +46,22 @@ function cases(): Case[] {
for (const actionCount of ACTION_COUNTS) {
for (const loss of WIDTH_LOSSES) {
for (const chromeHeight of CHROME_HEIGHTS) {
out.push({
label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} -${loss}w chrome:${chromeHeight}`,
input: {
availableWidth: width - loss,
windowHeight: height,
topInset,
fontScale,
hasSubtitle,
hasBack,
actionCount,
chromeHeight,
},
});
for (const hasExpandedActions of [false, true]) {
out.push({
label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} expanded:${hasExpandedActions} -${loss}w chrome:${chromeHeight}`,
input: {
availableWidth: width - loss,
windowHeight: height,
topInset,
fontScale,
hasSubtitle,
hasBack,
actionCount,
hasExpandedActions,
chromeHeight,
},
});
}
}
}
}
@@ -181,6 +187,36 @@ test('the collapse has room to read as motion', () => {
}
});
test('expanded actions sit beside the title without adding height and hand off cleanly', () => {
const input = {
availableWidth: 360,
windowHeight: 780,
topInset: 40,
fontScale: 1,
hasSubtitle: false,
hasBack: false,
actionCount: 2,
};
const bare = getScreenHeaderLayout(input);
const layout = getScreenHeaderLayout({ ...input, hasExpandedActions: true });
assert.equal(layout.expandedHeight, bare.expandedHeight);
assert.equal(layout.expandedActionsHeight, SCREEN_HEADER_EXPANDED_ACTION_SIZE);
assert.ok(layout.expandedActionsTop >= layout.titleTop);
assert.ok(layout.expandedActionsTop + layout.expandedActionsHeight <= layout.titleTop + layout.titleLine);
assert.ok(layout.titleRight > bare.titleRight);
assert.equal(expandedActionsOpacityAt(0, layout.settle), 1);
assert.equal(compactActionsOpacityAt(0, layout.settle), 0);
assert.equal(expandedActionsOpacityAt(layout.settle, layout.settle), 0);
assert.equal(compactActionsOpacityAt(layout.settle, layout.settle), 1);
for (let y = 0; y <= layout.settle; y += 1) {
assert.ok(
expandedActionsOpacityAt(y, layout.settle) === 0 ||
compactActionsOpacityAt(y, layout.settle) === 0,
`expanded and compact actions overlap at ${y}`,
);
}
});
test('a larger font setting only ever grows the header, and the cap binds', () => {
for (const entry of WINDOWS) {
for (const dims of [entry.portrait, entry.landscape]) {
+92 -6
View File
@@ -50,6 +50,10 @@ export const SCREEN_HEADER_ACTION_SIZE = 40;
const ACTION_SIZE = SCREEN_HEADER_ACTION_SIZE;
const ACTION_GAP = spacing.xs;
const ACTION_RIGHT = spacing.md;
/** Compact controls seated beside the expanded title before moving into the
* bar. Kept fixed so layout and rendering share one declaration. */
export const SCREEN_HEADER_EXPANDED_ACTION_SIZE = 40;
const EXPANDED_ACTIONS_GAP = spacing.xs;
/**
* Floor on how far the header travels.
@@ -102,6 +106,10 @@ const LABEL_FADE_SHARE = 0.4;
const LABEL_FADE_MAX = 45;
const SUBTITLE_FADE_SHARE = 0.5;
const SUBTITLE_LIFT = -12;
const EXPANDED_ACTIONS_FADE_SHARE = 0.48;
const EXPANDED_ACTIONS_LIFT = -14;
const COMPACT_ACTIONS_FADE_START_SHARE = 0.72;
const COMPACT_ACTIONS_FADE_END_SHARE = 0.94;
export interface ScreenHeaderInput {
/** Width inside `Screen`'s insets — not the window width. */
@@ -112,6 +120,8 @@ export interface ScreenHeaderInput {
hasSubtitle: boolean;
hasBack: boolean;
actionCount: number;
/** Seats a standard pair of compact actions beside the expanded title. */
hasExpandedActions?: boolean;
/**
* False on a screen whose destination is already named elsewhere — Library in
* rail mode, where the rail carries the word. The header then contributes no
@@ -161,6 +171,12 @@ export interface ScreenHeaderLayout {
/** Top of the large title, below the inset. */
titleTop: number;
titleLeft: number;
/** Trailing inset that keeps the travelling title clear of pinned actions. */
titleRight: number;
/** Expanded-only action pair beside the title. */
expandedActionsTop: number;
expandedActionsHeight: number;
expandedActionsWidth: number;
/** Top of the subtitle line, below the inset. */
subtitleTop: number;
/** Collapsed size as a share of the expanded size. Font-scale independent, so
@@ -203,6 +219,7 @@ export function getScreenHeaderLayout({
hasSubtitle,
hasBack,
actionCount,
hasExpandedActions = false,
hasTitle = true,
chromeHeight = 0,
}: ScreenHeaderInput): ScreenHeaderLayout {
@@ -210,11 +227,16 @@ export function getScreenHeaderLayout({
const titleLine = Math.round(variantLineHeight.title * scale);
const subLine = Math.round(variantLineHeight.label * scale);
const contentBlock =
TITLE_TOP +
titleLine +
(hasSubtitle ? TITLE_TO_SUB + subLine : 0) +
BLOCK_BOTTOM_PAD;
const titleBlockBottom =
TITLE_TOP + titleLine + (hasSubtitle ? TITLE_TO_SUB + subLine : 0);
const expandedActionsHeight = hasExpandedActions
? SCREEN_HEADER_EXPANDED_ACTION_SIZE
: 0;
const expandedActionsTop = TITLE_TOP + (titleLine - expandedActionsHeight) / 2;
const expandedActionsWidth = hasExpandedActions
? SCREEN_HEADER_EXPANDED_ACTION_SIZE * 2 + EXPANDED_ACTIONS_GAP
: 0;
const contentBlock = titleBlockBottom + BLOCK_BOTTOM_PAD;
const natural = Math.max(contentBlock, SCREEN_BAR_H + MIN_COLLAPSE_DISTANCE);
// The chrome is part of what the list must clear, so it counts against the
@@ -236,7 +258,27 @@ export function getScreenHeaderLayout({
const titleLeft = SCREEN_HEADER_GUTTER;
const travelX = hasBack ? BAR_TEXT_LEFT - titleLeft : 0;
const titleScale = BAR_TITLE_SIZE / fontSize.xxl;
const titleWidth = Math.max(0, availableWidth - titleLeft * 2);
const naturalTitleWidth = Math.max(0, availableWidth - titleLeft * 2);
// The same title box is scaled and translated into the bar. With three
// pinned actions, a narrow window can no longer give that box the full
// expanded width without its collapsed edge entering the action cluster.
// Bound the expanded box from the collapsed geometry so the rendered width
// and the invariant below stay the same number.
const collapsedTitleWidth = actionsWidth > 0
? Math.max(
0,
(availableWidth - actionsWidth - titleLeft - travelX) / titleScale,
)
: naturalTitleWidth;
const titleWidth = Math.min(naturalTitleWidth, collapsedTitleWidth);
const expandedTitleRight = hasExpandedActions
? ACTION_RIGHT + expandedActionsWidth + spacing.md
: titleLeft;
const titleRight = Math.max(
titleLeft,
availableWidth - titleLeft - titleWidth,
expandedTitleRight,
);
return {
collapsible,
@@ -253,6 +295,10 @@ export function getScreenHeaderLayout({
subLine,
titleTop: TITLE_TOP,
titleLeft,
titleRight,
expandedActionsTop,
expandedActionsHeight,
expandedActionsWidth,
subtitleTop: TITLE_TOP + titleLine + TITLE_TO_SUB,
titleScale,
travelX,
@@ -341,6 +387,46 @@ export function subtitleLiftAt(y: number, settle: number): number {
return lerpClamped(y, 0, settle * SUBTITLE_FADE_SHARE, 0, SUBTITLE_LIFT);
}
/** The deliberate expanded action row leaves early, before content reaches it. */
export function expandedActionsOpacityAt(y: number, settle: number): number {
'worklet';
return lerpClamped(y, 0, settle * EXPANDED_ACTIONS_FADE_SHARE, 1, 0);
}
export function expandedActionsLiftAt(y: number, settle: number): number {
'worklet';
return lerpClamped(
y,
0,
settle * EXPANDED_ACTIONS_FADE_SHARE,
0,
EXPANDED_ACTIONS_LIFT,
);
}
/** Compact bar icons arrive only after the expanded row has fully left. */
export function compactActionsOpacityAt(y: number, settle: number): number {
'worklet';
return lerpClamped(
y,
settle * COMPACT_ACTIONS_FADE_START_SHARE,
settle * COMPACT_ACTIONS_FADE_END_SHARE,
0,
1,
);
}
export function compactActionsScaleAt(y: number, settle: number): number {
'worklet';
return lerpClamped(
y,
settle * COMPACT_ACTIONS_FADE_START_SHARE,
settle * COMPACT_ACTIONS_FADE_END_SHARE,
0.78,
1,
);
}
/** Arrives last: the surface only earns its tint once content is behind it. */
export function barOpacityAt(y: number, settle: number): number {
'worklet';