From 8485ac65dfb7a79337781dac95092fa8cec2f92d Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 17 Feb 2026 11:49:57 +0200 Subject: [PATCH] Add animated scroll indicator insets to remaining profile tabs Apply the same scroll-position-aware indicator inset pattern to Labels, Lists, Feeds, and Starter Packs tabs. Each component now intercepts scroll events via ScrollProvider, tracks scrollY, and uses collapsedHeaderHeight to clamp the iOS scroll indicator top inset. Co-Authored-By: Claude Opus 4.6 --- .../StarterPack/ProfileStarterPacks.tsx | 98 ++++++++++++++----- src/screens/Profile/Sections/Labels.tsx | 98 ++++++++++++++----- src/view/com/feeds/ProfileFeedgens.tsx | 82 +++++++++++++--- src/view/com/lists/ProfileLists.tsx | 82 +++++++++++++--- src/view/screens/Profile.tsx | 15 ++- 5 files changed, 290 insertions(+), 85 deletions(-) diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index c335494001..5149ba6c3a 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -7,6 +7,11 @@ import { View, type ViewStyle, } from 'react-native' +import { + type ScrollHandler, + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated' import {type AppBskyGraphDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -18,9 +23,11 @@ import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' +import {ScrollProvider, useScrollHandlers} from '#/lib/ScrollContext' import {parseStarterPackUri} from '#/lib/strings/starter-pack' import {logger} from '#/logger' import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs' +import {useShellLayout} from '#/state/shell/shell-layout' import { EmptyState, type EmptyStateButtonProps, @@ -47,6 +54,7 @@ interface ProfileFeedgensProps { scrollElRef: ListRef did: string headerOffset: number + collapsedHeaderHeight?: number enabled?: boolean style?: StyleProp testID?: string @@ -66,6 +74,7 @@ export function ProfileStarterPacks({ scrollElRef, did, headerOffset, + collapsedHeaderHeight = 0, enabled, style, testID, @@ -79,6 +88,39 @@ export function ProfileStarterPacks({ const bottomBarOffset = useBottomBarOffset(100) const {height} = useWindowDimensions() const [isPTRing, setIsPTRing] = useState(false) + + const { + onBeginDrag: onBeginDragFromContext, + onEndDrag: onEndDragFromContext, + onScroll: onScrollFromContext, + onMomentumEnd: onMomentumEndFromContext, + } = useScrollHandlers() + + const scrollY = useSharedValue(0) + const onScrollWorklet = useCallback>( + (e, ctx) => { + 'worklet' + onScrollFromContext?.(e, ctx) + scrollY.set(e.contentOffset.y) + }, + [onScrollFromContext, scrollY], + ) + + const {footerHeight} = useShellLayout() + + const animatedProps = useAnimatedProps(() => { + if (IS_IOS) { + return { + scrollIndicatorInsets: { + top: Math.max(headerOffset - scrollY.get(), collapsedHeaderHeight), + right: 1, + left: 0, + bottom: footerHeight.get(), + }, + } + } + return {} + }) const { data, refetch, @@ -161,30 +203,38 @@ export function ProfileStarterPacks({ return ( - + + + ) } diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index e4af8c4a86..8a8ecb92e1 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -1,5 +1,10 @@ import {useCallback, useEffect, useImperativeHandle, useMemo} from 'react' import {findNodeHandle, type ListRenderItemInfo, View} from 'react-native' +import { + type ScrollHandler, + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated' import { type AppBskyLabelerDefs, type InterpretedLabelValueDefinition, @@ -11,6 +16,8 @@ import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation' +import {ScrollProvider, useScrollHandlers} from '#/lib/ScrollContext' +import {useShellLayout} from '#/state/shell/shell-layout' import {List, type ListRef} from '#/view/com/util/List' import {atoms as a, ios, tokens, useTheme} from '#/alf' import {Divider} from '#/components/Divider' @@ -31,6 +38,7 @@ interface LabelsSectionProps { moderationOpts: ModerationOpts scrollElRef: ListRef headerHeight: number + collapsedHeaderHeight: number isFocused: boolean setScrollViewTag: (tag: number | null) => void } @@ -43,11 +51,45 @@ export function ProfileLabelsSection({ moderationOpts, scrollElRef, headerHeight, + collapsedHeaderHeight, isFocused, setScrollViewTag, }: LabelsSectionProps) { const t = useTheme() + const { + onBeginDrag: onBeginDragFromContext, + onEndDrag: onEndDragFromContext, + onScroll: onScrollFromContext, + onMomentumEnd: onMomentumEndFromContext, + } = useScrollHandlers() + + const scrollY = useSharedValue(0) + const onScrollWorklet = useCallback>( + (e, ctx) => { + 'worklet' + onScrollFromContext?.(e, ctx) + scrollY.set(e.contentOffset.y) + }, + [onScrollFromContext, scrollY], + ) + + const {footerHeight} = useShellLayout() + + const animatedProps = useAnimatedProps(() => { + if (IS_IOS) { + return { + scrollIndicatorInsets: { + top: Math.max(headerHeight - scrollY.get(), collapsedHeaderHeight), + right: 1, + left: 0, + bottom: footerHeight.get(), + }, + } + } + return {} + }) + const onScrollToTop = useCallback(() => { scrollElRef.current?.scrollToOffset({ animated: IS_NATIVE, @@ -119,30 +161,38 @@ export function ProfileLabelsSection({ return ( - - } - ListFooterComponent={ - - } - /> + + + } + ListFooterComponent={ + + } + animatedProps={animatedProps} + /> + ) } diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 6eedcd8622..ef135e4be5 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -13,16 +13,23 @@ import { View, type ViewStyle, } from 'react-native' +import { + type ScrollHandler, + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {ScrollProvider, useScrollHandlers} from '#/lib/ScrollContext' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens' import {useSession} from '#/state/session' +import {useShellLayout} from '#/state/shell/shell-layout' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListRef} from '#/view/com/util/List' @@ -48,6 +55,7 @@ interface ProfileFeedgensProps { did: string scrollElRef: ListRef headerOffset: number + collapsedHeaderHeight?: number enabled?: boolean style?: StyleProp testID?: string @@ -59,6 +67,7 @@ export function ProfileFeedgens({ did, scrollElRef, headerOffset, + collapsedHeaderHeight = 0, enabled, style, testID, @@ -67,6 +76,39 @@ export function ProfileFeedgens({ const {_} = useLingui() const t = useTheme() const [isPTRing, setIsPTRing] = useState(false) + + const { + onBeginDrag: onBeginDragFromContext, + onEndDrag: onEndDragFromContext, + onScroll: onScrollFromContext, + onMomentumEnd: onMomentumEndFromContext, + } = useScrollHandlers() + + const scrollY = useSharedValue(0) + const onScrollWorklet = useCallback>( + (e, ctx) => { + 'worklet' + onScrollFromContext?.(e, ctx) + scrollY.set(e.contentOffset.y) + }, + [onScrollFromContext, scrollY], + ) + + const {footerHeight} = useShellLayout() + + const animatedProps = useAnimatedProps(() => { + if (IS_IOS) { + return { + scrollIndicatorInsets: { + top: Math.max(headerOffset - scrollY.get(), collapsedHeaderHeight), + right: 1, + left: 0, + bottom: footerHeight.get(), + }, + } + } + return {} + }) const {height} = useWindowDimensions() const opts = useMemo(() => ({enabled}), [enabled]) const { @@ -246,22 +288,30 @@ export function ProfileFeedgens({ return ( - + + + ) } diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index c175793624..f8c6c668a0 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -13,16 +13,23 @@ import { View, type ViewStyle, } from 'react-native' +import { + type ScrollHandler, + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' +import {ScrollProvider, useScrollHandlers} from '#/lib/ScrollContext' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' import {usePreferencesQuery} from '#/state/queries/preferences' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists' import {useSession} from '#/state/session' +import {useShellLayout} from '#/state/shell/shell-layout' import {EmptyState} from '#/view/com/util/EmptyState' import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' import {List, type ListRef} from '#/view/com/util/List' @@ -48,6 +55,7 @@ interface ProfileListsProps { did: string scrollElRef: ListRef headerOffset: number + collapsedHeaderHeight?: number enabled?: boolean style?: StyleProp testID?: string @@ -59,6 +67,7 @@ export function ProfileLists({ did, scrollElRef, headerOffset, + collapsedHeaderHeight = 0, enabled, style, testID, @@ -67,6 +76,39 @@ export function ProfileLists({ const {_} = useLingui() const t = useTheme() const [isPTRing, setIsPTRing] = useState(false) + + const { + onBeginDrag: onBeginDragFromContext, + onEndDrag: onEndDragFromContext, + onScroll: onScrollFromContext, + onMomentumEnd: onMomentumEndFromContext, + } = useScrollHandlers() + + const scrollY = useSharedValue(0) + const onScrollWorklet = useCallback>( + (e, ctx) => { + 'worklet' + onScrollFromContext?.(e, ctx) + scrollY.set(e.contentOffset.y) + }, + [onScrollFromContext, scrollY], + ) + + const {footerHeight} = useShellLayout() + + const animatedProps = useAnimatedProps(() => { + if (IS_IOS) { + return { + scrollIndicatorInsets: { + top: Math.max(headerOffset - scrollY.get(), collapsedHeaderHeight), + right: 1, + left: 0, + bottom: footerHeight.get(), + }, + } + } + return {} + }) const {height} = useWindowDimensions() const opts = useMemo(() => ({enabled}), [enabled]) const { @@ -245,22 +287,30 @@ export function ProfileLists({ return ( - + + + ) } diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 0e79f2f3fe..cad2295055 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -393,7 +393,7 @@ function ProfileScreenLoaded({ renderHeader={renderHeader} allowHeaderOverScroll> {showFiltersTab - ? ({headerHeight, isFocused, scrollElRef}) => ( + ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => ( ) : null} {showListsTab && !!profile.associated?.labeler - ? ({headerHeight, isFocused, scrollElRef}) => ( + ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => ( @@ -535,25 +537,27 @@ function ProfileScreenLoaded({ ) : null} {showFeedsTab - ? ({headerHeight, isFocused, scrollElRef}) => ( + ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => ( ) : null} {showStarterPacksTab - ? ({headerHeight, isFocused, scrollElRef}) => ( + ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => ( ( + ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (