[new arch] Fix profile perf (#8320)

* change approach for keeping scrollviews synced

* rm forwardRef from web too

---------

Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Samuel Newman
2025-05-02 16:45:25 +03:00
parent 5fa61c9b27
commit 512951a480
3 changed files with 301 additions and 285 deletions
+115 -107
View File
@@ -1,16 +1,22 @@
import React, {forwardRef, useCallback, useContext} from 'react'
import {
useCallback,
useContext,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import PagerView, {
PagerViewOnPageScrollEventData,
PagerViewOnPageSelectedEvent,
PagerViewOnPageSelectedEventData,
PageScrollStateChangedNativeEventData,
type PagerViewOnPageScrollEventData,
type PagerViewOnPageSelectedEvent,
type PagerViewOnPageSelectedEventData,
type PageScrollStateChangedNativeEventData,
} from 'react-native-pager-view'
import Animated, {
runOnJS,
SharedValue,
type SharedValue,
useEvent,
useHandler,
useSharedValue,
@@ -36,8 +42,12 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
// tab pressed, yet to scroll to page
onTabPressed?: (index: number) => void
// scroll settled
onPageSelected?: (index: number) => void
onPageScrollStateChanged?: (
scrollState: 'idle' | 'dragging' | 'settling',
@@ -47,114 +57,112 @@ interface Props {
const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)
export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
function PagerImpl(
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected: parentOnPageSelected,
onTabPressed: parentOnTabPressed,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
testID,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const pagerView = useRef<PagerView>(null)
const [isIdle, setIsIdle] = useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
},
}))
const onPageSelectedJSThread = useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = useCallback(
(index: number) => {
parentOnTabPressed?.(index)
pagerView.current?.setPage(index)
},
[pagerView, parentOnTabPressed],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
children,
initialPage = 0,
renderTabBar,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
onPageSelected: parentOnPageSelected,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const pagerView = React.useRef<PagerView>(null)
const [isIdle, setIsIdle] = React.useState(true)
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
useFocusEffect(
useCallback(() => {
const canSwipeDrawer = selectedPage === 0 && isIdle
setDrawerSwipeDisabled(!canSwipeDrawer)
return () => {
setDrawerSwipeDisabled(false)
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
}, [setDrawerSwipeDisabled, selectedPage, isIdle]),
)
React.useImperativeHandle(ref, () => ({
setPage: (index: number) => {
pagerView.current?.setPage(index)
dragProgress.set(e.offset + e.position)
},
}))
const onPageSelectedJSThread = React.useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
[setSelectedPage, parentOnPageSelected],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
[pagerView],
)
},
[parentOnPageScrollStateChanged],
)
const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
dragProgress.set(e.offset + e.position)
},
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
runOnJS(setIsIdle)(e.pageScrollState === 'idle')
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
},
[parentOnPageScrollStateChanged],
)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
const nativeGesture =
Gesture.Native().requireExternalGestureToFail(drawerGesture)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
},
)
return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<GestureDetector gesture={nativeGesture}>
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScroll={handlePageScroll}>
{children}
</AnimatedPagerView>
</GestureDetector>
</View>
)
}
function usePagerHandlers(
handlers: {
+31 -18
View File
@@ -1,8 +1,19 @@
import React from 'react'
import {
Children,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react'
import {View} from 'react-native'
import {flushSync} from 'react-dom'
import {s} from '#/lib/styles'
import {atoms as a} from '#/alf'
export interface PagerRef {
setPage: (index: number) => void
}
export interface RenderTabBarFnProps {
selectedPage: number
@@ -12,30 +23,30 @@ export interface RenderTabBarFnProps {
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
interface Props {
ref?: React.Ref<PagerRef>
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
}
export const Pager = React.forwardRef(function PagerImpl(
{
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const scrollYs = React.useRef<Array<number | null>>([])
const anchorRef = React.useRef(null)
React.useImperativeHandle(ref, () => ({
export function Pager({
ref,
children,
initialPage = 0,
renderTabBar,
onPageSelected,
}: React.PropsWithChildren<Props>) {
const [selectedPage, setSelectedPage] = useState(initialPage)
const scrollYs = useRef<Array<number | null>>([])
const anchorRef = useRef(null)
useImperativeHandle(ref, () => ({
setPage: (index: number) => {
onTabBarSelect(index)
},
}))
const onTabBarSelect = React.useCallback(
const onTabBarSelect = useCallback(
(index: number) => {
const scrollY = window.scrollY
// We want to determine if the tabbar is already "sticking" at the top (in which
@@ -75,11 +86,13 @@ export const Pager = React.forwardRef(function PagerImpl(
tabBarAnchor: <View ref={anchorRef} />,
onSelect: e => onTabBarSelect(e),
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
{Children.map(children, (child, i) => (
<View
style={selectedPage === i ? a.flex_1 : a.hidden}
key={`page-${i}`}>
{child}
</View>
))}
</View>
)
})
}
+155 -160
View File
@@ -1,17 +1,16 @@
import * as React from 'react'
import {memo, useCallback, useEffect, useRef, useState} from 'react'
import {
LayoutChangeEvent,
NativeScrollEvent,
ScrollView,
type LayoutChangeEvent,
type NativeScrollEvent,
type ScrollView,
StyleSheet,
View,
} from 'react-native'
import Animated, {
AnimatedRef,
runOnJS,
type AnimatedRef,
runOnUI,
scrollTo,
SharedValue,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
@@ -20,9 +19,13 @@ import Animated, {
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {ScrollProvider} from '#/lib/ScrollContext'
import {isIOS} from '#/platform/detection'
import {Pager, PagerRef, RenderTabBarFnProps} from '#/view/com/pager/Pager'
import {
Pager,
type PagerRef,
type RenderTabBarFnProps,
} from '#/view/com/pager/Pager'
import {useTheme} from '#/alf'
import {ListMethods} from '../util/List'
import {type ListMethods} from '../util/List'
import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
@@ -33,6 +36,7 @@ export interface PagerWithHeaderChildParams {
}
export interface PagerWithHeaderProps {
ref?: React.Ref<PagerRef>
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
@@ -49,97 +53,94 @@ export interface PagerWithHeaderProps {
onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
}
export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
function PageWithHeaderImpl(
{
children,
testID,
export function PagerWithHeader({
ref,
children,
testID,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
allowHeaderOverScroll,
}: PagerWithHeaderProps) {
const [currentPage, setCurrentPage] = useState(0)
const [tabBarHeight, setTabBarHeight] = useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const renderTabBar = useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider scrollY={scrollY} headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
}: PagerWithHeaderProps,
ref,
) {
const [currentPage, setCurrentPage] = React.useState(0)
const [tabBarHeight, setTabBarHeight] = React.useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = React.useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
],
)
// capture the header bar sizing
const onTabBarLayout = useNonReactiveCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
})
const onHeaderOnlyLayout = useNonReactiveCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
})
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const renderTabBar = React.useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider
scrollY={scrollY}
headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
],
)
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = React.useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = () => {
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = useCallback(
(scrollState: 'idle' | 'dragging' | 'settling') => {
'worklet'
if (scrollState !== 'dragging') return
const currentScrollY = scrollY.get()
const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
if (lastForcedScrollY.get() !== forcedScrollY) {
@@ -152,75 +153,69 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
}
}
}
}
},
[currentPage, headerOnlyHeight, lastForcedScrollY, scrollRefs, scrollY],
)
const throttleTimeout = React.useRef<ReturnType<typeof setTimeout> | null>(
null,
)
const queueThrottledOnScroll = useNonReactiveCallback(() => {
if (!throttleTimeout.current) {
throttleTimeout.current = setTimeout(() => {
throttleTimeout.current = null
runOnUI(adjustScrollForOtherPages)()
}, 80 /* Sync often enough you're unlikely to catch it unsynced */)
const onScrollWorklet = useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
}
})
},
[scrollY, headerHeight],
)
const onScrollWorklet = React.useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
// HACK: onScroll is reporting some strange values on load (negative header height).
// Highly improbable that you'd be overscrolled by over 400px -
// in fact, I actually can't do it, so let's just ignore those. -sfn
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (!isPossiblyInvalid) {
scrollY.set(nextScrollY)
runOnJS(queueThrottledOnScroll)()
}
},
[scrollY, queueThrottledOnScroll, headerHeight],
)
const onPageSelectedInner = useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onPageSelectedInner = React.useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onTabPressed = useCallback(() => {
runOnUI(adjustScrollForOtherPages)('dragging')
}, [adjustScrollForOtherPages])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
},
)
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onTabPressed={onTabPressed}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}
onPageScrollStateChanged={adjustScrollForOtherPages}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
}
let PagerTabBar = ({
currentPage,
@@ -258,7 +253,7 @@ let PagerTabBar = ({
dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}): React.ReactNode => {
const t = useTheme()
const [minimumHeaderHeight, setMinimumHeaderHeight] = React.useState(0)
const [minimumHeaderHeight, setMinimumHeaderHeight] = useState(0)
const headerTransform = useAnimatedStyle(() => {
const translateY =
Math.min(
@@ -275,7 +270,7 @@ let PagerTabBar = ({
],
}
})
const headerRef = React.useRef(null)
const headerRef = useRef(null)
return (
<Animated.View
pointerEvents={isIOS ? 'auto' : 'box-none'}
@@ -327,7 +322,7 @@ let PagerTabBar = ({
</Animated.View>
)
}
PagerTabBar = React.memo(PagerTabBar)
PagerTabBar = memo(PagerTabBar)
function PagerItem({
headerHeight,
@@ -348,7 +343,7 @@ function PagerItem({
}) {
const scrollElRef = useAnimatedRef()
React.useEffect(() => {
useEffect(() => {
registerRef(scrollElRef, index)
return () => {
registerRef(null, index)