Ok I think scrolling is solid
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user