Scroll sync in the pager without jumps (#1863)

zio/stable
dan 2023-11-10 19:54:33 +00:00 committed by GitHub
parent 65def37165
commit 91f8a23fbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 87 deletions

View File

@ -1,6 +1,7 @@
import React, {MutableRefObject} from 'react'
import {
ActivityIndicator,
Dimensions,
RefreshControl,
StyleProp,
View,
@ -18,7 +19,6 @@ import {ListModel} from 'state/models/content/list'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {s} from 'lib/styles'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
@ -226,7 +226,9 @@ export const ListItems = observer(function ListItemsImpl({
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
contentContainerStyle={{
paddingBottom: Dimensions.get('window').height - headerOffset,
}}
style={{paddingTop: headerOffset}}
onScroll={scrollHandler}
onEndReached={onEndReached}

View File

@ -49,7 +49,18 @@ export const Pager = React.forwardRef(function PagerImpl(
onSelect: onTabBarSelect,
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
<View
style={
selectedPage === i
? s.flex1
: {
position: 'absolute',
pointerEvents: 'none',
// @ts-ignore web-only
visibility: 'hidden',
}
}
key={`page-${i}`}>
{child}
</View>
))}

View File

@ -1,17 +1,19 @@
import * as React from 'react'
import {
LayoutChangeEvent,
NativeScrollEvent,
FlatList,
ScrollView,
StyleSheet,
View,
NativeScrollEvent,
} from 'react-native'
import Animated, {
Easing,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
runOnJS,
scrollTo,
useAnimatedRef,
AnimatedRef,
} from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar'
@ -24,6 +26,7 @@ interface PagerWithHeaderChildParams {
headerHeight: number
onScroll: OnScrollHandler
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
}
export interface PagerWithHeaderProps {
@ -54,28 +57,12 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
) {
const {isMobile} = useWebMediaQueries()
const [currentPage, setCurrentPage] = React.useState(0)
const scrollYs = React.useRef<Record<number, number>>({})
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState(
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
)
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
// react to scroll updates
function onScrollUpdate(v: number) {
// track each page's current scroll position
scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight)
// update the 'is scrolled down' value
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
}
useAnimatedReaction(
() => scrollY.value,
v => runOnJS(onScrollUpdate)(v),
)
// capture the header bar sizing
const onTabBarLayout = React.useCallback(
(evt: LayoutChangeEvent) => {
@ -91,19 +78,17 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
)
// render the the header and tab bar
const headerTransform = useAnimatedStyle(
() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerOnlyHeight) * -1,
0,
),
},
],
}),
[scrollY, headerHeight, tabBarHeight],
)
const headerTransform = useAnimatedStyle(() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerOnlyHeight) * -1,
0,
),
},
],
}))
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
@ -144,12 +129,38 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
],
)
// props to pass into children render functions
function onScrollWorklet(e: NativeScrollEvent) {
'worklet'
scrollY.value = e.contentOffset.y
const scrollRefs = useSharedValue<AnimatedRef<any>[]>([])
const registerRef = (scrollRef: AnimatedRef<any>, index: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[index] = scrollRef
return refs
})
}
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
scrollY.value = nextScrollY
if (nextScrollY < headerOnlyHeight) {
const refs = scrollRefs.value
for (let i = 0; i < refs.length; i++) {
if (i !== currentPage) {
scrollTo(refs[i], 0, nextScrollY, false)
}
}
}
const nextIsScrolledDown = nextScrollY > SCROLLED_DOWN_LIMIT
if (isScrolledDown !== nextIsScrolledDown) {
runOnJS(setIsScrolledDown)(nextIsScrolledDown)
}
},
[currentPage, headerOnlyHeight, isScrolledDown, scrollRefs, scrollY],
)
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
@ -158,19 +169,9 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback(
(index: number) => {
setCurrentPage(index)
if (scrollY.value > headerHeight) {
scrollY.value = headerHeight
}
scrollY.value = withTiming(scrollYs.current[index] || 0, {
duration: 170,
easing: Easing.inOut(Easing.quad),
})
},
[scrollY, setCurrentPage, scrollYs, headerHeight],
)
const onPageSelecting = React.useCallback((index: number) => {
setCurrentPage(index)
}, [])
return (
<Pager
@ -184,26 +185,18 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
{toArray(children)
.filter(Boolean)
.map((child, i) => {
let output = null
if (
child != null &&
// Defer showing content until we know it won't jump.
isHeaderReady &&
headerOnlyHeight > 0 &&
tabBarHeight > 0
) {
output = child({
headerHeight,
isScrolledDown,
onScroll: {
onScroll: i === currentPage ? onScrollWorklet : noop,
},
})
}
// Pager children must be noncollapsible plain <View>s.
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
{output}
<PagerItem
headerHeight={headerHeight}
isReady={isReady}
isScrolledDown={isScrolledDown}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={(r: AnimatedRef<any>) => registerRef(r, i)}
renderTab={child}
/>
</View>
)
})}
@ -212,6 +205,43 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
},
)
function PagerItem({
headerHeight,
isReady,
isScrolledDown,
onScrollWorklet,
renderTab,
registerRef,
}: {
headerHeight: number
isReady: boolean
isScrolledDown: boolean
registerRef: (scrollRef: AnimatedRef<any>) => void
onScrollWorklet: (e: NativeScrollEvent) => void
renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
}) {
const scrollElRef = useAnimatedRef()
registerRef(scrollElRef)
const scrollHandler = React.useMemo(
() => ({onScroll: onScrollWorklet}),
[onScrollWorklet],
)
if (!isReady || renderTab == null) {
return null
}
return renderTab({
headerHeight,
isScrolledDown,
onScroll: scrollHandler,
scrollElRef: scrollElRef as React.MutableRefObject<
FlatList<any> | ScrollView | null
>,
})
}
const styles = StyleSheet.create({
tabBarMobile: {
position: 'absolute',

View File

@ -2,6 +2,7 @@ import React, {MutableRefObject} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
Dimensions,
RefreshControl,
StyleProp,
StyleSheet,
@ -15,7 +16,6 @@ import {PostsFeedModel} from 'state/models/feeds/posts'
import {FeedSlice} from './FeedSlice'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {s} from 'lib/styles'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
@ -178,7 +178,9 @@ export const Feed = observer(function Feed({
progressViewOffset={headerOffset}
/>
}
contentContainerStyle={s.contentContainer}
contentContainerStyle={{
paddingBottom: Dimensions.get('window').height - headerOffset,
}}
style={{paddingTop: headerOffset}}
onScroll={onScroll != null ? scrollHandler : undefined}
scrollEventThrottle={scrollEventThrottle}

View File

@ -1,5 +1,11 @@
import React, {useMemo, useCallback} from 'react'
import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native'
import {
Dimensions,
StyleSheet,
View,
ActivityIndicator,
FlatList,
} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useNavigation} from '@react-navigation/native'
import {usePalette} from 'lib/hooks/usePalette'
@ -343,16 +349,19 @@ export const ProfileFeedScreenInner = observer(
isHeaderReady={feedInfo?.hasLoaded ?? false}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
/>
)}
{({onScroll, headerHeight}) => (
{({onScroll, headerHeight, scrollElRef}) => (
<AboutSection
feedOwnerDid={feedOwnerDid}
feedRkey={rkey}
@ -360,6 +369,9 @@ export const ProfileFeedScreenInner = observer(
headerHeight={headerHeight}
onToggleLiked={onToggleLiked}
onScroll={onScroll}
scrollElRef={
scrollElRef as React.MutableRefObject<ScrollView | null>
}
/>
)}
</PagerWithHeader>
@ -387,14 +399,14 @@ interface FeedSectionProps {
onScroll: OnScrollHandler
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
{feed, onScroll, headerHeight, isScrolledDown, scrollElRef},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@ -438,6 +450,7 @@ const AboutSection = observer(function AboutPageImpl({
headerHeight,
onToggleLiked,
onScroll,
scrollElRef,
}: {
feedOwnerDid: string
feedRkey: string
@ -445,6 +458,7 @@ const AboutSection = observer(function AboutPageImpl({
headerHeight: number
onToggleLiked: () => void
onScroll: OnScrollHandler
scrollElRef: React.MutableRefObject<ScrollView | null>
}) {
const pal = usePalette('default')
const {_} = useLingui()
@ -456,8 +470,12 @@ const AboutSection = observer(function AboutPageImpl({
return (
<ScrollView
ref={scrollElRef}
scrollEventThrottle={1}
contentContainerStyle={{paddingTop: headerHeight}}
contentContainerStyle={{
paddingTop: headerHeight,
paddingBottom: Dimensions.get('window').height - headerHeight,
}}
onScroll={scrollHandler}>
<View
style={[

View File

@ -175,18 +175,24 @@ export const ProfileListScreenInner = observer(
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<FeedSection
ref={feedSectionRef}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
feed={feed}
onScroll={onScroll}
headerHeight={headerHeight}
isScrolledDown={isScrolledDown}
/>
)}
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<AboutSection
ref={aboutSectionRef}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
list={list}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
@ -223,9 +229,12 @@ export const ProfileListScreenInner = observer(
items={SECTION_TITLES_MOD}
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader}>
{({onScroll, headerHeight, isScrolledDown}) => (
{({onScroll, headerHeight, isScrolledDown, scrollElRef}) => (
<AboutSection
list={list}
scrollElRef={
scrollElRef as React.MutableRefObject<FlatList<any> | null>
}
descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined}
isCurateList={list.isCuratelist}
@ -557,14 +566,14 @@ interface FeedSectionProps {
onScroll: OnScrollHandler
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
function FeedSectionImpl(
{feed, onScroll, headerHeight, isScrolledDown},
{feed, scrollElRef, onScroll, headerHeight, isScrolledDown},
ref,
) {
const hasNew = feed.hasNewLatest && !feed.isRefreshing
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})
@ -611,6 +620,7 @@ interface AboutSectionProps {
onScroll: OnScrollHandler
headerHeight: number
isScrolledDown: boolean
scrollElRef: React.MutableRefObject<FlatList<any> | null>
}
const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
function AboutSectionImpl(
@ -624,13 +634,13 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>(
onScroll,
headerHeight,
isScrolledDown,
scrollElRef,
},
ref,
) {
const pal = usePalette('default')
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const scrollElRef = React.useRef<FlatList>(null)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({offset: -headerHeight})