mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 13:09:46 +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 { Ionicons } from '@expo/vector-icons';
|
||||||
import { type FlashListRef } from '@shopify/flash-list';
|
import { type FlashListRef } from '@shopify/flash-list';
|
||||||
import { useFocusEffect, useRouter } from 'expo-router';
|
import { useFocusEffect, useRouter } from 'expo-router';
|
||||||
import {
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
ReduceMotion,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
useAnimatedScrollHandler,
|
useAnimatedScrollHandler,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -27,6 +29,7 @@ import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
|||||||
import {
|
import {
|
||||||
ScreenHeader,
|
ScreenHeader,
|
||||||
ScreenHeaderAction,
|
ScreenHeaderAction,
|
||||||
|
ScreenHeaderExpandedAction,
|
||||||
useScreenHeader,
|
useScreenHeader,
|
||||||
} from '@/components/ScreenHeader';
|
} from '@/components/ScreenHeader';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
@@ -43,7 +46,11 @@ import {
|
|||||||
type FolderActionTarget,
|
type FolderActionTarget,
|
||||||
} from '@/components/library/FoldersView';
|
} from '@/components/library/FoldersView';
|
||||||
import { LibraryContextBar } from '@/components/library/LibraryContextBar';
|
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 { MiniPlayerScrim } from '@/components/MiniPlayerScrim';
|
||||||
import {
|
import {
|
||||||
PlaylistOverlays,
|
PlaylistOverlays,
|
||||||
@@ -159,6 +166,11 @@ const CHROME_CONTROLS_H = 32;
|
|||||||
const CHROME_SCAN_H = 34;
|
const CHROME_SCAN_H = 34;
|
||||||
const CHROME_ERROR_H = 34;
|
const CHROME_ERROR_H = 34;
|
||||||
const CHROME_GAP = spacing.sm;
|
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: {
|
function libraryChromeHeight(options: {
|
||||||
scanning: boolean;
|
scanning: boolean;
|
||||||
@@ -276,12 +288,19 @@ export default function LibraryScreen() {
|
|||||||
|
|
||||||
const sortable =
|
const sortable =
|
||||||
viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
|
viewMode === 'tracks' || viewMode === 'albums' || viewMode === 'artists';
|
||||||
|
const showTrackPlaybackActions =
|
||||||
|
viewMode === 'tracks' && totalTrackCount > 0 && !selectMode;
|
||||||
|
const showHeaderSearch =
|
||||||
|
!phoneContextBar && showScreenTitle && !showLibraryStatus;
|
||||||
const header = useScreenHeader({
|
const header = useScreenHeader({
|
||||||
// In rail mode the rail names the destination, so the header contributes no
|
// 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.
|
// title and no bar — only the chrome the lists still have to clear.
|
||||||
hasTitle: showScreenTitle,
|
hasTitle: showScreenTitle,
|
||||||
hasBack: false,
|
hasBack: false,
|
||||||
actionCount: !phoneContextBar && showScreenTitle && !showLibraryStatus ? 1 : 0,
|
actionCount:
|
||||||
|
(showScreenTitle && showTrackPlaybackActions ? 2 : 0) +
|
||||||
|
(showHeaderSearch ? 1 : 0),
|
||||||
|
hasExpandedActions: showScreenTitle && showTrackPlaybackActions,
|
||||||
chromeHeight: showLibraryStatus || phoneContextBar
|
chromeHeight: showLibraryStatus || phoneContextBar
|
||||||
? 0
|
? 0
|
||||||
: libraryChromeHeight({
|
: libraryChromeHeight({
|
||||||
@@ -324,6 +343,16 @@ export default function LibraryScreen() {
|
|||||||
source: { kind: 'library', label: 'Library' },
|
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();
|
const openSearch = () => openQuickSearch();
|
||||||
|
|
||||||
// Sits at the end of the loaded window whenever more pages exist, so a fling that
|
// 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>
|
</AppPressable>
|
||||||
</View>
|
</View>
|
||||||
) : sortable ? (
|
) : 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"
|
<AppPressable feedback="control"
|
||||||
style={styles.sortTrigger}
|
style={styles.sortTrigger}
|
||||||
onPress={() => setSortSheetOpen(true)}
|
onPress={() => setSortSheetOpen(true)}
|
||||||
@@ -1024,8 +1082,49 @@ export default function LibraryScreen() {
|
|||||||
header={header}
|
header={header}
|
||||||
title="Library"
|
title="Library"
|
||||||
chrome={chrome}
|
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={
|
actions={
|
||||||
!phoneContextBar && showScreenTitle && !showLibraryStatus ? (
|
showHeaderSearch ? (
|
||||||
<ScreenHeaderAction onPress={() => openQuickSearch()} accessibilityLabel="Search library">
|
<ScreenHeaderAction onPress={() => openQuickSearch()} accessibilityLabel="Search library">
|
||||||
<Ionicons name="search" size={22} color={colors.textSecondary} />
|
<Ionicons name="search" size={22} color={colors.textSecondary} />
|
||||||
</ScreenHeaderAction>
|
</ScreenHeaderAction>
|
||||||
@@ -1233,6 +1332,24 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
gap: spacing.md,
|
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: {
|
sortTrigger: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
+180
-10
@@ -1,5 +1,11 @@
|
|||||||
/* eslint-disable react-hooks/immutability -- Reanimated shared values are the header's scroll state. */
|
/* 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 {
|
import {
|
||||||
PixelRatio,
|
PixelRatio,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -8,7 +14,9 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedScrollHandler,
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedReaction,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
runOnJS,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
type ScrollHandlerProcessed,
|
type ScrollHandlerProcessed,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
@@ -18,11 +26,16 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { useShellRailPresent } from '@/navigation/shellRailContext';
|
import { useShellRailPresent } from '@/navigation/shellRailContext';
|
||||||
import { useShellLayout } from '@/navigation/useShellLayout';
|
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 { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
|
import { AppPressable, SCROLL_PRESS_DELAY } from '@/components/AppPressable';
|
||||||
import {
|
import {
|
||||||
SCREEN_HEADER_ACTION_SIZE,
|
SCREEN_HEADER_ACTION_SIZE,
|
||||||
|
SCREEN_HEADER_EXPANDED_ACTION_SIZE,
|
||||||
|
compactActionsOpacityAt,
|
||||||
|
compactActionsScaleAt,
|
||||||
|
expandedActionsLiftAt,
|
||||||
|
expandedActionsOpacityAt,
|
||||||
barOpacityAt,
|
barOpacityAt,
|
||||||
getScreenHeaderLayout,
|
getScreenHeaderLayout,
|
||||||
headerHeightAt,
|
headerHeightAt,
|
||||||
@@ -45,11 +58,9 @@ import {
|
|||||||
*
|
*
|
||||||
* - **The list's top padding comes from `contentPaddingTop`, and nowhere else.**
|
* - **The list's top padding comes from `contentPaddingTop`, and nowhere else.**
|
||||||
* No consumer re-derives it, and nothing here measures it back.
|
* No consumer re-derives it, and nothing here measures it back.
|
||||||
* - **There is no React state and no effect.** `CollapsingDetail` needs latched
|
* - **React only hears about action handoff thresholds.** Motion stays on the
|
||||||
* booleans only to hand pointer events between big and small buttons that swap
|
* UI thread; two latched booleans move pointer events from an optional large
|
||||||
* places; here the chevron and the actions are pinned in the bar row from the
|
* expanded action row to its compact bar icons.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface ScreenHeaderController {
|
export interface ScreenHeaderController {
|
||||||
@@ -73,12 +84,15 @@ export function useScreenHeader({
|
|||||||
hasSubtitle = false,
|
hasSubtitle = false,
|
||||||
hasBack = true,
|
hasBack = true,
|
||||||
actionCount = 0,
|
actionCount = 0,
|
||||||
|
hasExpandedActions = false,
|
||||||
hasTitle = true,
|
hasTitle = true,
|
||||||
chromeHeight = 0,
|
chromeHeight = 0,
|
||||||
}: {
|
}: {
|
||||||
hasSubtitle?: boolean;
|
hasSubtitle?: boolean;
|
||||||
hasBack?: boolean;
|
hasBack?: boolean;
|
||||||
actionCount?: number;
|
actionCount?: number;
|
||||||
|
/** Whether the expanded title reserves a standard labelled-action row. */
|
||||||
|
hasExpandedActions?: boolean;
|
||||||
/** False where the destination is already named elsewhere (Library + rail). */
|
/** False where the destination is already named elsewhere (Library + rail). */
|
||||||
hasTitle?: boolean;
|
hasTitle?: boolean;
|
||||||
/** Declared height of the pinned controls passed as `chrome`. */
|
/** Declared height of the pinned controls passed as `chrome`. */
|
||||||
@@ -105,6 +119,7 @@ export function useScreenHeader({
|
|||||||
hasSubtitle,
|
hasSubtitle,
|
||||||
hasBack,
|
hasBack,
|
||||||
actionCount,
|
actionCount,
|
||||||
|
hasExpandedActions,
|
||||||
hasTitle,
|
hasTitle,
|
||||||
chromeHeight,
|
chromeHeight,
|
||||||
});
|
});
|
||||||
@@ -134,6 +149,8 @@ export function ScreenHeader({
|
|||||||
backLabel,
|
backLabel,
|
||||||
onBack,
|
onBack,
|
||||||
actions,
|
actions,
|
||||||
|
collapsedActions,
|
||||||
|
expandedActions,
|
||||||
chrome,
|
chrome,
|
||||||
}: {
|
}: {
|
||||||
header: ScreenHeaderController;
|
header: ScreenHeaderController;
|
||||||
@@ -142,7 +159,12 @@ export function ScreenHeader({
|
|||||||
/** Where back goes, shown beside the chevron until the title claims the row. */
|
/** Where back goes, shown beside the chevron until the title claims the row. */
|
||||||
backLabel?: string;
|
backLabel?: string;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
|
/** Actions that stay in the bar in both expanded and collapsed states. */
|
||||||
actions?: ReactNode;
|
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
|
* 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
|
* 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
|
// `getScreenHeaderLayout` is pure, so `layout` is a fresh identity every
|
||||||
// render, and Reanimated compares what the worklet captured.
|
// render, and Reanimated compares what the worklet captured.
|
||||||
const { dist, settle, travelX, travelY, titleScale, maxHeight, minHeight } = layout;
|
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(() => ({
|
const collapseClipStyle = useAnimatedStyle(() => ({
|
||||||
transform: [
|
transform: [
|
||||||
@@ -187,6 +245,26 @@ export function ScreenHeader({
|
|||||||
const barStyle = useAnimatedStyle(() => ({
|
const barStyle = useAnimatedStyle(() => ({
|
||||||
opacity: barOpacityAt(scrollY.value, settle),
|
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:
|
// The title sizes itself rather than leaning on `Text`'s automatic scaling:
|
||||||
// Android applies `maxFontSizeMultiplier` to fontSize but *not* to lineHeight
|
// Android applies `maxFontSizeMultiplier` to fontSize but *not* to lineHeight
|
||||||
@@ -223,7 +301,7 @@ export function ScreenHeader({
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
const actionCluster =
|
const actionCluster =
|
||||||
actions != null ? (
|
actions != null || collapsedActions != null ? (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.actions,
|
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}
|
{actions}
|
||||||
</View>
|
</View>
|
||||||
) : null;
|
) : 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
|
// 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 —
|
// it settles directly under the bar. Its height is declared, not measured —
|
||||||
// the caller owes `chromeHeight` and an element that is exactly that tall.
|
// the caller owes `chromeHeight` and an element that is exactly that tall.
|
||||||
@@ -310,7 +420,7 @@ export function ScreenHeader({
|
|||||||
{
|
{
|
||||||
top: insets.top + layout.titleTop,
|
top: insets.top + layout.titleTop,
|
||||||
left: layout.titleLeft,
|
left: layout.titleLeft,
|
||||||
right: layout.titleLeft,
|
right: layout.titleRight,
|
||||||
height: layout.titleLine,
|
height: layout.titleLine,
|
||||||
},
|
},
|
||||||
titleStyle,
|
titleStyle,
|
||||||
@@ -359,6 +469,8 @@ export function ScreenHeader({
|
|||||||
</Animated.Text>
|
</Animated.Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{expandedActionBlock}
|
||||||
|
|
||||||
{chevron}
|
{chevron}
|
||||||
{actionCluster}
|
{actionCluster}
|
||||||
</Animated.View>
|
</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,
|
* 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
|
* 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) => ({
|
const useStyles = createThemedStyles((colors) => ({
|
||||||
container: {
|
container: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -470,6 +617,29 @@ const useStyles = createThemedStyles((colors) => ({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
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: {
|
chrome: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
SCREEN_BAR_H,
|
SCREEN_BAR_H,
|
||||||
|
SCREEN_HEADER_EXPANDED_ACTION_SIZE,
|
||||||
SCREEN_HEADER_GUTTER,
|
SCREEN_HEADER_GUTTER,
|
||||||
|
compactActionsOpacityAt,
|
||||||
|
expandedActionsOpacityAt,
|
||||||
getScreenHeaderLayout,
|
getScreenHeaderLayout,
|
||||||
headerHeightAt,
|
headerHeightAt,
|
||||||
labelOpacityAt,
|
labelOpacityAt,
|
||||||
@@ -21,7 +24,7 @@ const WINDOWS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const FONT_SCALES = [0.85, 1, 1.15, 1.2, 1.3, 2] 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. */
|
/** How much of the width `Screen` can take away: nothing, a cutout, a docked player. */
|
||||||
const WIDTH_LOSSES = [0, 24, 48] as const;
|
const WIDTH_LOSSES = [0, 24, 48] as const;
|
||||||
/** No pinned chrome, and Library's switcher + sort row. */
|
/** No pinned chrome, and Library's switcher + sort row. */
|
||||||
@@ -43,19 +46,22 @@ function cases(): Case[] {
|
|||||||
for (const actionCount of ACTION_COUNTS) {
|
for (const actionCount of ACTION_COUNTS) {
|
||||||
for (const loss of WIDTH_LOSSES) {
|
for (const loss of WIDTH_LOSSES) {
|
||||||
for (const chromeHeight of CHROME_HEIGHTS) {
|
for (const chromeHeight of CHROME_HEIGHTS) {
|
||||||
out.push({
|
for (const hasExpandedActions of [false, true]) {
|
||||||
label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} -${loss}w chrome:${chromeHeight}`,
|
out.push({
|
||||||
input: {
|
label: `${entry.name} ${orientation} ${width}x${height} fs${fontScale} sub:${hasSubtitle} back:${hasBack} actions:${actionCount} expanded:${hasExpandedActions} -${loss}w chrome:${chromeHeight}`,
|
||||||
availableWidth: width - loss,
|
input: {
|
||||||
windowHeight: height,
|
availableWidth: width - loss,
|
||||||
topInset,
|
windowHeight: height,
|
||||||
fontScale,
|
topInset,
|
||||||
hasSubtitle,
|
fontScale,
|
||||||
hasBack,
|
hasSubtitle,
|
||||||
actionCount,
|
hasBack,
|
||||||
chromeHeight,
|
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', () => {
|
test('a larger font setting only ever grows the header, and the cap binds', () => {
|
||||||
for (const entry of WINDOWS) {
|
for (const entry of WINDOWS) {
|
||||||
for (const dims of [entry.portrait, entry.landscape]) {
|
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_SIZE = SCREEN_HEADER_ACTION_SIZE;
|
||||||
const ACTION_GAP = spacing.xs;
|
const ACTION_GAP = spacing.xs;
|
||||||
const ACTION_RIGHT = spacing.md;
|
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.
|
* Floor on how far the header travels.
|
||||||
@@ -102,6 +106,10 @@ const LABEL_FADE_SHARE = 0.4;
|
|||||||
const LABEL_FADE_MAX = 45;
|
const LABEL_FADE_MAX = 45;
|
||||||
const SUBTITLE_FADE_SHARE = 0.5;
|
const SUBTITLE_FADE_SHARE = 0.5;
|
||||||
const SUBTITLE_LIFT = -12;
|
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 {
|
export interface ScreenHeaderInput {
|
||||||
/** Width inside `Screen`'s insets — not the window width. */
|
/** Width inside `Screen`'s insets — not the window width. */
|
||||||
@@ -112,6 +120,8 @@ export interface ScreenHeaderInput {
|
|||||||
hasSubtitle: boolean;
|
hasSubtitle: boolean;
|
||||||
hasBack: boolean;
|
hasBack: boolean;
|
||||||
actionCount: number;
|
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
|
* 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
|
* 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. */
|
/** Top of the large title, below the inset. */
|
||||||
titleTop: number;
|
titleTop: number;
|
||||||
titleLeft: 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. */
|
/** Top of the subtitle line, below the inset. */
|
||||||
subtitleTop: number;
|
subtitleTop: number;
|
||||||
/** Collapsed size as a share of the expanded size. Font-scale independent, so
|
/** Collapsed size as a share of the expanded size. Font-scale independent, so
|
||||||
@@ -203,6 +219,7 @@ export function getScreenHeaderLayout({
|
|||||||
hasSubtitle,
|
hasSubtitle,
|
||||||
hasBack,
|
hasBack,
|
||||||
actionCount,
|
actionCount,
|
||||||
|
hasExpandedActions = false,
|
||||||
hasTitle = true,
|
hasTitle = true,
|
||||||
chromeHeight = 0,
|
chromeHeight = 0,
|
||||||
}: ScreenHeaderInput): ScreenHeaderLayout {
|
}: ScreenHeaderInput): ScreenHeaderLayout {
|
||||||
@@ -210,11 +227,16 @@ export function getScreenHeaderLayout({
|
|||||||
const titleLine = Math.round(variantLineHeight.title * scale);
|
const titleLine = Math.round(variantLineHeight.title * scale);
|
||||||
const subLine = Math.round(variantLineHeight.label * scale);
|
const subLine = Math.round(variantLineHeight.label * scale);
|
||||||
|
|
||||||
const contentBlock =
|
const titleBlockBottom =
|
||||||
TITLE_TOP +
|
TITLE_TOP + titleLine + (hasSubtitle ? TITLE_TO_SUB + subLine : 0);
|
||||||
titleLine +
|
const expandedActionsHeight = hasExpandedActions
|
||||||
(hasSubtitle ? TITLE_TO_SUB + subLine : 0) +
|
? SCREEN_HEADER_EXPANDED_ACTION_SIZE
|
||||||
BLOCK_BOTTOM_PAD;
|
: 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);
|
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
|
// 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 titleLeft = SCREEN_HEADER_GUTTER;
|
||||||
const travelX = hasBack ? BAR_TEXT_LEFT - titleLeft : 0;
|
const travelX = hasBack ? BAR_TEXT_LEFT - titleLeft : 0;
|
||||||
const titleScale = BAR_TITLE_SIZE / fontSize.xxl;
|
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 {
|
return {
|
||||||
collapsible,
|
collapsible,
|
||||||
@@ -253,6 +295,10 @@ export function getScreenHeaderLayout({
|
|||||||
subLine,
|
subLine,
|
||||||
titleTop: TITLE_TOP,
|
titleTop: TITLE_TOP,
|
||||||
titleLeft,
|
titleLeft,
|
||||||
|
titleRight,
|
||||||
|
expandedActionsTop,
|
||||||
|
expandedActionsHeight,
|
||||||
|
expandedActionsWidth,
|
||||||
subtitleTop: TITLE_TOP + titleLine + TITLE_TO_SUB,
|
subtitleTop: TITLE_TOP + titleLine + TITLE_TO_SUB,
|
||||||
titleScale,
|
titleScale,
|
||||||
travelX,
|
travelX,
|
||||||
@@ -341,6 +387,46 @@ export function subtitleLiftAt(y: number, settle: number): number {
|
|||||||
return lerpClamped(y, 0, settle * SUBTITLE_FADE_SHARE, 0, SUBTITLE_LIFT);
|
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. */
|
/** Arrives last: the surface only earns its tint once content is behind it. */
|
||||||
export function barOpacityAt(y: number, settle: number): number {
|
export function barOpacityAt(y: number, settle: number): number {
|
||||||
'worklet';
|
'worklet';
|
||||||
|
|||||||
Reference in New Issue
Block a user