From 866118c49f14d47451adb3b84eb50de814d3cabf Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 18 Feb 2026 17:16:45 +0200 Subject: [PATCH] abstract into a nice hook --- .../StarterPack/ProfileStarterPacks.tsx | 126 ++++++------------ src/screens/Profile/Sections/Labels.tsx | 109 +++++---------- .../useProfileScrollbarAdjustment.ios.ts | 81 +++++++++++ .../Profile/useProfileScrollbarAdjustment.ts | 50 +++++++ src/view/com/feeds/ProfileFeedgens.tsx | 99 ++++---------- src/view/com/lists/ProfileLists.tsx | 99 ++++---------- src/view/com/posts/PostFeed.tsx | 125 ++++++----------- 7 files changed, 299 insertions(+), 390 deletions(-) create mode 100644 src/screens/Profile/useProfileScrollbarAdjustment.ios.ts create mode 100644 src/screens/Profile/useProfileScrollbarAdjustment.ts diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 7c4b323c13..5f67759d7d 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -7,11 +7,6 @@ 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' @@ -21,20 +16,19 @@ import {useNavigation} from '@react-navigation/native' import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' 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 {ScrollProvider} 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, } from '#/view/com/util/EmptyState' import {List, type ListRef} from '#/view/com/util/List' -import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' -import {atoms as a, ios, useTheme} from '#/alf' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {useProfileScrollbarAdjustment} from '#/screens/Profile/useProfileScrollbarAdjustment' +import {atoms as a, ios, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' @@ -88,39 +82,8 @@ export function ProfileStarterPacks({ const bottomBarOffset = useBottomBarOffset(100) const {height} = useWindowDimensions() const [isPTRing, setIsPTRing] = useState(false) + const {gtMobile} = useBreakpoints() - const { - onBeginDrag: onBeginDragFromContext, - onEndDrag: onEndDragFromContext, - onScroll: onScrollFromContext, - onMomentumEnd: onMomentumEndFromContext, - } = useScrollHandlers() - - const scrollY = useSharedValue(0) - const onScrollWorklet = useCallback>( - (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, @@ -129,7 +92,6 @@ export function ProfileStarterPacks({ isFetchingNextPage, fetchNextPage, } = useActorStarterPacksQuery({did, enabled}) - const {isTabletOrDesktop} = useWebMediaQueries() const items = data?.pages.flatMap(page => page.starterPacks) const {_} = useLingui() @@ -191,58 +153,54 @@ export function ProfileStarterPacks({ ) }, - [isTabletOrDesktop, t.atoms.border_contrast_low], + [gtMobile, t.atoms.border_contrast_low], ) - const list = ( - - ) + const {scrollHandlers, animatedProps} = useProfileScrollbarAdjustment({ + headerOffset, + collapsedHeaderHeight, + }) return ( - {IS_IOS ? ( - - {list} - - ) : ( - list - )} + + + ) } diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx index 6a2edf0c73..855b5368ee 100644 --- a/src/screens/Profile/Sections/Labels.tsx +++ b/src/screens/Profile/Sections/Labels.tsx @@ -1,10 +1,5 @@ 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, @@ -16,9 +11,9 @@ 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 {ScrollProvider} from '#/lib/ScrollContext' import {List, type ListRef} from '#/view/com/util/List' +import {useProfileScrollbarAdjustment} from '#/screens/Profile/useProfileScrollbarAdjustment' import {atoms as a, ios, tokens, useTheme} from '#/alf' import {Divider} from '#/components/Divider' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' @@ -57,39 +52,6 @@ export function ProfileLabelsSection({ }: LabelsSectionProps) { const t = useTheme() - const { - onBeginDrag: onBeginDragFromContext, - onEndDrag: onEndDragFromContext, - onScroll: onScrollFromContext, - onMomentumEnd: onMomentumEndFromContext, - } = useScrollHandlers() - - const scrollY = useSharedValue(0) - const onScrollWorklet = useCallback>( - (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, @@ -159,45 +121,40 @@ export function ProfileLabelsSection({ [labelerInfo, isSubscribed, numItems, t], ) - const list = ( - - } - ListFooterComponent={ - - } - animatedProps={IS_IOS ? animatedProps : undefined} - /> - ) + const {scrollHandlers, animatedProps} = useProfileScrollbarAdjustment({ + headerOffset: headerHeight, + collapsedHeaderHeight, + }) return ( - {IS_IOS ? ( - - {list} - - ) : ( - list - )} + + + } + ListFooterComponent={ + + } + animatedProps={animatedProps} + /> + ) } diff --git a/src/screens/Profile/useProfileScrollbarAdjustment.ios.ts b/src/screens/Profile/useProfileScrollbarAdjustment.ios.ts new file mode 100644 index 0000000000..3fb900b24a --- /dev/null +++ b/src/screens/Profile/useProfileScrollbarAdjustment.ios.ts @@ -0,0 +1,81 @@ +import {useCallback, useMemo} from 'react' +import { + type ScrollHandler, + useAnimatedProps, + useSharedValue, +} from 'react-native-reanimated' + +import {useScrollHandlers} from '#/lib/ScrollContext' +import {useShellLayout} from '#/state/shell/shell-layout' + +/** + * Adjusts the scroll indicator insets on iOS to account for the pager header. + * Adds another scroll offset listener, so use sparingly. + * + * HOW TO USE: + * + * ```tsx + * const { scrollHandlers, animatedProps } = useProfileScrollbarAdjustment({ + * headerHeight: 600, // full size of the header + * collapsedHeaderHeight: 200, // height of the header when collapsed + * }) + * + * return ( + * + * + * + * ) + * ```` + */ +export function useProfileScrollbarAdjustment({ + enabled = true, + headerOffset, + collapsedHeaderHeight, +}: { + enabled?: boolean + headerOffset: number + collapsedHeaderHeight: number +}) { + const {onScroll: onScrollFromContext, ...otherScrollHandlers} = + useScrollHandlers() + + const scrollY = useSharedValue(0) + const onScroll = useCallback>( + (e, ctx) => { + 'worklet' + onScrollFromContext?.(e, ctx) + if (enabled) { + scrollY.set(e.contentOffset.y) + } + }, + [onScrollFromContext, scrollY, enabled], + ) + + const {footerHeight} = useShellLayout() + + const animatedProps = useAnimatedProps(() => { + return { + scrollIndicatorInsets: { + top: enabled + ? Math.max(headerOffset - scrollY.get(), collapsedHeaderHeight) + : headerOffset, + right: 1, + left: 0, + bottom: footerHeight.get(), + }, + } + }) + + const scrollHandlers = useMemo( + () => ({ + onScroll, + ...otherScrollHandlers, + }), + [onScroll, otherScrollHandlers], + ) + + return { + scrollHandlers, + animatedProps, + } +} diff --git a/src/screens/Profile/useProfileScrollbarAdjustment.ts b/src/screens/Profile/useProfileScrollbarAdjustment.ts new file mode 100644 index 0000000000..308b9b661d --- /dev/null +++ b/src/screens/Profile/useProfileScrollbarAdjustment.ts @@ -0,0 +1,50 @@ +import {useScrollHandlers} from '#/lib/ScrollContext' + +/** + * Adjusts the scroll indicator insets on iOS to account for the pager header. Adds another scroll offset + * + * HOW TO USE: + * + * ```tsx + * const { scrollHandlers, animatedProps } = useScrollbarAdjustment({ + * headerHeight: 600, // full size of the header + * collapsedHeaderHeight: 200, // height of the header when collapsed + * }) + * + * return ( + * + * + * + * ) + * ``` + * + * @platform ios + */ +export function useProfileScrollbarAdjustment({}: { + enabled?: boolean + headerOffset: number + collapsedHeaderHeight: number +}): { + scrollHandlers: ReturnType + animatedProps: + | undefined + | Partial<{ + scrollIndicatorInsets: { + top: number + right: number + left: number + bottom: number + } + }> +} { + // this is a no-op version for android/web + + const scrollHandlers = useScrollHandlers() + + const animatedProps = undefined + + return { + scrollHandlers, + animatedProps, + } +} diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx index 4a611a665b..46b862bf8a 100644 --- a/src/view/com/feeds/ProfileFeedgens.tsx +++ b/src/view/com/feeds/ProfileFeedgens.tsx @@ -13,28 +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 {ScrollProvider} 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' -import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import {useProfileScrollbarAdjustment} from '#/screens/Profile/useProfileScrollbarAdjustment' import {atoms as a, ios, useTheme} from '#/alf' import * as FeedCard from '#/components/FeedCard' import {HashtagWide_Stroke1_Corner0_Rounded as HashtagWideIcon} from '#/components/icons/Hashtag' @@ -77,38 +72,6 @@ export function ProfileFeedgens({ 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>( - (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 { @@ -230,7 +193,7 @@ export function ProfileFeedgens({ /> ) } else if (item === LOADING) { - return + return } if (preferences) { return ( @@ -286,40 +249,32 @@ export function ProfileFeedgens({ isEmpty, ]) - const list = ( - - ) + const {scrollHandlers, animatedProps} = useProfileScrollbarAdjustment({ + headerOffset, + collapsedHeaderHeight, + }) return ( - {IS_IOS ? ( - - {list} - - ) : ( - list - )} + + + ) } diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx index 18c793d673..0d689b01d1 100644 --- a/src/view/com/lists/ProfileLists.tsx +++ b/src/view/com/lists/ProfileLists.tsx @@ -13,28 +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 {ScrollProvider} 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' -import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' +import {FeedFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' +import {useProfileScrollbarAdjustment} from '#/screens/Profile/useProfileScrollbarAdjustment' import {atoms as a, ios, useTheme} from '#/alf' import {BulletList_Stroke1_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList' import * as ListCard from '#/components/ListCard' @@ -77,38 +72,6 @@ export function ProfileLists({ 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>( - (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 { @@ -229,7 +192,7 @@ export function ProfileLists({ /> ) } else if (item === LOADING) { - return + return } if (preferences) { return ( @@ -285,40 +248,32 @@ export function ProfileLists({ isEmpty, ]) - const list = ( - - ) + const {scrollHandlers, animatedProps} = useProfileScrollbarAdjustment({ + headerOffset, + collapsedHeaderHeight, + }) return ( - {IS_IOS ? ( - - {list} - - ) : ( - list - )} + + + ) } diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 6e9bae16a6..ac48bc2c32 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -10,11 +10,6 @@ import { View, type ViewStyle, } from 'react-native' -import { - type ScrollHandler, - useAnimatedProps, - useSharedValue, -} from 'react-native-reanimated' import { type AppBskyActorDefs, AppBskyEmbedVideo, @@ -26,7 +21,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 {ScrollProvider} from '#/lib/ScrollContext' import {isNetworkError} from '#/lib/strings/errors' import {logger} from '#/logger' import {usePostAuthorShadowFilter} from '#/state/cache/profile-shadow' @@ -48,10 +43,10 @@ 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' +import {useProfileScrollbarAdjustment} from '#/screens/Profile/useProfileScrollbarAdjustment' import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types' import {useBreakpoints, useLayoutBreakpoints} from '#/alf' import { @@ -211,7 +206,7 @@ let PostFeed = ({ savedFeedConfig, initialNumToRender: initialNumToRenderOverride, isVideoFeed = false, - adjustScrollIndicators, + adjustScrollIndicators = false, collapsedHeaderHeight = 0, }: { feed: FeedDescriptor @@ -1010,88 +1005,46 @@ let PostFeed = ({ [feedFeedback, feed, liveNowConfig, getPostPosition, ax], ) - const { - onBeginDrag: onBeginDragFromContext, - onEndDrag: onEndDragFromContext, - onScroll: onScrollFromContext, - onMomentumEnd: onMomentumEndFromContext, - } = useScrollHandlers() - - const scrollY = useSharedValue(0) - const onScrollWorklet = useCallback>( - (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 {} + const {scrollHandlers, animatedProps} = useProfileScrollbarAdjustment({ + enabled: adjustScrollIndicators, + headerOffset, + collapsedHeaderHeight, }) - const list = ( - 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={IS_IOS ? animatedProps : undefined} - /> - ) - return ( - {IS_IOS ? ( - - {list} - - ) : ( - list - )} + + 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} + /> + ) }