fix fabric reanimated perf by introducing derived value

This commit is contained in:
Samuel Newman
2025-12-31 15:59:13 +02:00
parent 8edc42293f
commit f117c1b801
5 changed files with 48 additions and 42 deletions
+2 -2
View File
@@ -376,8 +376,8 @@
"reanimated": { "reanimated": {
"staticFeatureFlags": { "staticFeatureFlags": {
"DISABLE_COMMIT_PAUSING_MECHANISM": true, "DISABLE_COMMIT_PAUSING_MECHANISM": true,
"ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, "ANDROID_SYNCHRONOUSLY_UPDATE_UI_PROPS": false,
"IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": true, "IOS_SYNCHRONOUSLY_UPDATE_UI_PROPS": false,
"USE_SYNCHRONIZABLE_FOR_MUTABLES": true, "USE_SYNCHRONIZABLE_FOR_MUTABLES": true,
"USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS": true, "USE_COMMIT_HOOK_ONLY_FOR_REACT_COMMITS": true,
"FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": true "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": true
@@ -1,6 +1,5 @@
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import Animated, { import Animated, {
Extrapolation,
interpolate, interpolate,
type SharedValue, type SharedValue,
useAnimatedStyle, useAnimatedStyle,
@@ -24,30 +23,28 @@ export function GrowableAvatar({
return <View style={style}>{children}</View> return <View style={style}>{children}</View>
} }
const {scrollY} = pagerContext const {clampedScrollY} = pagerContext
return ( return (
<GrowableAvatarInner scrollY={scrollY} style={style}> <GrowableAvatarInner clampedScrollY={clampedScrollY} style={style}>
{children} {children}
</GrowableAvatarInner> </GrowableAvatarInner>
) )
} }
function GrowableAvatarInner({ function GrowableAvatarInner({
scrollY, clampedScrollY,
children, children,
style, style,
}: { }: {
scrollY: SharedValue<number> clampedScrollY: SharedValue<number>
children: React.ReactNode children: React.ReactNode
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }) {
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
scale: interpolate(scrollY.get(), [-150, 0], [1.2, 1], { scale: interpolate(clampedScrollY.get(), [-150, 0], [1.2, 1]),
extrapolateRight: Extrapolation.CLAMP,
}),
}, },
], ],
})) }))
+19 -22
View File
@@ -44,34 +44,37 @@ export function GrowableBanner({
) )
} }
const {scrollY} = pagerContext const {clampedScrollY} = pagerContext
return ( return (
<GrowableBannerInner scrollY={scrollY} backButton={backButton}> <GrowableBannerInner
clampedScrollY={clampedScrollY}
backButton={backButton}>
{children} {children}
</GrowableBannerInner> </GrowableBannerInner>
) )
} }
function GrowableBannerInner({ function GrowableBannerInner({
scrollY, clampedScrollY,
backButton, backButton,
children, children,
}: { }: {
scrollY: SharedValue<number> clampedScrollY: SharedValue<number>
backButton?: React.ReactNode backButton?: React.ReactNode
children: React.ReactNode children: React.ReactNode
}) { }) {
const {top: topInset} = useSafeAreaInsets() const {top: topInset} = useSafeAreaInsets()
const isFetching = useIsProfileFetching() const isFetching = useIsProfileFetching()
const animateSpinner = useShouldAnimateSpinner({isFetching, scrollY}) const animateSpinner = useShouldAnimateSpinner({
isFetching,
clampedScrollY,
})
const animatedStyle = useAnimatedStyle(() => ({ const animatedStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
scale: interpolate(scrollY.get(), [-150, 0], [2, 1], { scale: interpolate(clampedScrollY.get(), [-150, 0], [2, 1]),
extrapolateRight: Extrapolation.CLAMP,
}),
}, },
], ],
})) }))
@@ -79,7 +82,7 @@ function GrowableBannerInner({
const animatedBlurViewProps = useAnimatedProps(() => { const animatedBlurViewProps = useAnimatedProps(() => {
return { return {
intensity: interpolate( intensity: interpolate(
scrollY.get(), clampedScrollY.get(),
[-300, -65, -15], [-300, -65, -15],
[50, 40, 0], [50, 40, 0],
Extrapolation.CLAMP, Extrapolation.CLAMP,
@@ -88,7 +91,7 @@ function GrowableBannerInner({
}) })
const animatedSpinnerStyle = useAnimatedStyle(() => { const animatedSpinnerStyle = useAnimatedStyle(() => {
const scrollYValue = scrollY.get() const scrollYValue = clampedScrollY.get()
return { return {
display: scrollYValue < 0 ? 'flex' : 'none', display: scrollYValue < 0 ? 'flex' : 'none',
opacity: interpolate( opacity: interpolate(
@@ -98,11 +101,7 @@ function GrowableBannerInner({
Extrapolation.CLAMP, Extrapolation.CLAMP,
), ),
transform: [ transform: [
{ {translateY: interpolate(scrollYValue, [-150, 0], [-75, 0])},
translateY: interpolate(scrollYValue, [-150, 0], [-75, 0], {
extrapolateRight: Extrapolation.CLAMP,
}),
},
{rotate: '90deg'}, {rotate: '90deg'},
], ],
} }
@@ -111,9 +110,7 @@ function GrowableBannerInner({
const animatedBackButtonStyle = useAnimatedStyle(() => ({ const animatedBackButtonStyle = useAnimatedStyle(() => ({
transform: [ transform: [
{ {
translateY: interpolate(scrollY.get(), [-150, 10], [-150, 10], { translateY: Math.max(-150, Math.min(10, clampedScrollY.get())),
extrapolateRight: Extrapolation.CLAMP,
}),
}, },
], ],
})) }))
@@ -172,10 +169,10 @@ function useIsProfileFetching() {
function useShouldAnimateSpinner({ function useShouldAnimateSpinner({
isFetching, isFetching,
scrollY, clampedScrollY,
}: { }: {
isFetching: boolean isFetching: boolean
scrollY: SharedValue<number> clampedScrollY: SharedValue<number>
}) { }) {
const [isOverscrolled, setIsOverscrolled] = useState(false) const [isOverscrolled, setIsOverscrolled] = useState(false)
// HACK: it reports a scroll pos of 0 for a tick when fetching finishes // HACK: it reports a scroll pos of 0 for a tick when fetching finishes
@@ -183,13 +180,13 @@ function useShouldAnimateSpinner({
const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10) const stickyIsOverscrolled = useStickyToggle(isOverscrolled, 10)
useAnimatedReaction( useAnimatedReaction(
() => scrollY.get() < -5, () => clampedScrollY.get() < -5,
(value, prevValue) => { (value, prevValue) => {
if (value !== prevValue) { if (value !== prevValue) {
runOnJS(setIsOverscrolled)(value) runOnJS(setIsOverscrolled)(value)
} }
}, },
[scrollY], [clampedScrollY],
) )
const [isAnimating, setIsAnimating] = useState(isFetching) const [isAnimating, setIsAnimating] = useState(isFetching)
@@ -14,8 +14,8 @@ export function StatusBarShadow() {
const pagerContext = usePagerHeaderContext() const pagerContext = usePagerHeaderContext()
if (isIOS && pagerContext) { if (isIOS && pagerContext) {
const {scrollY} = pagerContext const {clampedScrollY} = pagerContext
return <StatusBarShadowInnner scrollY={scrollY} /> return <StatusBarShadowInnner clampedScrollY={clampedScrollY} />
} }
return ( return (
@@ -30,14 +30,18 @@ export function StatusBarShadow() {
) )
} }
function StatusBarShadowInnner({scrollY}: {scrollY: SharedValue<number>}) { function StatusBarShadowInnner({
clampedScrollY,
}: {
clampedScrollY: SharedValue<number>
}) {
const {top: topInset} = useSafeAreaInsets() const {top: topInset} = useSafeAreaInsets()
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = useAnimatedStyle(() => {
return { return {
transform: [ transform: [
{ {
translateY: Math.min(0, scrollY.get()), translateY: Math.min(0, clampedScrollY.get()),
}, },
], ],
} }
+14 -6
View File
@@ -1,10 +1,11 @@
import React, {useContext} from 'react' import {createContext, useContext, useMemo} from 'react'
import {type SharedValue} from 'react-native-reanimated' import {type SharedValue, useDerivedValue} from 'react-native-reanimated'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
export const PagerHeaderContext = React.createContext<{ export const PagerHeaderContext = createContext<{
scrollY: SharedValue<number> scrollY: SharedValue<number>
clampedScrollY: SharedValue<number>
headerHeight: number headerHeight: number
} | null>(null) } | null>(null)
PagerHeaderContext.displayName = 'PagerHeaderContext' PagerHeaderContext.displayName = 'PagerHeaderContext'
@@ -24,9 +25,16 @@ export function PagerHeaderProvider({
headerHeight: number headerHeight: number
children: React.ReactNode children: React.ReactNode
}) { }) {
const value = React.useMemo( // bit of a hackfix - most of the usage of scrollY here is for the pull-to-refresh
() => ({scrollY, headerHeight}), // behaviour in the header, and after scrolling it's not needed anymore.
[scrollY, headerHeight], // to improve performance, we create a derived value that clamps up-front,
// therefore removing the need for the downstream animated styles to run on every frame
// when they won't actually be used -sfn
const clampedScrollY = useDerivedValue(() => Math.min(100, scrollY.get()))
const value = useMemo(
() => ({scrollY, clampedScrollY, headerHeight}),
[scrollY, headerHeight, clampedScrollY],
) )
return ( return (
<PagerHeaderContext.Provider value={value}> <PagerHeaderContext.Provider value={value}>