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 pageszio/stable
parent
4c00fc576d
commit
d715246e26
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}}>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
Loading…
Reference in New Issue