Ok I think scrolling is solid

This commit is contained in:
Eric Bailey
2025-06-08 16:02:51 -05:00
parent 2c7419ec2a
commit 39664bfde6
3 changed files with 128 additions and 69 deletions
+12 -4
View File
@@ -1,24 +1,29 @@
import {createContext, useCallback, useContext} from 'react' import {createContext, useCallback, useContext} from 'react'
import {GestureResponderEvent, Keyboard, View} from 'react-native' import {
type GestureResponderEvent,
Keyboard,
View,
type ViewProps,
} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useSetDrawerOpen} from '#/state/shell' import {useSetDrawerOpen} from '#/state/shell'
import { import {
atoms as a, atoms as a,
platform, platform,
TextStyleProp, type TextStyleProp,
useBreakpoints, useBreakpoints,
useGutters, useGutters,
useLayoutBreakpoints, useLayoutBreakpoints,
useTheme, useTheme,
web, web,
} from '#/alf' } from '#/alf'
import {Button, ButtonIcon, ButtonProps} from '#/components/Button' import {Button, ButtonIcon, type ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import { import {
@@ -35,11 +40,13 @@ export function Outer({
noBottomBorder, noBottomBorder,
headerRef, headerRef,
sticky = true, sticky = true,
onLayout,
}: { }: {
children: React.ReactNode children: React.ReactNode
noBottomBorder?: boolean noBottomBorder?: boolean
headerRef?: React.MutableRefObject<View | null> headerRef?: React.MutableRefObject<View | null>
sticky?: boolean sticky?: boolean
onLayout?: ViewProps['onLayout']
}) { }) {
const t = useTheme() const t = useTheme()
const gutters = useGutters([0, 'base']) const gutters = useGutters([0, 'base'])
@@ -50,6 +57,7 @@ export function Outer({
return ( return (
<View <View
ref={headerRef} ref={headerRef}
onLayout={onLayout}
style={[ style={[
a.w_full, a.w_full,
!noBottomBorder && a.border_b, !noBottomBorder && a.border_b,
+109 -55
View File
@@ -28,7 +28,7 @@ import {
ThreadItemTreePost, ThreadItemTreePost,
ThreadItemTreePostSkeleton, ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost' } from '#/screens/PostThread/components/ThreadItemTreePost'
import {useBreakpoints, web} from '#/alf' import {native, platform,useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists' import {ListFooter} from '#/components/Lists'
@@ -84,8 +84,9 @@ export function Inner({uri}: {uri: string | undefined}) {
const totalParentCount = useRef(0) // recomputed below const totalParentCount = useRef(0) // recomputed below
const totalChildrenCount = useRef(thread.data.items.length) // recomputed below const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
const headerRef = useRef<View | null>(null)
const anchorRef = useRef<View | null>(null) const anchorRef = useRef<View | null>(null)
const headerRef = useRef<View | null>(null)
const headerHeight = useRef(0)
/* /*
* On a cold load, parents are not prepended until the anchor post has * On a cold load, parents are not prepended until the anchor post has
@@ -107,11 +108,9 @@ export function Inner({uri}: {uri: string | undefined}) {
* this is always true. And when a user changes thread parameters, we also * this is always true. And when a user changes thread parameters, we also
* manually set this to true. * manually set this to true.
*/ */
const shouldScrollToAnchor = useRef(true) const shouldHandleScroll = useRef(true)
/** /**
* WEB ONLY * Called any time the content size of the list changes, _just_ before paint.
*
* Called any time the content size of the list changes, just before paint.
* *
* We want this to fire every time we change params (which will reset * We want this to fire every time we change params (which will reset
* `deferParents` via `onLayout` on the anchor post, due to the key change), * `deferParents` via `onLayout` on the anchor post, due to the key change),
@@ -122,46 +121,85 @@ export function Inner({uri}: {uri: string | undefined}) {
* in the anchor being pinned as the first item. * in the anchor being pinned as the first item.
*/ */
const onContentSizeChangeWebOnly = web(() => { const onContentSizeChangeWebOnly = web(() => {
const anchorElement = anchorRef.current as any as Element const list = listRef.current
const headerElement = headerRef.current as any as Element const anchor = anchorRef.current as any as Element
if (anchorElement && headerElement) { if (list && anchor && shouldHandleScroll.current) {
const anchorOffsetTop = anchorElement.getBoundingClientRect().top const anchorOffsetTop = anchor.getBoundingClientRect().top
const headerHeight = headerElement.getBoundingClientRect().height
if (shouldScrollToAnchor.current) { /*
/* * `deferParents` is `true` on a cold load, and always reset to
* `deferParents` is `true` on a cold load, and always reset to * `true` when params change via `prepareForParamsUpdate`.
* `true` when params change (via the key on the anchor post). *
* * On a cold load or a push to a new post, on the first pass of this
* On a cold load, on the first pass of this logic, the anchor post is * logic, the anchor post is the first item in the list. Therefore
* the first item in the list. Therefore `anchorOffsetTop - headerHeight` * `anchorOffsetTop - headerHeight` will be 0.
* will be 0. *
* * When a user changes thread params, on the first pass of this logic,
* On a warm load, on the first pass of this logic, the anchor post may * the anchor post may not move (if there are no parents above it), or it
* not move (if there are no parents above it), or it may have gone off * may have gone off the screen above, because of the sudden lack of
* the screen above, because of the sudden lack of parents due to * parents due to `deferParents === true`. This negative value (minus
* `deferParents === true`. This negative value (minus `headerHeight`) * `headerHeight`) will result in a _negative_ `offset` value, which will
* will result in a _negative_ `offset` value, which will scroll the * scroll the anchor post _down_ to the top of the screen.
* anchor post _down_ to the top of the screen. *
* * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
* Once parents are prepended, this will fire again. Now, the * changes params, the anchor post's offset will actually be equivalent
* `anchorOffsetTop` will be positive, which minus the header height, * to the `headerHeight` because of how the DOM is stacked on web.
* will give us a _positive_ offset, which will scroll the anchor post * Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
* back _up_ to the top of the screen. * which means the first pass in this case will result in no scroll.
*/ *
listRef.current?.scrollToOffset({ * Then, once parents are prepended, this will fire again. Now, the
offset: anchorOffsetTop - headerHeight, * `anchorOffsetTop` will be positive, which minus the header height,
}) * will give us a _positive_ offset, which will scroll the anchor post
* back _up_ to the top of the screen.
*/
list.scrollToOffset({
offset: anchorOffsetTop - headerHeight.current,
})
/* /*
* After the second pass, `deferParents` will be `false`, and we need * After the second pass, `deferParents` will be `false`, and we need
* to ensure this doesn't run again until scroll handling is requested * to ensure this doesn't run again until scroll handling is requested
* again via `shouldScrollToAnchor.current === true` and a params * again via `shouldHandleScroll.current === true` and a params
* change. * change via `prepareForParamsUpdate`.
*/ */
if (!deferParents) shouldScrollToAnchor.current = false if (!deferParents) shouldHandleScroll.current = false
} }
})
/**
* Ditto the above, but for native.
*/
const onContentSizeChangeNativeOnly = native(() => {
const list = listRef.current
const anchor = anchorRef.current
if (list && anchor && shouldHandleScroll.current) {
/*
* `prepareForParamsUpdate` is called any time the user changes thread params like
* `view` or `sort`, which sets `deferParents(true)` and resets the
* scroll to the top of the list. However, there is a split second
* where the top of the list is wherever the parents _just were_. So if
* there were parents, the anchor is not at the top of the list just
* prior to this handler being called.
*
* Once this handler is called, the anchor post is the first item in
* the list (because of `deferParents` being `true`), and so we can
* synchronously scroll the list back to the top of the list (which is
* 0 on native, no need to handle `headerHeight`).
*/
list.scrollToOffset({
animated: false,
offset: 0,
})
/*
* After this first pass, `deferParents` will be `false`, and those
* will render in. However, the anchor post will retain its position
* because of `maintainVisibleContentPosition` handling on native. So we
* don't need to let this handler run again, like we do on web.
*/
shouldHandleScroll.current = false
} }
}) })
@@ -259,10 +297,10 @@ export function Inner({uri}: {uri: string | undefined}) {
return ( return (
<View <View
/* /*
* IMPORTANT: this is a load-bearing key. We want to force * IMPORTANT: this is a load-bearing key on all platforms. We
* `onLayout` to fire any time the thread params change so that * want to force `onLayout` to fire any time the thread params
* `deferParents` is always reset to `false` once the anchor post is * change so that `deferParents` is always reset to `false` once
* rendered. * the anchor post is rendered.
* *
* If we ever add additional thread params to this screen, they * If we ever add additional thread params to this screen, they
* will need to be added here. * will need to be added here.
@@ -343,27 +381,40 @@ export function Inner({uri}: {uri: string | undefined}) {
[thread, optimisticOnPostReply, onReplyToAnchor, gtPhone], [thread, optimisticOnPostReply, onReplyToAnchor, gtPhone],
) )
const prepareForParamsUpdate = useCallback(() => {
setDeferParents(true)
setMaxParentCount(PARENT_CHUNK_SIZE)
setMaxChildrenCount(CHILDREN_CHUNK_SIZE)
listRef.current?.scrollToOffset({
animated: false,
offset: 0,
})
shouldHandleScroll.current = true
}, [setDeferParents, setMaxParentCount, setMaxChildrenCount])
const setSortWrapped = useCallback( const setSortWrapped = useCallback(
(sort: string) => { (sort: string) => {
setDeferParents(true) prepareForParamsUpdate()
shouldScrollToAnchor.current = true
thread.actions.setSort(sort) thread.actions.setSort(sort)
}, },
[thread, setDeferParents], [thread, prepareForParamsUpdate],
) )
const setViewWrapped = useCallback( const setViewWrapped = useCallback(
(view: ThreadViewOption) => { (view: ThreadViewOption) => {
prepareForParamsUpdate()
thread.actions.setView(view) thread.actions.setView(view)
setDeferParents(true)
shouldScrollToAnchor.current = true
}, },
[thread, setDeferParents], [thread, prepareForParamsUpdate],
) )
return ( return (
<> <>
<Layout.Header.Outer headerRef={headerRef}> <Layout.Header.Outer
headerRef={headerRef}
onLayout={e => {
headerHeight.current = e.nativeEvent.layout.height
}}>
<Layout.Header.BackButton /> <Layout.Header.BackButton />
<Layout.Header.Content> <Layout.Header.Content>
<Layout.Header.TitleText> <Layout.Header.TitleText>
@@ -391,7 +442,10 @@ export function Inner({uri}: {uri: string | undefined}) {
data={slices} data={slices}
renderItem={renderItem} renderItem={renderItem}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
onContentSizeChange={onContentSizeChangeWebOnly} onContentSizeChange={platform({
web: onContentSizeChangeWebOnly,
default: onContentSizeChangeNativeOnly,
})}
onStartReached={onStartReached} onStartReached={onStartReached}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={2} onEndReachedThreshold={2}
+7 -10
View File
@@ -56,16 +56,13 @@ export function usePostThread({anchor}: {anchor?: string}) {
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
queryKey: postThreadQueryKey, queryKey: postThreadQueryKey,
async queryFn(ctx) { async queryFn(ctx) {
const {data} = await wait( const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
400, anchor: anchor!,
agent.app.bsky.unspecced.getPostThreadV2({ branchingFactor: view === 'linear' ? 1 : undefined,
anchor: anchor!, below: BELOW,
branchingFactor: view === 'linear' ? 1 : undefined, sort: sort,
below: BELOW, prioritizeFollowedUsers: prioritizeFollowedUsers,
sort: sort, })
prioritizeFollowedUsers: prioritizeFollowedUsers,
}),
)
/* /*
* Initialize `ctx.meta` to track if we know we have additional replies * Initialize `ctx.meta` to track if we know we have additional replies