Optimize pager rendering (#2055)
* Pull out and memoize PagerTabBar * Avoid invalidating onScroll and add throttling * Make isScrolledDown update non-blocking * Fix typeszio/stable
parent
23ad3ad98b
commit
9fa90bb8d9
|
@ -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,
|
items={items}
|
||||||
headerTransform,
|
isHeaderReady={isHeaderReady}
|
||||||
]}>
|
renderHeader={renderHeader}
|
||||||
<View onLayout={onHeaderOnlyLayout}>{renderHeader?.()}</View>
|
currentPage={currentPage}
|
||||||
<View
|
onCurrentPageSelected={onCurrentPageSelected}
|
||||||
onLayout={onTabBarLayout}
|
onTabBarLayout={onTabBarLayout}
|
||||||
style={{
|
onHeaderOnlyLayout={onHeaderOnlyLayout}
|
||||||
// Render it immediately to measure it early since its size doesn't depend on the content.
|
onSelect={props.onSelect}
|
||||||
// However, keep it invisible until the header above stabilizes in order to prevent jumps.
|
scrollY={scrollY}
|
||||||
opacity: isHeaderReady ? 1 : 0,
|
testID={testID}
|
||||||
pointerEvents: isHeaderReady ? 'auto' : 'none',
|
/>
|
||||||
}}>
|
|
||||||
<TabBar
|
|
||||||
testID={testID}
|
|
||||||
items={items}
|
|
||||||
selectedPage={currentPage}
|
|
||||||
onSelect={props.onSelect}
|
|
||||||
onPressSelected={onCurrentPageSelected}
|
|
||||||
/>
|
|
||||||
</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 adjustScrollForOtherPages = () => {
|
||||||
|
'worklet'
|
||||||
|
const currentScrollY = scrollY.value
|
||||||
|
const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
|
||||||
|
if (lastForcedScrollY.value !== forcedScrollY) {
|
||||||
|
lastForcedScrollY.value = forcedScrollY
|
||||||
|
const refs = scrollRefs.value
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
if (i !== currentPage) {
|
||||||
|
// This needs to run on the UI thread.
|
||||||
|
scrollTo(refs[i], 0, forcedScrollY, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
const onScrollWorklet = React.useCallback(
|
||||||
(e: NativeScrollEvent) => {
|
(e: NativeScrollEvent) => {
|
||||||
'worklet'
|
'worklet'
|
||||||
const nextScrollY = e.contentOffset.y
|
const nextScrollY = e.contentOffset.y
|
||||||
scrollY.value = nextScrollY
|
scrollY.value = nextScrollY
|
||||||
|
runOnJS(queueThrottledOnScroll)()
|
||||||
const forcedScrollY = Math.min(nextScrollY, headerOnlyHeight)
|
|
||||||
if (lastForcedScrollY.value !== forcedScrollY) {
|
|
||||||
lastForcedScrollY.value = forcedScrollY
|
|
||||||
const refs = scrollRefs.value
|
|
||||||
for (let i = 0; i < refs.length; i++) {
|
|
||||||
if (i !== currentPage) {
|
|
||||||
scrollTo(refs[i], 0, forcedScrollY, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextIsScrolledDown = nextScrollY > SCROLLED_DOWN_LIMIT
|
|
||||||
if (isScrolledDown !== nextIsScrolledDown) {
|
|
||||||
runOnJS(setIsScrolledDown)(nextIsScrolledDown)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[
|
[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,
|
||||||
|
|
Loading…
Reference in New Issue