Huge performance issues when using multiple worklets (`useAnimatedStyle`, `useDerivedValue`)
See original GitHub issueDescription
I’ve created a view which is essentially a horizontal ScrollView that can only be swiped one item at a time. Each item (= category) has a header showing the category’s name. To somehow reveal to the user what the next page/previous page has to offer, I decided to show a tiny bit of the headers flowing into the screen.
Without header parallax | With header parallax |
---|---|
Demo
Without header parallax | With header parallax |
---|---|
While you can’t really see the difference in a compressed, 320 pixel wide, 25 FPS GIF, it is extremely noticeable in the app, since you can swipe/scroll normally without the parallax, and once you enable the header parallax it looks like a powerpoint presentation. I’d say without parallax it’s around 60 FPS, with parallax about 5 FPS.
Attempts to solve
I’ve tried multiple approaches to solve those performance issues, here’s what I tried:
- Checked my re-renders. Maybe something is unnecessarily re-rendering? Nope, everything’s happening on the UI thread.
- Tried to optimize the worklets inside the Header (put all
useDerivedValues
into a singleuseAnimatedStyle
) so that there’s a few less worklets executing at once - didn’t make a difference at all. - I’ve tried (and I’m still using) the
removeClippedSubviews
property on the container to remove all views that are offscreen, so not rendering them if they’re overflowing. That requiresoverflow: 'hidden'
to be set, which I did, so now only everything that’s on screen should be rendered. This doesn’t affect worklets though, since still every singleuseDerivedValue
anduseAnimatedStyle
worklet is executing per frame on drag. For 30 Stories, that’s 1useAnimatedStyle
and 1useAnimatedGestureHandler
for the “list” container, and 30useDerivedValue
s and 30useAnimatedStyle
s for the Headers. Can we ignore them if RCTView removes that view due to clipping? - I tried to unmount everything if it’s offscreen. So only mounting current header, header to the right and header to the left. Weirdly enough, this still had terrible performance. It looked a bit better, but still absolutely unusable, like below 20 FPS.
Steps To Reproduce
- Create horizontal Swiper using
<PanGestureHandler>
and translate those views using thetranslateX
(plus someoffsetX
) - Now pass each item the
translateX
value and it’s index in the Swiper - Each item can now have a custom
useAnimatedStyle
which interpolates the current Swiper’stranslateX
to parallax-like translate the header towards the screen corners so they get revealed.
Expected behavior
I expect it to run smoothly
Actual behavior
It runs terribly stuttery.
Snack or minimal code example
Header.tsx
function StoryHeader({
story,
style: _style,
indexInSwiper,
swiperTranslateX,
isSwipeMode,
onPress,
...passThroughProps
}: StoryHeaderProps): React.ReactElement {
const [width, setWidth] = useState(0);
const onLayout = useCallback(
({
nativeEvent: {
layout: { width: newWidth },
},
}: LayoutChangeEvent) => {
setWidth(newWidth);
},
[setWidth],
);
const thisTranslateX = useMemo(() => -(SCREEN_WIDTH * indexInSwiper), [indexInSwiper]);
const offsetX = useDerivedValue(() => {
if (isSwipeMode) {
return withTiming(width / 2, {
duration: 200,
easing: Easing.back(1),
});
} else {
return withTiming(width / 2 - HEADER_PREVIEW_OVERFLOW, {
duration: 300,
easing: Easing.elastic(1),
});
}
}, [isSwipeMode, width]);
const animatedStyle = useAnimatedStyle(() => {
const translateX = interpolate(
swiperTranslateX.value,
[
thisTranslateX - SCREEN_WIDTH - SCREEN_WIDTH,
thisTranslateX - SCREEN_WIDTH,
thisTranslateX, //
thisTranslateX + SCREEN_WIDTH,
thisTranslateX + SCREEN_WIDTH + SCREEN_WIDTH,
],
[
SCREEN_WIDTH / 2,
SCREEN_WIDTH / 2 - offsetX.value, // Swiper is at Next slide
0, // Swiper is at current slide
-(SCREEN_WIDTH / 2) + offsetX.value, // Swiper is at Previous slide
-(SCREEN_WIDTH / 2),
],
Extrapolate.CLAMP,
);
const isCurrentHeader = between(translateX, -10, 10);
return {
// opacity: withTiming(isSwipeMode ? (isCurrentHeader ? 1 : 0) : 1, { duration: 300 }),
opacity: 1,
transform: [{ translateX: translateX }, { scale: withTiming(isSwipeMode ? (isCurrentHeader ? 1.2 : 1) : 1, { duration: 150 }) }],
};
}, [isSwipeMode, offsetX, swiperTranslateX, thisTranslateX]);
const style = useMemo(() => [_style, { left: SCREEN_WIDTH * indexInSwiper + (SCREEN_WIDTH / 2 - width / 2) }], [_style, indexInSwiper, width]);
return (
// TODO: DEBUG IF renderToHardwareTextureAndroid={true} shouldRasterizeIOS={true} ARE A GOOD IDEA
<Reanimated.View
onLayout={onLayout}
style={[style, animatedStyle]}
renderToHardwareTextureAndroid={true}
shouldRasterizeIOS={true}
{...passThroughProps}>
<StoryHeaderContent story={story} onPress={onPress} />
</Reanimated.View>
);
}
Package versions
- React: 17
- React Native: 0.64.rc1 (but also reproduced on 0.63)
- React Native Reanimated: master (but also reproduced on alpha 9 or 2.0-rc0)
- Engine: Hermes (but also reproduced on JSC)
- NodeJS: v14.15.1
I’m really not sure how I can make this more efficient or if I’m doing something wrong, so any tips would be greatly appreciated!
EDIT
Here’s everything I’ve noted:
- In the header’s
useAnimatedStyle
hook, that gets executed a lot (30 times for 30 different headers per on drag event), usingwithTiming
(orwithSpring
) is a bad idea. Instead, extracting that into auseSharedValue
and imperatively animating that when theisSwipeMode
orisCurrentHeader
props changes works a lot better. I am guessing it just takes a long time to set up thewithTiming
(orwithSpring
) animation, even though the inputs haven’t changed an it already is at the desired position. - Splitting all my dependencies up into
useDerivedValue
s doesn’t seem to really do anything, it feels a tiny bit smoother I guess - Animations are run even if views are removed by RCTView’s performance strategy:
removeClippedSubviews
. (overflow hidden) - I’ve unmounted everything except 3 headers (current, left and right), and it still had bad performance. Even removed all other stuff, e.g. the background you’re seeing in the demo. Now I know that this is a very complex animation, but with REA v1 I just passed different interpolated nodes into this and everything worked fine and smoothly, I assume “everything working as good as REA v1 or even better” is also the goal here.
- If you want to see another demo of this, and even have a REA v1 vs REA v2 comparison, take a look at William’s Jelly Scroll video (https://www.youtube.com/watch?v=Xnj6uoW2PJM). I’ve tried to implement this in REA v2, and it had terrible performance because of the many
useAnimatedStyle
hooks.
Issue Analytics
- State:
- Created 3 years ago
- Reactions:6
- Comments:14 (12 by maintainers)
Top GitHub Comments
Issue validator - update # 3
Hello! Congratulations! Your issue passed the validator! Thank you!
I’ve done some testing and my fix in #1502 seems to reduce 90%+ of the lag. BUT there has to be a proper approach applied. The main idea is to have that one
useDerivedValue
per item in the render loop(Array.map
) which would be some kind of ‘entry point’ for all the other mappers for that item. Then every other worklet there would depend on that value. Something like this(pseudo-code):I wouldn’t treat it as some kind of workaround but rather a good practice. This happens only when there is a lot of mappers spawned which recalculate a lot. Maybe there’s some way we could improve the process more on the reanimated 2 side but keep in mind that the application’s logic matters a lot too.
Below I’m pasting the code that I used for testing/reproducing. It’s a standalone component so just feel free to paste it and launch it.
I’ve run it on iPhone 11 and without the fix from #1502 it would drop to extremely low FPS values(both JS and UI, often <10, sometimes even 0, lol) but with the fix applied it stays at 60FPS(both) and drops to 50+ when there are more components involved(meaning when you swipe so the new ones appear)(also, if you remove
console.log
’s that little drop only appears on UI, on JS it remains at 60). I realize that’s still a frame drop there and I will look deeper, but just wanted to point out a direction for solving the problem(also the calculations in this particular example could be not very precise as I didn’t pay that much attention to this).the code
@mrousavy what do you think? I’m probably going to try to also refactor your code a little bit in such a manner.