Fix sticky pager jumps (#1825)

* Defer showing pager content until its header settles

* Introduce the concept of headerOnlyHeight

* Keep headerOnlyHeight in state, make headerHeight derived

* Hide content until *both* header (only) and tabbar are measured

* Hide tabbar to read its layout earlier

* Give consistent keys to pages
zio/stable
dan 2023-11-06 22:30:10 +00:00 committed by GitHub
parent 4c00fc576d
commit d715246e26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 48 additions and 28 deletions

View File

@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import {LayoutChangeEvent, StyleSheet} from 'react-native' import {LayoutChangeEvent, StyleSheet, View} from 'react-native'
import Animated, { import Animated, {
Easing, Easing,
useAnimatedReaction, useAnimatedReaction,
@ -28,6 +28,7 @@ export interface PagerWithHeaderProps {
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element) | ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[] items: string[]
isHeaderReady: boolean
renderHeader?: () => JSX.Element renderHeader?: () => JSX.Element
initialPage?: number initialPage?: number
onPageSelected?: (index: number) => void onPageSelected?: (index: number) => void
@ -39,6 +40,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
children, children,
testID, testID,
items, items,
isHeaderReady,
renderHeader, renderHeader,
initialPage, initialPage,
onPageSelected, onPageSelected,
@ -51,15 +53,17 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
const scrollYs = React.useRef<Record<number, number>>({}) const scrollYs = React.useRef<Record<number, number>>({})
const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) const scrollY = useSharedValue(scrollYs.current[currentPage] || 0)
const [tabBarHeight, setTabBarHeight] = React.useState(0) const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerHeight, setHeaderHeight] = React.useState(0) const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const [isScrolledDown, setIsScrolledDown] = React.useState( const [isScrolledDown, setIsScrolledDown] = React.useState(
scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT,
) )
const headerHeight = headerOnlyHeight + tabBarHeight
// react to scroll updates // react to scroll updates
function onScrollUpdate(v: number) { function onScrollUpdate(v: number) {
// track each page's current scroll position // track each page's current scroll position
scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight) scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight)
// update the 'is scrolled down' value // update the 'is scrolled down' value
setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) setIsScrolledDown(v > SCROLLED_DOWN_LIMIT)
} }
@ -75,11 +79,11 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}, },
[setTabBarHeight], [setTabBarHeight],
) )
const onHeaderLayout = React.useCallback( const onHeaderOnlyLayout = React.useCallback(
(evt: LayoutChangeEvent) => { (evt: LayoutChangeEvent) => {
setHeaderHeight(evt.nativeEvent.layout.height) setHeaderOnlyHeight(evt.nativeEvent.layout.height)
}, },
[setHeaderHeight], [setHeaderOnlyHeight],
) )
// render the the header and tab bar // render the the header and tab bar
@ -88,7 +92,7 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
transform: [ transform: [
{ {
translateY: Math.min( translateY: Math.min(
Math.min(scrollY.value, headerHeight - tabBarHeight) * -1, Math.min(scrollY.value, headerOnlyHeight) * -1,
0, 0,
), ),
}, },
@ -100,31 +104,39 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
(props: RenderTabBarFnProps) => { (props: RenderTabBarFnProps) => {
return ( return (
<Animated.View <Animated.View
onLayout={onHeaderLayout}
style={[ style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop, isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform, headerTransform,
]}> ]}>
{renderHeader?.()} <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
<TabBar <View
items={items}
selectedPage={currentPage}
onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected}
onLayout={onTabBarLayout} onLayout={onTabBarLayout}
/> style={{
// Render it immediately to measure it early since its size doesn't depend on the content.
// However, keep it invisible until the header above stabilizes in order to prevent jumps.
opacity: isHeaderReady ? 1 : 0,
pointerEvents: isHeaderReady ? 'auto' : 'none',
}}>
<TabBar
items={items}
selectedPage={currentPage}
onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected}
/>
</View>
</Animated.View> </Animated.View>
) )
}, },
[ [
items, items,
isHeaderReady,
renderHeader, renderHeader,
headerTransform, headerTransform,
currentPage, currentPage,
onCurrentPageSelected, onCurrentPageSelected,
isMobile, isMobile,
onTabBarLayout, onTabBarLayout,
onHeaderLayout, onHeaderOnlyLayout,
], ],
) )
@ -175,11 +187,23 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
tabBarPosition="top"> tabBarPosition="top">
{toArray(children) {toArray(children)
.filter(Boolean) .filter(Boolean)
.map(child => { .map((child, i) => {
if (child) { let output = null
return child(childProps) if (
child != null &&
// Defer showing content until we know it won't jump.
isHeaderReady &&
headerOnlyHeight > 0 &&
tabBarHeight > 0
) {
output = child(childProps)
} }
return null // Pager children must be noncollapsible plain <View>s.
return (
<View key={i} collapsable={false}>
{output}
</View>
)
})} })}
</Pager> </Pager>
) )

View File

@ -14,7 +14,6 @@ export interface TabBarProps {
indicatorColor?: string indicatorColor?: string
onSelect?: (index: number) => void onSelect?: (index: number) => void
onPressSelected?: (index: number) => void onPressSelected?: (index: number) => void
onLayout?: (evt: LayoutChangeEvent) => void
} }
export function TabBar({ export function TabBar({
@ -24,7 +23,6 @@ export function TabBar({
indicatorColor, indicatorColor,
onSelect, onSelect,
onPressSelected, onPressSelected,
onLayout,
}: TabBarProps) { }: TabBarProps) {
const pal = usePalette('default') const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null) const scrollElRef = useRef<ScrollView>(null)
@ -68,7 +66,7 @@ export function TabBar({
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
return ( return (
<View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> <View testID={testID} style={[pal.view, styles.outer]}>
<DraggableScrollView <DraggableScrollView
horizontal={true} horizontal={true}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}

View File

@ -332,11 +332,11 @@ export const ProfileFeedScreenInner = observer(
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
<PagerWithHeader <PagerWithHeader
items={SECTION_TITLES} items={SECTION_TITLES}
isHeaderReady={feedInfo?.hasLoaded ?? false}
renderHeader={renderHeader} renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}> onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => ( {({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection <FeedSection
key="1"
ref={feedSectionRef} ref={feedSectionRef}
feed={feed} feed={feed}
onScroll={onScroll} onScroll={onScroll}
@ -346,7 +346,6 @@ export const ProfileFeedScreenInner = observer(
)} )}
{({onScroll, headerHeight}) => ( {({onScroll, headerHeight}) => (
<ScrollView <ScrollView
key="2"
onScroll={onScroll} onScroll={onScroll}
scrollEventThrottle={1} scrollEventThrottle={1}
contentContainerStyle={{paddingTop: headerHeight}}> contentContainerStyle={{paddingTop: headerHeight}}>

View File

@ -165,11 +165,11 @@ export const ProfileListScreenInner = observer(
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
<PagerWithHeader <PagerWithHeader
items={SECTION_TITLES_CURATE} items={SECTION_TITLES_CURATE}
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader} renderHeader={renderHeader}
onCurrentPageSelected={onCurrentPageSelected}> onCurrentPageSelected={onCurrentPageSelected}>
{({onScroll, headerHeight, isScrolledDown}) => ( {({onScroll, headerHeight, isScrolledDown}) => (
<FeedSection <FeedSection
key="1"
ref={feedSectionRef} ref={feedSectionRef}
feed={feed} feed={feed}
onScroll={onScroll} onScroll={onScroll}
@ -179,7 +179,6 @@ export const ProfileListScreenInner = observer(
)} )}
{({onScroll, headerHeight, isScrolledDown}) => ( {({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection <AboutSection
key="2"
ref={aboutSectionRef} ref={aboutSectionRef}
list={list} list={list}
descriptionRT={list.descriptionRT} descriptionRT={list.descriptionRT}
@ -215,10 +214,10 @@ export const ProfileListScreenInner = observer(
<View style={s.hContentRegion}> <View style={s.hContentRegion}>
<PagerWithHeader <PagerWithHeader
items={SECTION_TITLES_MOD} items={SECTION_TITLES_MOD}
isHeaderReady={list.hasLoaded}
renderHeader={renderHeader}> renderHeader={renderHeader}>
{({onScroll, headerHeight, isScrolledDown}) => ( {({onScroll, headerHeight, isScrolledDown}) => (
<AboutSection <AboutSection
key="2"
list={list} list={list}
descriptionRT={list.descriptionRT} descriptionRT={list.descriptionRT}
creator={list.data ? list.data.creator : undefined} creator={list.data ? list.data.creator : undefined}