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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user