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:
@@ -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,6 +203,12 @@ export function ProfileStarterPacks({
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<ScrollProvider
|
||||
onScroll={onScrollWorklet}
|
||||
onBeginDrag={onBeginDragFromContext}
|
||||
onEndDrag={onEndDragFromContext}
|
||||
onMomentumBegin={onMomentumEndFromContext}
|
||||
onMomentumEnd={onMomentumEndFromContext}>
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
@@ -184,7 +232,9 @@ export function ProfileStarterPacks({
|
||||
ListFooterComponent={
|
||||
!!data && items?.length !== 0 && isMe ? CreateAnother : undefined
|
||||
}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +161,12 @@ export function ProfileLabelsSection({
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ScrollProvider
|
||||
onScroll={onScrollWorklet}
|
||||
onBeginDrag={onBeginDragFromContext}
|
||||
onEndDrag={onEndDragFromContext}
|
||||
onMomentumBegin={onMomentumEndFromContext}
|
||||
onMomentumEnd={onMomentumEndFromContext}>
|
||||
<List
|
||||
ref={scrollElRef}
|
||||
data={labelValues}
|
||||
@@ -142,7 +190,9 @@ export function ProfileLabelsSection({
|
||||
style={a.border_transparent}
|
||||
/>
|
||||
}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +288,12 @@ export function ProfileFeedgens({
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<ScrollProvider
|
||||
onScroll={onScrollWorklet}
|
||||
onBeginDrag={onBeginDragFromContext}
|
||||
onEndDrag={onEndDragFromContext}
|
||||
onMomentumBegin={onMomentumEndFromContext}
|
||||
onMomentumEnd={onMomentumEndFromContext}>
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
@@ -261,7 +309,9 @@ export function ProfileFeedgens({
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
contentContainerStyle={{minHeight: height + headerOffset}}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +287,12 @@ export function ProfileLists({
|
||||
|
||||
return (
|
||||
<View testID={testID} style={style}>
|
||||
<ScrollProvider
|
||||
onScroll={onScrollWorklet}
|
||||
onBeginDrag={onBeginDragFromContext}
|
||||
onEndDrag={onEndDragFromContext}
|
||||
onMomentumBegin={onMomentumEndFromContext}
|
||||
onMomentumEnd={onMomentumEndFromContext}>
|
||||
<List
|
||||
testID={testID ? `${testID}-flatlist` : undefined}
|
||||
ref={scrollElRef}
|
||||
@@ -260,7 +308,9 @@ export function ProfileLists({
|
||||
desktopFixedHeight
|
||||
onEndReached={onEndReached}
|
||||
contentContainerStyle={{minHeight: height + headerOffset}}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user