diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx index ad6c048..2d4d81a 100644 --- a/src/app/(tabs)/library/album/[key].tsx +++ b/src/app/(tabs)/library/album/[key].tsx @@ -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 ( - (item.kind === 'disc' ? `disc-${item.disc}` : String(item.track.id))} getItemType={(item) => item.kind} diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 00b9dd0..57e1915 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -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 ( - item.key} showsVerticalScrollIndicator={false} diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 675082f..2a6f669 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -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. - album.identity_key} diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index 34fd2e4..27ae4f4 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -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. - String(track.id)} showsVerticalScrollIndicator={false} diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index b8b9d8a..655a35e 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -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. - String(track.id)} showsVerticalScrollIndicator={false} diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index cb19a94..87a8a08 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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(null); + const activeListMountIdentityUi = useSharedValue(listMountIdentity); + const settledHeadListIdentityUi = useSharedValue(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) => { - // 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) ? ( @@ -723,7 +774,7 @@ export default function LibraryScreen() { } > {viewMode === 'albums' ? ( - - String(entry.id)} showsVerticalScrollIndicator={false} diff --git a/src/app/(tabs)/stats.tsx b/src/app/(tabs)/stats.tsx index 9022658..4034b5c 100644 --- a/src/app/(tabs)/stats.tsx +++ b/src/app/(tabs)/stats.tsx @@ -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. - )} - + - - + - track.path} showsVerticalScrollIndicator={false} diff --git a/src/app/sources/index.tsx b/src/app/sources/index.tsx index 788d999..610aef2 100644 --- a/src/app/sources/index.tsx +++ b/src/app/sources/index.tsx @@ -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. - + ; - onScroll: (event: NativeSyntheticEvent) => 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) => { - 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 ( - + {layout.hasTitle ? ( <> @@ -274,73 +286,90 @@ export function ScreenHeader({ } return ( - - {/* 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. */} - + + {/* 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. */} - - - - {title} - - - - {subtitle ? ( - - {subtitle} - + {/* Rows emerge at the translated clip's bottom edge with no seam. */} + + + + + + {title} + + + + {subtitle ? ( + + + {subtitle} + + + ) : null} + + {backLabel ? ( + + {backLabel} + + ) : null} + + {chevron} + {actionCluster} - ) : null} - {backLabel ? ( - - {backLabel} - - ) : null} - - {chevron} - {actionCluster} - {chromeBlock} - + {/* Chrome stays attached to the moving bottom edge and settles directly + below the app bar, so it intentionally is not counter-translated. */} + {chromeBlock} + + ); } @@ -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, diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx index 93c2c55..2106341 100644 --- a/src/components/library/CollapsingDetail.tsx +++ b/src/components/library/CollapsingDetail.tsx @@ -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) => { - 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 ( - - {/* Blurred wash (fixed tall, clipped by the shrinking container) + fade at the bottom edge. */} - + + + {/* The wash counter-translates to stay fixed while the clip's bottom edge rises. */} + {backdropUri ? ( ) : ( )} - + + {backdropUri ? ( <> @@ -390,7 +460,9 @@ export function CollapsingHeader({ > {artwork} - + + + ); } @@ -400,6 +472,8 @@ const useStyles = createThemedStyles((colors) => ({ top: 0, left: 0, right: 0, + }, + collapseClip: { overflow: 'hidden', }, wash: { diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index e44f685..a338f78 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -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) => 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 ( <> - row.id} diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index 8ab7277..f9b1ea7 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -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) => 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 ( - String(playlist.id)} diff --git a/src/components/search/PullSearchGesture.tsx b/src/components/search/PullSearchGesture.tsx index a5fcd40..5131167 100644 --- a/src/components/search/PullSearchGesture.tsx +++ b/src/components/search/PullSearchGesture.tsx @@ -109,16 +109,28 @@ export function useScrollTopGate(initialAtTop = true) { setAtTop(next); }, []); - const onScroll = useCallback( - (event: NativeSyntheticEvent) => { - const { y } = event.nativeEvent.contentOffset; + const onScrollOffset = useCallback( + (y: number) => { offsetRef.current = y; setScrollAtTop(y <= 2); }, [setScrollAtTop] ); + const onScroll = useCallback( + (event: NativeSyntheticEvent) => { + 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({ diff --git a/src/components/settings/SettingsSectionScaffold.tsx b/src/components/settings/SettingsSectionScaffold.tsx index 17f7f2e..aada7b7 100644 --- a/src/components/settings/SettingsSectionScaffold.tsx +++ b/src/components/settings/SettingsSectionScaffold.tsx @@ -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. - {children} - +