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 {GestureResponderEvent, Keyboard, View} from 'react-native'
import {
type GestureResponderEvent,
Keyboard,
View,
type ViewProps,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
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 {useSetDrawerOpen} from '#/state/shell'
import {
atoms as a,
platform,
TextStyleProp,
type TextStyleProp,
useBreakpoints,
useGutters,
useLayoutBreakpoints,
useTheme,
web,
} 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 {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {
@@ -35,11 +40,13 @@ export function Outer({
noBottomBorder,
headerRef,
sticky = true,
onLayout,
}: {
children: React.ReactNode
noBottomBorder?: boolean
headerRef?: React.MutableRefObject<View | null>
sticky?: boolean
onLayout?: ViewProps['onLayout']
}) {
const t = useTheme()
const gutters = useGutters([0, 'base'])
@@ -50,6 +57,7 @@ export function Outer({
return (
<View
ref={headerRef}
onLayout={onLayout}
style={[
a.w_full,
!noBottomBorder && a.border_b,
+109 -55
View File
@@ -28,7 +28,7 @@ import {
ThreadItemTreePost,
ThreadItemTreePostSkeleton,
} from '#/screens/PostThread/components/ThreadItemTreePost'
import {useBreakpoints, web} from '#/alf'
import {native, platform,useBreakpoints, web} from '#/alf'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
@@ -84,8 +84,9 @@ export function Inner({uri}: {uri: string | undefined}) {
const totalParentCount = useRef(0) // recomputed below
const totalChildrenCount = useRef(thread.data.items.length) // recomputed below
const listRef = useRef<ListMethods>(null)
const headerRef = 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
@@ -107,11 +108,9 @@ export function Inner({uri}: {uri: string | undefined}) {
* this is always true. And when a user changes thread parameters, we also
* 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
* `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.
*/
const onContentSizeChangeWebOnly = web(() => {
const anchorElement = anchorRef.current as any as Element
const headerElement = headerRef.current as any as Element
const list = listRef.current
const anchor = anchorRef.current as any as Element
if (anchorElement && headerElement) {
const anchorOffsetTop = anchorElement.getBoundingClientRect().top
const headerHeight = headerElement.getBoundingClientRect().height
if (list && anchor && shouldHandleScroll.current) {
const anchorOffsetTop = anchor.getBoundingClientRect().top
if (shouldScrollToAnchor.current) {
/*
* `deferParents` is `true` on a cold load, and always reset to
* `true` when params change (via the key on the anchor post).
*
* On a cold load, on the first pass of this logic, the anchor post is
* the first item in the list. Therefore `anchorOffsetTop - headerHeight`
* will be 0.
*
* On a warm load, on the first pass of this logic, the anchor post may
* not move (if there are no parents above it), or it may have gone off
* the screen above, because of the sudden lack of parents due to
* `deferParents === true`. This negative value (minus `headerHeight`)
* will result in a _negative_ `offset` value, which will scroll the
* anchor post _down_ to the top of the screen.
*
* Once parents are prepended, this will fire again. Now, the
* `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.
*/
listRef.current?.scrollToOffset({
offset: anchorOffsetTop - headerHeight,
})
/*
* `deferParents` is `true` on a cold load, and always reset to
* `true` when params change via `prepareForParamsUpdate`.
*
* On a cold load or a push to a new post, on the first pass of this
* logic, the anchor post is the first item in the list. Therefore
* `anchorOffsetTop - headerHeight` will be 0.
*
* When a user changes thread params, on the first pass of this logic,
* the anchor post may not move (if there are no parents above it), or it
* may have gone off the screen above, because of the sudden lack of
* parents due to `deferParents === true`. This negative value (minus
* `headerHeight`) will result in a _negative_ `offset` value, which will
* scroll the anchor post _down_ to the top of the screen.
*
* However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user
* changes params, the anchor post's offset will actually be equivalent
* to the `headerHeight` because of how the DOM is stacked on web.
* Therefore, `anchorOffsetTop - headerHeight` will once again be 0,
* which means the first pass in this case will result in no scroll.
*
* Then, once parents are prepended, this will fire again. Now, the
* `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
* to ensure this doesn't run again until scroll handling is requested
* again via `shouldScrollToAnchor.current === true` and a params
* change.
*/
if (!deferParents) shouldScrollToAnchor.current = false
}
/*
* After the second pass, `deferParents` will be `false`, and we need
* to ensure this doesn't run again until scroll handling is requested
* again via `shouldHandleScroll.current === true` and a params
* change via `prepareForParamsUpdate`.
*/
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 (
<View
/*
* IMPORTANT: this is a load-bearing key. We want to force
* `onLayout` to fire any time the thread params change so that
* `deferParents` is always reset to `false` once the anchor post is
* rendered.
* IMPORTANT: this is a load-bearing key on all platforms. We
* want to force `onLayout` to fire any time the thread params
* change so that `deferParents` is always reset to `false` once
* the anchor post is rendered.
*
* If we ever add additional thread params to this screen, they
* will need to be added here.
@@ -343,27 +381,40 @@ export function Inner({uri}: {uri: string | undefined}) {
[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(
(sort: string) => {
setDeferParents(true)
shouldScrollToAnchor.current = true
prepareForParamsUpdate()
thread.actions.setSort(sort)
},
[thread, setDeferParents],
[thread, prepareForParamsUpdate],
)
const setViewWrapped = useCallback(
(view: ThreadViewOption) => {
prepareForParamsUpdate()
thread.actions.setView(view)
setDeferParents(true)
shouldScrollToAnchor.current = true
},
[thread, setDeferParents],
[thread, prepareForParamsUpdate],
)
return (
<>
<Layout.Header.Outer headerRef={headerRef}>
<Layout.Header.Outer
headerRef={headerRef}
onLayout={e => {
headerHeight.current = e.nativeEvent.layout.height
}}>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
@@ -391,7 +442,10 @@ export function Inner({uri}: {uri: string | undefined}) {
data={slices}
renderItem={renderItem}
keyExtractor={keyExtractor}
onContentSizeChange={onContentSizeChangeWebOnly}
onContentSizeChange={platform({
web: onContentSizeChangeWebOnly,
default: onContentSizeChangeNativeOnly,
})}
onStartReached={onStartReached}
onEndReached={onEndReached}
onEndReachedThreshold={2}
+7 -10
View File
@@ -56,16 +56,13 @@ export function usePostThread({anchor}: {anchor?: string}) {
enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts,
queryKey: postThreadQueryKey,
async queryFn(ctx) {
const {data} = await wait(
400,
agent.app.bsky.unspecced.getPostThreadV2({
anchor: anchor!,
branchingFactor: view === 'linear' ? 1 : undefined,
below: BELOW,
sort: sort,
prioritizeFollowedUsers: prioritizeFollowedUsers,
}),
)
const {data} = await agent.app.bsky.unspecced.getPostThreadV2({
anchor: anchor!,
branchingFactor: view === 'linear' ? 1 : undefined,
below: BELOW,
sort: sort,
prioritizeFollowedUsers: prioritizeFollowedUsers,
})
/*
* Initialize `ctx.meta` to track if we know we have additional replies