mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-16 00:01:59 +02:00
play/shuffle button on tracks screen
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user