From d715246e26728ef55408deeb09a3721e39c5031e Mon Sep 17 00:00:00 2001 From: dan Date: Mon, 6 Nov 2023 22:30:10 +0000 Subject: [PATCH] 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 --- src/view/com/pager/PagerWithHeader.tsx | 64 ++++++++++++++++++-------- src/view/com/pager/TabBar.tsx | 4 +- src/view/screens/ProfileFeed.tsx | 3 +- src/view/screens/ProfileList.tsx | 5 +- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx index 3cdd3ab2ea..842a4574ed 100644 --- a/src/view/com/pager/PagerWithHeader.tsx +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import {LayoutChangeEvent, StyleSheet} from 'react-native' +import {LayoutChangeEvent, StyleSheet, View} from 'react-native' import Animated, { Easing, useAnimatedReaction, @@ -28,6 +28,7 @@ export interface PagerWithHeaderProps { | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] | ((props: PagerWithHeaderChildParams) => JSX.Element) items: string[] + isHeaderReady: boolean renderHeader?: () => JSX.Element initialPage?: number onPageSelected?: (index: number) => void @@ -39,6 +40,7 @@ export const PagerWithHeader = React.forwardRef( children, testID, items, + isHeaderReady, renderHeader, initialPage, onPageSelected, @@ -51,15 +53,17 @@ export const PagerWithHeader = React.forwardRef( const scrollYs = React.useRef>({}) const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) const [tabBarHeight, setTabBarHeight] = React.useState(0) - const [headerHeight, setHeaderHeight] = React.useState(0) + const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) const [isScrolledDown, setIsScrolledDown] = React.useState( scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, ) + 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, headerHeight - tabBarHeight) + scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight) // update the 'is scrolled down' value setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) } @@ -75,11 +79,11 @@ export const PagerWithHeader = React.forwardRef( }, [setTabBarHeight], ) - const onHeaderLayout = React.useCallback( + const onHeaderOnlyLayout = React.useCallback( (evt: LayoutChangeEvent) => { - setHeaderHeight(evt.nativeEvent.layout.height) + setHeaderOnlyHeight(evt.nativeEvent.layout.height) }, - [setHeaderHeight], + [setHeaderOnlyHeight], ) // render the the header and tab bar @@ -88,7 +92,7 @@ export const PagerWithHeader = React.forwardRef( transform: [ { translateY: Math.min( - Math.min(scrollY.value, headerHeight - tabBarHeight) * -1, + Math.min(scrollY.value, headerOnlyHeight) * -1, 0, ), }, @@ -100,31 +104,39 @@ export const PagerWithHeader = React.forwardRef( (props: RenderTabBarFnProps) => { return ( - {renderHeader?.()} - {renderHeader?.()} + + 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', + }}> + + ) }, [ items, + isHeaderReady, renderHeader, headerTransform, currentPage, onCurrentPageSelected, isMobile, onTabBarLayout, - onHeaderLayout, + onHeaderOnlyLayout, ], ) @@ -175,11 +187,23 @@ export const PagerWithHeader = React.forwardRef( tabBarPosition="top"> {toArray(children) .filter(Boolean) - .map(child => { - if (child) { - return child(childProps) + .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(childProps) } - return null + // Pager children must be noncollapsible plain s. + return ( + + {output} + + ) })} ) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 662d736684..0e08b22d8a 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -14,7 +14,6 @@ export interface TabBarProps { indicatorColor?: string onSelect?: (index: number) => void onPressSelected?: (index: number) => void - onLayout?: (evt: LayoutChangeEvent) => void } export function TabBar({ @@ -24,7 +23,6 @@ export function TabBar({ indicatorColor, onSelect, onPressSelected, - onLayout, }: TabBarProps) { const pal = usePalette('default') const scrollElRef = useRef(null) @@ -68,7 +66,7 @@ export function TabBar({ const styles = isDesktop || isTablet ? desktopStyles : mobileStyles return ( - + {({onScroll, headerHeight, isScrolledDown}) => ( ( diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index cfe9c41825..624bea027d 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -165,11 +165,11 @@ export const ProfileListScreenInner = observer( {({onScroll, headerHeight, isScrolledDown}) => ( ( {({onScroll, headerHeight, isScrolledDown}) => (