Optimize pager rendering (#2055)

* Pull out and memoize PagerTabBar

* Avoid invalidating onScroll and add throttling

* Make isScrolledDown update non-blocking

* Fix types
zio/stable
dan 2023-12-01 02:11:05 +00:00 committed by GitHub
parent 23ad3ad98b
commit 9fa90bb8d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
1 changed files with 117 additions and 62 deletions

View File

@ -11,14 +11,17 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
useSharedValue, useSharedValue,
runOnJS, runOnJS,
runOnUI,
scrollTo, scrollTo,
useAnimatedRef, useAnimatedRef,
AnimatedRef, AnimatedRef,
SharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
import {TabBar} from './TabBar' import {TabBar} from './TabBar'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {OnScrollHandler} from 'lib/hooks/useOnMainScroll' import {OnScrollHandler} from 'lib/hooks/useOnMainScroll'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
const SCROLLED_DOWN_LIMIT = 200 const SCROLLED_DOWN_LIMIT = 200
@ -56,7 +59,6 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}: PagerWithHeaderProps, }: PagerWithHeaderProps,
ref, ref,
) { ) {
const {isMobile} = useWebMediaQueries()
const [currentPage, setCurrentPage] = React.useState(0) const [currentPage, setCurrentPage] = React.useState(0)
const [tabBarHeight, setTabBarHeight] = React.useState(0) const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0) const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
@ -78,56 +80,34 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
[setHeaderOnlyHeight], [setHeaderOnlyHeight],
) )
// render the the header and tab bar
const headerTransform = useAnimatedStyle(() => ({
transform: [
{
translateY: Math.min(
Math.min(scrollY.value, headerOnlyHeight) * -1,
0,
),
},
],
}))
const renderTabBar = React.useCallback( const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => { (props: RenderTabBarFnProps) => {
return ( return (
<Animated.View <PagerTabBar
style={[ headerOnlyHeight={headerOnlyHeight}
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
<View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
<View
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
testID={testID}
items={items} items={items}
selectedPage={currentPage} isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect} onSelect={props.onSelect}
onPressSelected={onCurrentPageSelected} scrollY={scrollY}
testID={testID}
/> />
</View>
</Animated.View>
) )
}, },
[ [
headerOnlyHeight,
items, items,
isHeaderReady, isHeaderReady,
renderHeader, renderHeader,
headerTransform,
currentPage, currentPage,
onCurrentPageSelected, onCurrentPageSelected,
isMobile,
onTabBarLayout, onTabBarLayout,
onHeaderOnlyLayout, onHeaderOnlyLayout,
scrollY,
testID, testID,
], ],
) )
@ -142,36 +122,50 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
} }
const lastForcedScrollY = useSharedValue(0) const lastForcedScrollY = useSharedValue(0)
const onScrollWorklet = React.useCallback( const adjustScrollForOtherPages = () => {
(e: NativeScrollEvent) => {
'worklet' 'worklet'
const nextScrollY = e.contentOffset.y const currentScrollY = scrollY.value
scrollY.value = nextScrollY const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
const forcedScrollY = Math.min(nextScrollY, headerOnlyHeight)
if (lastForcedScrollY.value !== forcedScrollY) { if (lastForcedScrollY.value !== forcedScrollY) {
lastForcedScrollY.value = forcedScrollY lastForcedScrollY.value = forcedScrollY
const refs = scrollRefs.value const refs = scrollRefs.value
for (let i = 0; i < refs.length; i++) { for (let i = 0; i < refs.length; i++) {
if (i !== currentPage) { if (i !== currentPage) {
// This needs to run on the UI thread.
scrollTo(refs[i], 0, forcedScrollY, false) scrollTo(refs[i], 0, forcedScrollY, false)
} }
} }
} }
const nextIsScrolledDown = nextScrollY > SCROLLED_DOWN_LIMIT
if (isScrolledDown !== nextIsScrolledDown) {
runOnJS(setIsScrolledDown)(nextIsScrolledDown)
} }
const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const queueThrottledOnScroll = useNonReactiveCallback(() => {
if (!throttleTimeout.current) {
throttleTimeout.current = setTimeout(() => {
throttleTimeout.current = null
runOnUI(adjustScrollForOtherPages)()
const nextIsScrolledDown = scrollY.value > SCROLLED_DOWN_LIMIT
if (isScrolledDown !== nextIsScrolledDown) {
React.startTransition(() => {
setIsScrolledDown(nextIsScrolledDown)
})
}
}, 80 /* Sync often enough you're unlikely to catch it unsynced */)
}
})
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
scrollY.value = nextScrollY
runOnJS(queueThrottledOnScroll)()
}, },
[ [scrollY, queueThrottledOnScroll],
currentPage,
headerOnlyHeight,
isScrolledDown,
scrollRefs,
scrollY,
lastForcedScrollY,
],
) )
const onPageSelectedInner = React.useCallback( const onPageSelectedInner = React.useCallback(
@ -219,6 +213,67 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}, },
) )
let PagerTabBar = ({
currentPage,
headerOnlyHeight,
isHeaderReady,
items,
scrollY,
testID,
renderHeader,
onHeaderOnlyLayout,
onTabBarLayout,
onCurrentPageSelected,
onSelect,
}: {
currentPage: number
headerOnlyHeight: number
isHeaderReady: boolean
items: string[]
testID?: string
scrollY: SharedValue<number>
renderHeader?: () => JSX.Element
onHeaderOnlyLayout: (e: LayoutChangeEvent) => void
onTabBarLayout: (e: LayoutChangeEvent) => void
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
}): React.ReactNode => {
const {isMobile} = useWebMediaQueries()
const headerTransform = useAnimatedStyle(() => ({
transform: [
{
translateY: Math.min(Math.min(scrollY.value, headerOnlyHeight) * -1, 0),
},
],
}))
return (
<Animated.View
style={[
isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
headerTransform,
]}>
<View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
<View
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
testID={testID}
items={items}
selectedPage={currentPage}
onSelect={onSelect}
onPressSelected={onCurrentPageSelected}
/>
</View>
</Animated.View>
)
}
PagerTabBar = React.memo(PagerTabBar)
function PagerItem({ function PagerItem({
headerHeight, headerHeight,
isReady, isReady,