smoother animation

This commit is contained in:
Boof2015
2026-08-03 22:49:55 -04:00
parent d385b39c8c
commit 8a45ae4431
18 changed files with 351 additions and 160 deletions
+2 -2
View File
@@ -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}
+2 -2
View File
@@ -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}
+70 -19
View File
@@ -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}
+2 -2
View File
@@ -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}
+2 -3
View File
@@ -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}
+3 -3
View File
@@ -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}
+2 -2
View File
@@ -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}
+3 -3
View File
@@ -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}
+13
View File
@@ -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
View File
@@ -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,
+101 -27
View File
@@ -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 -5
View File
@@ -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}
+5 -8
View File
@@ -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)}
+16 -4
View File
@@ -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}