Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

dan and committed by
GitHub
d715246e 4c00fc57

+48 -28
+44 -20
src/view/com/pager/PagerWithHeader.tsx
··· 1 1 import * as React from 'react' 2 - import {LayoutChangeEvent, StyleSheet} from 'react-native' 2 + import {LayoutChangeEvent, StyleSheet, View} from 'react-native' 3 3 import Animated, { 4 4 Easing, 5 5 useAnimatedReaction, ··· 28 28 | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] 29 29 | ((props: PagerWithHeaderChildParams) => JSX.Element) 30 30 items: string[] 31 + isHeaderReady: boolean 31 32 renderHeader?: () => JSX.Element 32 33 initialPage?: number 33 34 onPageSelected?: (index: number) => void ··· 39 40 children, 40 41 testID, 41 42 items, 43 + isHeaderReady, 42 44 renderHeader, 43 45 initialPage, 44 46 onPageSelected, ··· 51 53 const scrollYs = React.useRef<Record<number, number>>({}) 52 54 const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) 53 55 const [tabBarHeight, setTabBarHeight] = React.useState(0) 54 - const [headerHeight, setHeaderHeight] = React.useState(0) 56 + const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) 55 57 const [isScrolledDown, setIsScrolledDown] = React.useState( 56 58 scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, 57 59 ) 60 + 61 + const headerHeight = headerOnlyHeight + tabBarHeight 58 62 59 63 // react to scroll updates 60 64 function onScrollUpdate(v: number) { 61 65 // track each page's current scroll position 62 - scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight) 66 + scrollYs.current[currentPage] = Math.min(v, headerOnlyHeight) 63 67 // update the 'is scrolled down' value 64 68 setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) 65 69 } ··· 75 79 }, 76 80 [setTabBarHeight], 77 81 ) 78 - const onHeaderLayout = React.useCallback( 82 + const onHeaderOnlyLayout = React.useCallback( 79 83 (evt: LayoutChangeEvent) => { 80 - setHeaderHeight(evt.nativeEvent.layout.height) 84 + setHeaderOnlyHeight(evt.nativeEvent.layout.height) 81 85 }, 82 - [setHeaderHeight], 86 + [setHeaderOnlyHeight], 83 87 ) 84 88 85 89 // render the the header and tab bar ··· 88 92 transform: [ 89 93 { 90 94 translateY: Math.min( 91 - Math.min(scrollY.value, headerHeight - tabBarHeight) * -1, 95 + Math.min(scrollY.value, headerOnlyHeight) * -1, 92 96 0, 93 97 ), 94 98 }, ··· 100 104 (props: RenderTabBarFnProps) => { 101 105 return ( 102 106 <Animated.View 103 - onLayout={onHeaderLayout} 104 107 style={[ 105 108 isMobile ? styles.tabBarMobile : styles.tabBarDesktop, 106 109 headerTransform, 107 110 ]}> 108 - {renderHeader?.()} 109 - <TabBar 110 - items={items} 111 - selectedPage={currentPage} 112 - onSelect={props.onSelect} 113 - onPressSelected={onCurrentPageSelected} 111 + <View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View> 112 + <View 114 113 onLayout={onTabBarLayout} 115 - /> 114 + style={{ 115 + // Render it immediately to measure it early since its size doesn't depend on the content. 116 + // However, keep it invisible until the header above stabilizes in order to prevent jumps. 117 + opacity: isHeaderReady ? 1 : 0, 118 + pointerEvents: isHeaderReady ? 'auto' : 'none', 119 + }}> 120 + <TabBar 121 + items={items} 122 + selectedPage={currentPage} 123 + onSelect={props.onSelect} 124 + onPressSelected={onCurrentPageSelected} 125 + /> 126 + </View> 116 127 </Animated.View> 117 128 ) 118 129 }, 119 130 [ 120 131 items, 132 + isHeaderReady, 121 133 renderHeader, 122 134 headerTransform, 123 135 currentPage, 124 136 onCurrentPageSelected, 125 137 isMobile, 126 138 onTabBarLayout, 127 - onHeaderLayout, 139 + onHeaderOnlyLayout, 128 140 ], 129 141 ) 130 142 ··· 175 187 tabBarPosition="top"> 176 188 {toArray(children) 177 189 .filter(Boolean) 178 - .map(child => { 179 - if (child) { 180 - return child(childProps) 190 + .map((child, i) => { 191 + let output = null 192 + if ( 193 + child != null && 194 + // Defer showing content until we know it won't jump. 195 + isHeaderReady && 196 + headerOnlyHeight > 0 && 197 + tabBarHeight > 0 198 + ) { 199 + output = child(childProps) 181 200 } 182 - return null 201 + // Pager children must be noncollapsible plain <View>s. 202 + return ( 203 + <View key={i} collapsable={false}> 204 + {output} 205 + </View> 206 + ) 183 207 })} 184 208 </Pager> 185 209 )
+1 -3
src/view/com/pager/TabBar.tsx
··· 14 14 indicatorColor?: string 15 15 onSelect?: (index: number) => void 16 16 onPressSelected?: (index: number) => void 17 - onLayout?: (evt: LayoutChangeEvent) => void 18 17 } 19 18 20 19 export function TabBar({ ··· 24 23 indicatorColor, 25 24 onSelect, 26 25 onPressSelected, 27 - onLayout, 28 26 }: TabBarProps) { 29 27 const pal = usePalette('default') 30 28 const scrollElRef = useRef<ScrollView>(null) ··· 68 66 const styles = isDesktop || isTablet ? desktopStyles : mobileStyles 69 67 70 68 return ( 71 - <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> 69 + <View testID={testID} style={[pal.view, styles.outer]}> 72 70 <DraggableScrollView 73 71 horizontal={true} 74 72 showsHorizontalScrollIndicator={false}
+1 -2
src/view/screens/ProfileFeed.tsx
··· 332 332 <View style={s.hContentRegion}> 333 333 <PagerWithHeader 334 334 items={SECTION_TITLES} 335 + isHeaderReady={feedInfo?.hasLoaded ?? false} 335 336 renderHeader={renderHeader} 336 337 onCurrentPageSelected={onCurrentPageSelected}> 337 338 {({onScroll, headerHeight, isScrolledDown}) => ( 338 339 <FeedSection 339 - key="1" 340 340 ref={feedSectionRef} 341 341 feed={feed} 342 342 onScroll={onScroll} ··· 346 346 )} 347 347 {({onScroll, headerHeight}) => ( 348 348 <ScrollView 349 - key="2" 350 349 onScroll={onScroll} 351 350 scrollEventThrottle={1} 352 351 contentContainerStyle={{paddingTop: headerHeight}}>
+2 -3
src/view/screens/ProfileList.tsx
··· 165 165 <View style={s.hContentRegion}> 166 166 <PagerWithHeader 167 167 items={SECTION_TITLES_CURATE} 168 + isHeaderReady={list.hasLoaded} 168 169 renderHeader={renderHeader} 169 170 onCurrentPageSelected={onCurrentPageSelected}> 170 171 {({onScroll, headerHeight, isScrolledDown}) => ( 171 172 <FeedSection 172 - key="1" 173 173 ref={feedSectionRef} 174 174 feed={feed} 175 175 onScroll={onScroll} ··· 179 179 )} 180 180 {({onScroll, headerHeight, isScrolledDown}) => ( 181 181 <AboutSection 182 - key="2" 183 182 ref={aboutSectionRef} 184 183 list={list} 185 184 descriptionRT={list.descriptionRT} ··· 215 214 <View style={s.hContentRegion}> 216 215 <PagerWithHeader 217 216 items={SECTION_TITLES_MOD} 217 + isHeaderReady={list.hasLoaded} 218 218 renderHeader={renderHeader}> 219 219 {({onScroll, headerHeight, isScrolledDown}) => ( 220 220 <AboutSection 221 - key="2" 222 221 list={list} 223 222 descriptionRT={list.descriptionRT} 224 223 creator={list.data ? list.data.creator : undefined}