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