Add animated scroll indicator insets to PostFeed

Track scroll position in PostFeed and use it to dynamically adjust iOS scroll
indicator insets so they follow the collapsing header. Gate the scrollY-based
adjustment behind an adjustScrollIndicators prop so it only activates on
profile pages. Thread collapsedHeaderHeight from PagerWithHeader through
ProfileFeedSection to PostFeed to replace the previous hardcoded minimum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-02-17 11:48:52 +02:00
parent 8672740833
commit 4c374eb616
3 changed files with 97 additions and 34 deletions
+4
View File
@@ -28,6 +28,7 @@ interface FeedSectionProps {
ref?: React.Ref<SectionRef> ref?: React.Ref<SectionRef>
feed: FeedDescriptor feed: FeedDescriptor
headerHeight: number headerHeight: number
collapsedHeaderHeight: number
isFocused: boolean isFocused: boolean
scrollElRef: ListRef scrollElRef: ListRef
ignoreFilterFor?: string ignoreFilterFor?: string
@@ -41,6 +42,7 @@ export function ProfileFeedSection({
ref, ref,
feed, feed,
headerHeight, headerHeight,
collapsedHeaderHeight,
isFocused, isFocused,
scrollElRef, scrollElRef,
ignoreFilterFor, ignoreFilterFor,
@@ -110,6 +112,8 @@ export function ProfileFeedSection({
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
} }
isVideoFeed={isVideoFeed} isVideoFeed={isVideoFeed}
adjustScrollIndicators
collapsedHeaderHeight={collapsedHeaderHeight}
/> />
{(isScrolledDown || hasNew) && ( {(isScrolledDown || hasNew) && (
<LoadLatestBtn <LoadLatestBtn
+83 -29
View File
@@ -10,6 +10,11 @@ import {
View, View,
type ViewStyle, type ViewStyle,
} from 'react-native' } from 'react-native'
import {
type ScrollHandler,
useAnimatedProps,
useSharedValue,
} from 'react-native-reanimated'
import { import {
type AppBskyActorDefs, type AppBskyActorDefs,
AppBskyEmbedVideo, AppBskyEmbedVideo,
@@ -21,6 +26,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants' import {DISCOVER_FEED_URI, KNOWN_SHUTDOWN_FEEDS} from '#/lib/constants'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ScrollProvider, useScrollHandlers} from '#/lib/ScrollContext'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow'
@@ -42,6 +48,7 @@ import {truncateAndInvalidate} from '#/state/queries/util'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import {useSelectedFeed} from '#/state/shell/selected-feed' import {useSelectedFeed} from '#/state/shell/selected-feed'
import {useShellLayout} from '#/state/shell/shell-layout'
import {List, type ListRef} from '#/view/com/util/List' import {List, type ListRef} from '#/view/com/util/List'
import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
@@ -204,6 +211,8 @@ let PostFeed = ({
savedFeedConfig, savedFeedConfig,
initialNumToRender: initialNumToRenderOverride, initialNumToRender: initialNumToRenderOverride,
isVideoFeed = false, isVideoFeed = false,
adjustScrollIndicators,
collapsedHeaderHeight = 0,
}: { }: {
feed: FeedDescriptor feed: FeedDescriptor
feedParams?: FeedParams feedParams?: FeedParams
@@ -227,6 +236,8 @@ let PostFeed = ({
initialNumToRender?: number initialNumToRender?: number
isVideoFeed?: boolean isVideoFeed?: boolean
lastFetchDate?: () => number lastFetchDate?: () => number
adjustScrollIndicators?: boolean
collapsedHeaderHeight?: number
}): React.ReactNode => { }): React.ReactNode => {
const ax = useAnalytics() const ax = useAnalytics()
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -999,37 +1010,80 @@ let PostFeed = ({
[feedFeedback, feed, liveNowConfig, getPostPosition, ax], [feedFeedback, feed, liveNowConfig, getPostPosition, ax],
) )
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: adjustScrollIndicators
? Math.max(headerOffset - scrollY.get(), collapsedHeaderHeight)
: headerOffset,
right: 1,
left: 0,
bottom: footerHeight.get(),
},
}
}
return {}
})
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
<List <ScrollProvider
testID={testID ? `${testID}-flatlist` : undefined} onScroll={onScrollWorklet}
ref={scrollElRef} onBeginDrag={onBeginDragFromContext}
data={feedItems} onEndDrag={onEndDragFromContext}
keyExtractor={(item: FeedRow) => item.key} onMomentumBegin={onMomentumEndFromContext}
renderItem={renderItem} onMomentumEnd={onMomentumEndFromContext}>
ListFooterComponent={FeedFooter} <List
ListHeaderComponent={ListHeaderComponent} testID={testID ? `${testID}-flatlist` : undefined}
refreshing={isPTRing} ref={scrollElRef}
onRefresh={() => void onRefresh()} data={feedItems}
headerOffset={headerOffset} keyExtractor={(item: FeedRow) => item.key}
progressViewOffset={progressViewOffset} renderItem={renderItem}
contentContainerStyle={{ ListFooterComponent={FeedFooter}
minHeight: Dimensions.get('window').height * 1.5, ListHeaderComponent={ListHeaderComponent}
}} refreshing={isPTRing}
onScrolledDownChange={handleScrolledDownChange} onRefresh={() => void onRefresh()}
onEndReached={() => void onEndReached()} headerOffset={headerOffset}
onEndReachedThreshold={2} // number of posts left to trigger load more progressViewOffset={progressViewOffset}
removeClippedSubviews={true} contentContainerStyle={{
extraData={extraData} minHeight: Dimensions.get('window').height * 1.5,
desktopFixedHeight={ }}
desktopFixedHeightOffset ? desktopFixedHeightOffset : true onScrolledDownChange={handleScrolledDownChange}
} onEndReached={() => void onEndReached()}
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender} onEndReachedThreshold={2} // number of posts left to trigger load more
windowSize={9} removeClippedSubviews={true}
maxToRenderPerBatch={IS_IOS ? 5 : 1} extraData={extraData}
updateCellsBatchingPeriod={40} desktopFixedHeight={
onItemSeen={onItemSeen} desktopFixedHeightOffset ? desktopFixedHeightOffset : true
/> }
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
windowSize={9}
maxToRenderPerBatch={IS_IOS ? 5 : 1}
updateCellsBatchingPeriod={40}
onItemSeen={onItemSeen}
animatedProps={animatedProps}
/>
</ScrollProvider>
</View> </View>
) )
} }
+10 -5
View File
@@ -420,11 +420,12 @@ function ProfileScreenLoaded({
) )
: null} : null}
{showPostsTab {showPostsTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection <ProfileFeedSection
ref={postsSectionRef} ref={postsSectionRef}
feed={`author|${profile.did}|posts_and_author_threads`} feed={`author|${profile.did}|posts_and_author_threads`}
headerHeight={headerHeight} headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
@@ -446,11 +447,12 @@ function ProfileScreenLoaded({
) )
: null} : null}
{showRepliesTab {showRepliesTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection <ProfileFeedSection
ref={repliesSectionRef} ref={repliesSectionRef}
feed={`author|${profile.did}|posts_with_replies`} feed={`author|${profile.did}|posts_with_replies`}
headerHeight={headerHeight} headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
@@ -461,11 +463,12 @@ function ProfileScreenLoaded({
) )
: null} : null}
{showMediaTab {showMediaTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection <ProfileFeedSection
ref={mediaSectionRef} ref={mediaSectionRef}
feed={`author|${profile.did}|posts_with_media`} feed={`author|${profile.did}|posts_with_media`}
headerHeight={headerHeight} headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
@@ -488,11 +491,12 @@ function ProfileScreenLoaded({
) )
: null} : null}
{showVideosTab {showVideosTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection <ProfileFeedSection
ref={videosSectionRef} ref={videosSectionRef}
feed={`author|${profile.did}|posts_with_video`} feed={`author|${profile.did}|posts_with_video`}
headerHeight={headerHeight} headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
@@ -515,11 +519,12 @@ function ProfileScreenLoaded({
) )
: null} : null}
{showLikesTab {showLikesTab
? ({headerHeight, isFocused, scrollElRef}) => ( ? ({headerHeight, collapsedHeaderHeight, isFocused, scrollElRef}) => (
<ProfileFeedSection <ProfileFeedSection
ref={likesSectionRef} ref={likesSectionRef}
feed={`likes|${profile.did}`} feed={`likes|${profile.did}`}
headerHeight={headerHeight} headerHeight={headerHeight}
collapsedHeaderHeight={collapsedHeaderHeight}
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}