mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-28 17:11:19 +02:00
smoother animation
This commit is contained in:
@@ -2,10 +2,10 @@ import { useMemo, useState } from 'react';
|
||||
import { Image } from 'expo-image';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { TrackRow } from '@/components/library/TrackRow';
|
||||
@@ -106,7 +106,7 @@ export default function AlbumScreen() {
|
||||
|
||||
return (
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={albumItems}
|
||||
keyExtractor={(item) => (item.kind === 'disc' ? `disc-${item.disc}` : String(item.track.id))}
|
||||
getItemType={(item) => item.kind}
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
import { Image } from 'expo-image';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { TrackRow } from '@/components/library/TrackRow';
|
||||
@@ -309,7 +309,7 @@ export default function ArtistScreen() {
|
||||
|
||||
return (
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={listItems}
|
||||
keyExtractor={(item) => item.key}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ScreenHeader, useScreenHeader } from '@/components/ScreenHeader';
|
||||
import { Text } from '@/components/Text';
|
||||
@@ -31,7 +31,7 @@ export default function ArtistAlbumsScreen() {
|
||||
// The header is an overlay the grid scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the grid itself.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={page.items}
|
||||
numColumns={2}
|
||||
keyExtractor={(album) => album.identity_key}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ScreenHeader, useScreenHeader } from '@/components/ScreenHeader';
|
||||
import { Text } from '@/components/Text';
|
||||
@@ -56,7 +56,7 @@ export default function ArtistAppearancesScreen() {
|
||||
// The header is an overlay the list scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the list itself.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={tracks}
|
||||
keyExtractor={(track) => String(track.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ScreenHeader, useScreenHeader } from '@/components/ScreenHeader';
|
||||
import { Text } from '@/components/Text';
|
||||
@@ -56,7 +56,7 @@ export default function ArtistSongsScreen() {
|
||||
// The header is an overlay the list scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the list itself.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={tracks}
|
||||
keyExtractor={(track) => String(track.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/immutability -- Reanimated shared values gate the active UI-thread list. */
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -12,15 +13,20 @@ import {
|
||||
View,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
useWindowDimensions,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList, type FlashListRef } from '@shopify/flash-list';
|
||||
import { type FlashListRef } from '@shopify/flash-list';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import Animated, { FadeIn, ReduceMotion } from 'react-native-reanimated';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
ReduceMotion,
|
||||
runOnJS,
|
||||
useAnimatedScrollHandler,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import {
|
||||
ScreenHeader,
|
||||
ScreenHeaderAction,
|
||||
@@ -219,7 +225,11 @@ export default function LibraryScreen() {
|
||||
// The gate object is rebuilt whenever `atTop` flips; these two members are
|
||||
// stable, so depending on them keeps the re-tap subscription from churning on
|
||||
// every scroll.
|
||||
const { offsetRef: scrollOffsetRef, setScrollAtTop } = scrollTop;
|
||||
const {
|
||||
offsetRef: scrollOffsetRef,
|
||||
onScrollOffset: syncScrollOffset,
|
||||
setScrollAtTop,
|
||||
} = scrollTop;
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
|
||||
// One ref per view mode so a tab re-tap can send whichever list is on screen
|
||||
@@ -536,8 +546,11 @@ export default function LibraryScreen() {
|
||||
const listMountIdentity = `${surfaceHeadIdentity}:${sectionJumpRevision}`;
|
||||
const activeListMountIdentity = useRef(listMountIdentity);
|
||||
const settledHeadListIdentity = useRef<string | null>(null);
|
||||
const activeListMountIdentityUi = useSharedValue(listMountIdentity);
|
||||
const settledHeadListIdentityUi = useSharedValue<string | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
activeListMountIdentity.current = listMountIdentity;
|
||||
activeListMountIdentityUi.value = listMountIdentity;
|
||||
// FlashList can report its item-0 correction before `onLoad`. Keep that
|
||||
// native bookkeeping event away from the header until the list is moved to
|
||||
// its true scroll origin below. Positive A-Z anchors and the two child-list
|
||||
@@ -546,7 +559,14 @@ export default function LibraryScreen() {
|
||||
jumpAnchorIndex > 0 || viewMode === 'playlists' || viewMode === 'folders'
|
||||
? listMountIdentity
|
||||
: null;
|
||||
}, [jumpAnchorIndex, listMountIdentity, viewMode]);
|
||||
settledHeadListIdentityUi.value = settledHeadListIdentity.current;
|
||||
}, [
|
||||
activeListMountIdentityUi,
|
||||
jumpAnchorIndex,
|
||||
listMountIdentity,
|
||||
settledHeadListIdentityUi,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (jumpAnchorIndex > 0) return;
|
||||
@@ -583,20 +603,51 @@ export default function LibraryScreen() {
|
||||
: trackListRef.current;
|
||||
list?.scrollToOffset({ offset: 0, animated: false });
|
||||
settledHeadListIdentity.current = listMountIdentity;
|
||||
settledHeadListIdentityUi.value = listMountIdentity;
|
||||
setScrollAtTop(true);
|
||||
resetHeader();
|
||||
}, [listMountIdentity, resetHeader, setScrollAtTop]);
|
||||
}, [
|
||||
listMountIdentity,
|
||||
resetHeader,
|
||||
setScrollAtTop,
|
||||
settledHeadListIdentityUi,
|
||||
]);
|
||||
|
||||
// Two independent consumers of the same scroll: the pull-to-search gate and
|
||||
// the collapsing header. Neither owns the list, so the screen fans out.
|
||||
const onListScroll = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
// Native scroll delivery can trail an unmount. A section, sort, layout, or
|
||||
// A-Z remount must not inherit one last offset from the surface it replaced.
|
||||
const syncScrollTopGate = useCallback((y: number) => {
|
||||
// A queued runOnJS call can also trail an unmount, so retain the JS-side
|
||||
// identity guard even though the animation is independently gated below.
|
||||
if (activeListMountIdentity.current !== listMountIdentity) return;
|
||||
if (settledHeadListIdentity.current !== listMountIdentity) return;
|
||||
scrollTop.onScroll(event);
|
||||
header.onScroll(event);
|
||||
};
|
||||
syncScrollOffset(y);
|
||||
}, [listMountIdentity, syncScrollOffset]);
|
||||
|
||||
// The header follows the list entirely on the UI thread. JS only hears when
|
||||
// the pull-to-search top gate changes and when a drag/momentum run settles,
|
||||
// keeping the imperative tab-reselect offset current without a bridge call on
|
||||
// every frame.
|
||||
const headerScrollY = header.scrollY;
|
||||
const onListScroll = useAnimatedScrollHandler<{ atTop?: boolean }>({
|
||||
onScroll: (event, context) => {
|
||||
// Native scroll delivery can trail an unmount. A section, sort, layout,
|
||||
// or A-Z remount must not inherit an offset from the surface it replaced.
|
||||
if (activeListMountIdentityUi.value !== listMountIdentity) return;
|
||||
if (settledHeadListIdentityUi.value !== listMountIdentity) return;
|
||||
|
||||
const y = event.contentOffset.y;
|
||||
headerScrollY.value = y;
|
||||
const atTop = y <= 2;
|
||||
if (context.atTop !== atTop) {
|
||||
context.atTop = atTop;
|
||||
runOnJS(syncScrollTopGate)(y);
|
||||
}
|
||||
},
|
||||
onEndDrag: (event) => {
|
||||
runOnJS(syncScrollTopGate)(event.contentOffset.y);
|
||||
},
|
||||
onMomentumEnd: (event) => {
|
||||
runOnJS(syncScrollTopGate)(event.contentOffset.y);
|
||||
},
|
||||
});
|
||||
|
||||
const inlineStatus = phoneContextBar && (isScanning || scanError) ? (
|
||||
<View style={styles.inlineStatus}>
|
||||
@@ -723,7 +774,7 @@ export default function LibraryScreen() {
|
||||
}
|
||||
>
|
||||
{viewMode === 'albums' ? (
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
ref={albumListRef}
|
||||
key={`albums-${albumSort}-${albumLayout}-${albumColumns}-${sectionJumpRevision}`}
|
||||
data={sortedAlbums}
|
||||
@@ -777,7 +828,7 @@ export default function LibraryScreen() {
|
||||
) : null}
|
||||
|
||||
{viewMode === 'artists' ? (
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
ref={artistListRef}
|
||||
key={`artists-${artistSort}-${artistLayout}-${artistColumns}-${sectionJumpRevision}`}
|
||||
data={sortedArtists}
|
||||
@@ -831,7 +882,7 @@ export default function LibraryScreen() {
|
||||
) : null}
|
||||
|
||||
{viewMode === 'tracks' ? (
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
ref={trackListRef}
|
||||
key={`tracks-${trackSort}-${sectionJumpRevision}`}
|
||||
data={sortedTracks}
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Text } from '@/components/Text';
|
||||
import { TrackRow } from '@/components/library/TrackRow';
|
||||
import { TrackActionsSheet, type TrackActionSheetItem } from '@/components/library/TrackActionsSheet';
|
||||
@@ -281,7 +281,7 @@ export default function PlaylistScreen() {
|
||||
|
||||
return (
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={entries}
|
||||
keyExtractor={(entry) => String(entry.id)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
AppState,
|
||||
Pressable,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native';
|
||||
@@ -307,7 +306,7 @@ export default function ListeningStatsScreen() {
|
||||
// The header is an overlay the content scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the ScrollView.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={header.onScroll}
|
||||
scrollEventThrottle={header.scrollEventThrottle}
|
||||
@@ -441,7 +440,7 @@ export default function ListeningStatsScreen() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<ScreenHeader
|
||||
header={header}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
@@ -208,7 +208,7 @@ export default function LastFmScreen() {
|
||||
// The header is an overlay the content scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the ScrollView.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={header.onScroll}
|
||||
scrollEventThrottle={header.scrollEventThrottle}
|
||||
@@ -281,7 +281,7 @@ export default function LastFmScreen() {
|
||||
A scrobble is sent once a track plays past half its length (or 4 minutes). Tracks under 30
|
||||
seconds are skipped. Failed scrobbles queue offline and retry automatically.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<ScreenHeader
|
||||
header={header}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
View
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ScreenHeader, useScreenHeader } from '@/components/ScreenHeader';
|
||||
import { Text } from '@/components/Text';
|
||||
@@ -53,7 +53,7 @@ export default function RecentlyPlayedScreen() {
|
||||
// The header is an overlay the list scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the list itself.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
data={tracks}
|
||||
keyExtractor={(track) => track.path}
|
||||
showsVerticalScrollIndicator={false}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View
|
||||
} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
@@ -112,7 +112,7 @@ export default function SourcesScreen() {
|
||||
// The header is an overlay the content scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the ScrollView.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={header.onScroll}
|
||||
scrollEventThrottle={header.scrollEventThrottle}
|
||||
@@ -174,7 +174,7 @@ export default function SourcesScreen() {
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<ScreenHeader
|
||||
header={header}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import Animated from 'react-native-reanimated';
|
||||
|
||||
/**
|
||||
* FlashList wrapped by Reanimated so worklet scroll handlers stay on the UI
|
||||
* thread instead of crossing through React Native's JavaScript event path.
|
||||
*
|
||||
* Preserve FlashList's generic call signature: createAnimatedComponent loses
|
||||
* it even though the runtime component forwards the same props and ref.
|
||||
*/
|
||||
export const ReanimatedFlashList = Animated.createAnimatedComponent(
|
||||
FlashList
|
||||
) as typeof FlashList;
|
||||
+117
-71
@@ -6,12 +6,12 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import Animated, {
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type ScrollHandlerProcessed,
|
||||
type SharedValue,
|
||||
} from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -50,12 +50,12 @@ import {
|
||||
* 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 a JS-thread scroll handler drives it.
|
||||
* subtree from re-rendering while the UI-thread scroll handler drives it.
|
||||
*/
|
||||
|
||||
export interface ScreenHeaderController {
|
||||
scrollY: SharedValue<number>;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onScroll: ScrollHandlerProcessed;
|
||||
scrollEventThrottle: 16;
|
||||
/** What the scroll surface owes as `contentContainerStyle.paddingTop`. */
|
||||
contentPaddingTop: number;
|
||||
@@ -109,9 +109,11 @@ export function useScreenHeader({
|
||||
hasTitle,
|
||||
chromeHeight,
|
||||
});
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollY.value = event.nativeEvent.contentOffset.y;
|
||||
}, [scrollY]);
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
const resetScroll = useCallback(() => {
|
||||
scrollY.value = 0;
|
||||
}, [scrollY]);
|
||||
@@ -160,8 +162,15 @@ export function ScreenHeader({
|
||||
// render, and Reanimated compares what the worklet captured.
|
||||
const { dist, settle, travelX, travelY, titleScale, maxHeight, minHeight } = layout;
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
height: headerHeightAt(scrollY.value, dist, maxHeight, minHeight),
|
||||
const collapseClipStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateY: headerHeightAt(scrollY.value, dist, maxHeight, minHeight) - maxHeight },
|
||||
],
|
||||
}));
|
||||
const fixedContentStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateY: maxHeight - headerHeightAt(scrollY.value, dist, maxHeight, minHeight) },
|
||||
],
|
||||
}));
|
||||
const titleStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
@@ -244,7 +253,10 @@ export function ScreenHeader({
|
||||
// chrome). Neither animates, so neither has to degrade gracefully at dist 0.
|
||||
if (!layout.collapsible) {
|
||||
return (
|
||||
<View style={[styles.container, { height: minHeight }]} pointerEvents="box-none">
|
||||
<View
|
||||
style={[styles.container, styles.clipped, { height: minHeight }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View style={[styles.backdrop, styles.bar, { height: minHeight }]} pointerEvents="none" />
|
||||
{layout.hasTitle ? (
|
||||
<>
|
||||
@@ -274,73 +286,90 @@ export function ScreenHeader({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, containerStyle]} pointerEvents="box-none">
|
||||
{/* Fixed-height opaque field, clipped by the shrinking container. Without
|
||||
it, rows scroll up *through* the large title. It is `bgPrimary`, the
|
||||
same colour `Screen` paints, so the container's bottom edge is
|
||||
invisible against the page — and because that edge and row 1's top are
|
||||
the same number, rows emerge exactly at it with no seam. */}
|
||||
<View style={[styles.backdrop, { height: maxHeight }]} pointerEvents="none" />
|
||||
<View style={[styles.container, { height: maxHeight }]} pointerEvents="box-none">
|
||||
{/* The fixed-size clip translates upward so its bottom edge follows the
|
||||
collapsed height. Its ordinary header contents counter-translate and
|
||||
therefore remain visually fixed while being revealed by that edge.
|
||||
This preserves the old geometry without per-frame layout commits. */}
|
||||
<Animated.View
|
||||
style={[styles.backdrop, styles.bar, { height: minHeight }, barStyle]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.titleWrap,
|
||||
{
|
||||
top: insets.top + layout.titleTop,
|
||||
left: layout.titleLeft,
|
||||
right: layout.titleLeft,
|
||||
height: layout.titleLine,
|
||||
},
|
||||
titleStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
style={[styles.collapseClip, collapseClipStyle]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<Text variant="title" numberOfLines={1} allowFontScaling={false} style={titleFont}>
|
||||
{title}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{subtitle ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.subtitle,
|
||||
{ top: insets.top + layout.subtitleTop, left: layout.titleLeft, right: layout.titleLeft },
|
||||
subtitleStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
style={[styles.fixedContent, fixedContentStyle]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<Text variant="label" numberOfLines={1} allowFontScaling={false}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
{/* Rows emerge at the translated clip's bottom edge with no seam. */}
|
||||
<View style={[styles.backdrop, { height: maxHeight }]} pointerEvents="none" />
|
||||
<Animated.View
|
||||
style={[styles.backdrop, styles.bar, { height: minHeight }, barStyle]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.titleWrap,
|
||||
{
|
||||
top: insets.top + layout.titleTop,
|
||||
left: layout.titleLeft,
|
||||
right: layout.titleLeft,
|
||||
height: layout.titleLine,
|
||||
},
|
||||
titleStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text variant="title" numberOfLines={1} allowFontScaling={false} style={titleFont}>
|
||||
{title}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{subtitle ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.subtitle,
|
||||
{
|
||||
top: insets.top + layout.subtitleTop,
|
||||
left: layout.titleLeft,
|
||||
right: layout.titleLeft,
|
||||
},
|
||||
subtitleStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text variant="label" numberOfLines={1} allowFontScaling={false}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
{backLabel ? (
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
top: layout.barCenterY - variantLineHeight.label / 2,
|
||||
left: layout.barTextLeft,
|
||||
right: layout.labelRight,
|
||||
},
|
||||
labelStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{backLabel}
|
||||
</Animated.Text>
|
||||
) : null}
|
||||
|
||||
{chevron}
|
||||
{actionCluster}
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
{backLabel ? (
|
||||
<Animated.Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
top: layout.barCenterY - variantLineHeight.label / 2,
|
||||
left: layout.barTextLeft,
|
||||
right: layout.labelRight,
|
||||
},
|
||||
labelStyle,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{backLabel}
|
||||
</Animated.Text>
|
||||
) : null}
|
||||
|
||||
{chevron}
|
||||
{actionCluster}
|
||||
{chromeBlock}
|
||||
</Animated.View>
|
||||
{/* Chrome stays attached to the moving bottom edge and settles directly
|
||||
below the app bar, so it intentionally is not counter-translated. */}
|
||||
{chromeBlock}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,8 +412,25 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
clipped: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
collapseClip: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fixedContent: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
backdrop: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable react-hooks/immutability -- Reanimated shared values are the header's UI-thread scroll state. */
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -10,8 +12,6 @@ import {
|
||||
View,
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -19,6 +19,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
type SharedValue
|
||||
@@ -43,10 +45,10 @@ import {
|
||||
getDetailHeroLayout,
|
||||
} from '@/components/library/detailHeroLayout';
|
||||
|
||||
// Collapsing detail header. An absolute container whose height shrinks with the
|
||||
// scroll and clips its faded content, so the track list (padded to the expanded
|
||||
// height) rises to meet it — no mid-scroll dead space. The artwork is a single
|
||||
// element that shrinks/tucks into the top-left corner as the header collapses.
|
||||
// Collapsing detail header. A fixed-size clipping layer translates upward with
|
||||
// the scroll while its contents counter-translate, producing a rising bottom
|
||||
// edge without animating layout. The artwork is a single element that
|
||||
// shrinks/tucks into the top-left corner as the header collapses.
|
||||
//
|
||||
// Hero geometry — artwork size/position, where the title block goes, and the
|
||||
// ceiling that keeps the list reachable — lives in ./detailHeroLayout so it can
|
||||
@@ -81,11 +83,25 @@ export function useDetailCollapse() {
|
||||
);
|
||||
const ref = useRef({ heroFaded: false, collapsed: false });
|
||||
const [state, setState] = useState({ heroFaded: false, collapsed: false });
|
||||
const collapseDistance = useSharedValue(expandedHeight - BAR_H);
|
||||
const collapseFlags = useSharedValue(0);
|
||||
|
||||
const expandedRef = useRef(expandedHeight);
|
||||
const updateCollapseState = useCallback((heroFaded: boolean, collapsed: boolean) => {
|
||||
if (heroFaded === ref.current.heroFaded && collapsed === ref.current.collapsed) return;
|
||||
ref.current = { heroFaded, collapsed };
|
||||
setState({ heroFaded, collapsed });
|
||||
}, []);
|
||||
|
||||
// Measurements and rotations are rare JS-side events. Keep the worklet's
|
||||
// threshold current without routing any per-frame scroll values through JS.
|
||||
useEffect(() => {
|
||||
expandedRef.current = expandedHeight;
|
||||
}, [expandedHeight]);
|
||||
const dist = expandedHeight - BAR_H;
|
||||
collapseDistance.value = dist;
|
||||
const y = scrollY.value;
|
||||
const flags = (y >= 60 ? 1 : 0) | (y >= dist - 40 ? 2 : 0);
|
||||
collapseFlags.value = flags;
|
||||
updateCollapseState((flags & 1) !== 0, (flags & 2) !== 0);
|
||||
}, [collapseDistance, collapseFlags, expandedHeight, scrollY, updateCollapseState]);
|
||||
|
||||
const onHeroBlockLayout = (e: LayoutChangeEvent) => {
|
||||
const next = getDetailExpandedHeight(hero, e.nativeEvent.layout.height);
|
||||
@@ -96,17 +112,21 @@ export function useDetailCollapse() {
|
||||
);
|
||||
};
|
||||
|
||||
const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const y = e.nativeEvent.contentOffset.y;
|
||||
scrollY.value = y;
|
||||
const dist = expandedRef.current - BAR_H;
|
||||
const heroFaded = y >= 60;
|
||||
const collapsed = y >= dist - 40;
|
||||
if (heroFaded !== ref.current.heroFaded || collapsed !== ref.current.collapsed) {
|
||||
ref.current = { heroFaded, collapsed };
|
||||
setState({ heroFaded, collapsed });
|
||||
}
|
||||
};
|
||||
const onScroll = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
const y = event.contentOffset.y;
|
||||
const dist = collapseDistance.value;
|
||||
const flags = (y >= 60 ? 1 : 0) | (y >= dist - 40 ? 2 : 0);
|
||||
|
||||
// This is the animation's source of truth and now stays on the UI thread.
|
||||
// React only hears about the two pointer-event handoff thresholds.
|
||||
scrollY.value = y;
|
||||
if (flags !== collapseFlags.value) {
|
||||
collapseFlags.value = flags;
|
||||
runOnJS(updateCollapseState)((flags & 1) !== 0, (flags & 2) !== 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
scrollY,
|
||||
@@ -201,8 +221,47 @@ export function CollapsingHeader({
|
||||
const tyTarget = barCenterY - (artExpandedTop + hero.artSize / 2);
|
||||
const scaleTarget = ART_COLLAPSED / hero.artSize;
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
height: interpolate(scrollY.value, [0, dist], [maxH, minH], Extrapolation.CLAMP),
|
||||
// Moving a fixed-size clipping layer produces the same visible bottom edge as
|
||||
// shrinking `height`, while keeping every per-frame update to transform and
|
||||
// opacity. Its contents counter-translate so the header stays anchored as the
|
||||
// clip edge rises with the list.
|
||||
const collapseClipStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: -interpolate(
|
||||
scrollY.value,
|
||||
[0, dist],
|
||||
[0, dist],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
// Reanimated animated-style objects are view-specific; keep separate inverse
|
||||
// styles for the wash and foreground even though they share the same math.
|
||||
const washFixedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollY.value,
|
||||
[0, dist],
|
||||
[0, dist],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
const headerFixedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
scrollY.value,
|
||||
[0, dist],
|
||||
[0, dist],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
const artStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
@@ -243,18 +302,29 @@ export function CollapsingHeader({
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, containerStyle]} pointerEvents="box-none">
|
||||
{/* Blurred wash (fixed tall, clipped by the shrinking container) + fade at the bottom edge. */}
|
||||
<View style={[styles.wash, { height: maxH }]} pointerEvents="none">
|
||||
<View style={[styles.container, { height: maxH }]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, styles.collapseClip, collapseClipStyle]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
{/* The wash counter-translates to stay fixed while the clip's bottom edge rises. */}
|
||||
<Animated.View
|
||||
style={[styles.wash, { height: maxH }, washFixedStyle]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{backdropUri ? (
|
||||
<Image source={{ uri: backdropUri }} style={StyleSheet.absoluteFill} contentFit="cover" blurRadius={40} transition={null} />
|
||||
) : (
|
||||
<View style={[StyleSheet.absoluteFill, styles.washFallback]} />
|
||||
)}
|
||||
<View style={styles.scrim} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
<BottomFade />
|
||||
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, headerFixedStyle]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<Animated.View style={[styles.barBg, { height: minH }, barBgStyle]} pointerEvents="none">
|
||||
{backdropUri ? (
|
||||
<>
|
||||
@@ -390,7 +460,9 @@ export function CollapsingHeader({
|
||||
>
|
||||
{artwork}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -400,6 +472,8 @@ const useStyles = createThemedStyles((colors) => ({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
collapseClip: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
wash: {
|
||||
|
||||
@@ -4,16 +4,15 @@ import {
|
||||
StyleSheet,
|
||||
View,
|
||||
type GestureResponderEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import type { ScrollHandlerProcessed } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import {
|
||||
AstraLibraryData,
|
||||
type NativeFolderNode,
|
||||
} from '../../../modules/astra-library-scanner';
|
||||
import { Text } from '@/components/Text';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
|
||||
import {
|
||||
AppSheet,
|
||||
@@ -38,7 +37,7 @@ import type { ScrollToTopHandle } from '@/navigation/scrollToTopHandle';
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface FoldersViewProps {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onScroll?: ScrollHandlerProcessed;
|
||||
scrollEventThrottle?: number;
|
||||
/** What the list owes so it clears Library's collapsing header. */
|
||||
contentPaddingTop?: number;
|
||||
@@ -365,7 +364,7 @@ export function FoldersView({
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
ref={listRef}
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.id}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
View,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent
|
||||
} from 'react-native';
|
||||
import { View } from 'react-native';
|
||||
import type { ScrollHandlerProcessed } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ReanimatedFlashList } from '@/components/ReanimatedFlashList';
|
||||
import { Text } from '@/components/Text';
|
||||
import {
|
||||
AppSheet,
|
||||
@@ -47,7 +44,7 @@ export function PlaylistsView({
|
||||
onSheetOpenChange,
|
||||
listRef,
|
||||
}: {
|
||||
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
onScroll?: ScrollHandlerProcessed;
|
||||
scrollEventThrottle?: number;
|
||||
/** What the list owes so it clears Library's collapsing header. */
|
||||
contentPaddingTop?: number;
|
||||
@@ -199,7 +196,7 @@ export function PlaylistsView({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<FlashList
|
||||
<ReanimatedFlashList
|
||||
ref={listRef}
|
||||
data={playlists}
|
||||
keyExtractor={(playlist) => String(playlist.id)}
|
||||
|
||||
@@ -109,16 +109,28 @@ export function useScrollTopGate(initialAtTop = true) {
|
||||
setAtTop(next);
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { y } = event.nativeEvent.contentOffset;
|
||||
const onScrollOffset = useCallback(
|
||||
(y: number) => {
|
||||
offsetRef.current = y;
|
||||
setScrollAtTop(y <= 2);
|
||||
},
|
||||
[setScrollAtTop]
|
||||
);
|
||||
const onScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
onScrollOffset(event.nativeEvent.contentOffset.y);
|
||||
},
|
||||
[onScrollOffset]
|
||||
);
|
||||
|
||||
return { atTop, offsetRef, onScroll, scrollEventThrottle: 16 as const, setScrollAtTop };
|
||||
return {
|
||||
atTop,
|
||||
offsetRef,
|
||||
onScroll,
|
||||
onScrollOffset,
|
||||
scrollEventThrottle: 16 as const,
|
||||
setScrollAtTop,
|
||||
};
|
||||
}
|
||||
|
||||
export function PullSearchGesture({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
View,
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from 'react-native';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
@@ -34,14 +34,14 @@ export function SettingsSectionScreen({
|
||||
// The header is an overlay the content scrolls under, so the screen keeps
|
||||
// neither the top inset nor the gutter — both move into the ScrollView.
|
||||
<Screen padded={false} style={styles.screen}>
|
||||
<ScrollView
|
||||
<Animated.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={header.onScroll}
|
||||
scrollEventThrottle={header.scrollEventThrottle}
|
||||
contentContainerStyle={[styles.content, { paddingTop: header.contentPaddingTop }]}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.ScrollView>
|
||||
|
||||
<ScreenHeader
|
||||
header={header}
|
||||
|
||||
Reference in New Issue
Block a user