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 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-02-17 11:49:57 +02:00
parent 4c374eb616
commit 8485ac65df
5 changed files with 290 additions and 85 deletions
@@ -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<ViewStyle>
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<ScrollHandler<any>>(
(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 (
<View testID={testID} style={style}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{
minHeight: height + headerOffset,
paddingBottom: bottomBarOffset,
}}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
onRefresh={onRefresh}
ListEmptyComponent={
data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
}
ListFooterComponent={
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
}
/>
<ScrollProvider
onScroll={onScrollWorklet}
onBeginDrag={onBeginDragFromContext}
onEndDrag={onEndDragFromContext}
onMomentumBegin={onMomentumEndFromContext}
onMomentumEnd={onMomentumEndFromContext}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
refreshing={isPTRing}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
contentContainerStyle={{
minHeight: height + headerOffset,
paddingBottom: bottomBarOffset,
}}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
onRefresh={onRefresh}
ListEmptyComponent={
data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
}
ListFooterComponent={
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
}
animatedProps={animatedProps}
/>
</ScrollProvider>
</View>
)
}
+74 -24
View File
@@ -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<ScrollHandler<any>>(
(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 (
<View>
<List
ref={scrollElRef}
data={labelValues}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={a.px_xl}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
ListHeaderComponent={
<LabelerListHeader
isLabelerLoading={isLabelerLoading}
labelerInfo={labelerInfo}
labelerError={labelerError}
hasValues={labelValues.length !== 0}
isSubscribed={isSubscribed}
/>
}
ListFooterComponent={
<ListFooter
height={headerHeight + 180}
style={a.border_transparent}
/>
}
/>
<ScrollProvider
onScroll={onScrollWorklet}
onBeginDrag={onBeginDragFromContext}
onEndDrag={onEndDragFromContext}
onMomentumBegin={onMomentumEndFromContext}
onMomentumEnd={onMomentumEndFromContext}>
<List
ref={scrollElRef}
data={labelValues}
renderItem={renderItem}
keyExtractor={keyExtractor}
contentContainerStyle={a.px_xl}
headerOffset={headerHeight}
progressViewOffset={ios(0)}
ListHeaderComponent={
<LabelerListHeader
isLabelerLoading={isLabelerLoading}
labelerInfo={labelerInfo}
labelerError={labelerError}
hasValues={labelValues.length !== 0}
isSubscribed={isSubscribed}
/>
}
ListFooterComponent={
<ListFooter
height={headerHeight + 180}
style={a.border_transparent}
/>
}
animatedProps={animatedProps}
/>
</ScrollProvider>
</View>
)
}
+66 -16
View File
@@ -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<ViewStyle>
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<ScrollHandler<any>>(
(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 (
<View testID={testID} style={style}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListFooterComponent={ProfileFeedgensFooter}
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
contentContainerStyle={{minHeight: height + headerOffset}}
/>
<ScrollProvider
onScroll={onScrollWorklet}
onBeginDrag={onBeginDragFromContext}
onEndDrag={onEndDragFromContext}
onMomentumBegin={onMomentumEndFromContext}
onMomentumEnd={onMomentumEndFromContext}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListFooterComponent={ProfileFeedgensFooter}
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
contentContainerStyle={{minHeight: height + headerOffset}}
animatedProps={animatedProps}
/>
</ScrollProvider>
</View>
)
}
+66 -16
View File
@@ -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<ViewStyle>
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<ScrollHandler<any>>(
(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 (
<View testID={testID} style={style}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListFooterComponent={ProfileListsFooter}
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
contentContainerStyle={{minHeight: height + headerOffset}}
/>
<ScrollProvider
onScroll={onScrollWorklet}
onBeginDrag={onBeginDragFromContext}
onEndDrag={onEndDragFromContext}
onMomentumBegin={onMomentumEndFromContext}
onMomentumEnd={onMomentumEndFromContext}>
<List
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
ListFooterComponent={ProfileListsFooter}
refreshing={isPTRing}
onRefresh={onRefresh}
headerOffset={headerOffset}
progressViewOffset={ios(0)}
removeClippedSubviews={true}
desktopFixedHeight
onEndReached={onEndReached}
contentContainerStyle={{minHeight: height + headerOffset}}
animatedProps={animatedProps}
/>
</ScrollProvider>
</View>
)
}
+10 -5
View File
@@ -393,7 +393,7 @@ function ProfileScreenLoaded({
renderHeader={renderHeader}
allowHeaderOverScroll>
{showFiltersTab
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileLabelsSection
ref={labelsSectionRef}
labelerInfo={labelerInfo}
@@ -402,18 +402,20 @@ function ProfileScreenLoaded({
moderationOpts={moderationOpts}
scrollElRef={scrollElRef as ListRef}
headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused}
setScrollViewTag={setScrollViewTag}
/>
)
: null}
{showListsTab && !!profile.associated?.labeler
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/>
@@ -535,25 +537,27 @@ function ProfileScreenLoaded({
)
: null}
{showFeedsTab
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedgens
ref={feedsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/>
)
: null}
{showStarterPacksTab
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileStarterPacks
ref={starterPacksSectionRef}
did={profile.did}
isMe={isMe}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
enabled={isFocused}
setScrollViewTag={setScrollViewTag}
emptyStateMessage={
@@ -579,12 +583,13 @@ function ProfileScreenLoaded({
)
: null}
{showListsTab && !profile.associated?.labeler
? ({headerHeight, isFocused, scrollElRef}) => (
? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileLists
ref={listsSectionRef}
did={profile.did}
scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/>