question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Huge performance issues when using multiple worklets (`useAnimatedStyle`, `useDerivedValue`)

See original GitHub issue

Description

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:

  1. Checked my re-renders. Maybe something is unnecessarily re-rendering? Nope, everything’s happening on the UI thread.
  2. Tried to optimize the worklets inside the Header (put all useDerivedValues into a single useAnimatedStyle) so that there’s a few less worklets executing at once - didn’t make a difference at all.
  3. 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 requires overflow: '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 single useDerivedValue and useAnimatedStyle worklet is executing per frame on drag. For 30 Stories, that’s 1 useAnimatedStyle and 1 useAnimatedGestureHandler for the “list” container, and 30 useDerivedValues and 30 useAnimatedStyles for the Headers. Can we ignore them if RCTView removes that view due to clipping?
  4. 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

  1. Create horizontal Swiper using <PanGestureHandler> and translate those views using the translateX (plus some offsetX)
  2. Now pass each item the translateX value and it’s index in the Swiper
  3. Each item can now have a custom useAnimatedStyle which interpolates the current Swiper’s translateX 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:

  1. In the header’s useAnimatedStyle hook, that gets executed a lot (30 times for 30 different headers per on drag event), using withTiming (or withSpring) is a bad idea. Instead, extracting that into a useSharedValue and imperatively animating that when the isSwipeMode or isCurrentHeader props changes works a lot better. I am guessing it just takes a long time to set up the withTiming (or withSpring) animation, even though the inputs haven’t changed an it already is at the desired position.
  2. Splitting all my dependencies up into useDerivedValues doesn’t seem to really do anything, it feels a tiny bit smoother I guess
  3. Animations are run even if views are removed by RCTView’s performance strategy: removeClippedSubviews. (overflow hidden)
  4. 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.
  5. 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:closed
  • Created 3 years ago
  • Reactions:6
  • Comments:14 (12 by maintainers)

github_iconTop GitHub Comments

6reactions
github-actions[bot]commented, Dec 1, 2020

Issue validator - update # 3

Hello! Congratulations! Your issue passed the validator! Thank you!

4reactions
karol-bisztygacommented, Dec 3, 2020

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):

items.map((item) => {
	const udv = useDerivedValue(() => {
		// ... calculate something
		return ... // e.g. null/undefined when the item wouldn't be visible, original number when it would
	})
	const st1 = useAnimatedStyle(() => {
		return { transform: [{ translateX: udv.value === null ? -100 : udv.value }] }
	})
	const st2 = useAnimatedStyle(() => {
		return { scale: withTiming(udv.value === null ? 1 : 0.7) }
	})
	return <...>
})

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
import Animated, {
  useSharedValue,
  withTiming,
  useAnimatedStyle,
  useDerivedValue,
  useAnimatedGestureHandler,
} from 'react-native-reanimated';
import { View, Dimensions, Text } from 'react-native';
import React from 'react';
import { PanGestureHandler } from 'react-native-gesture-handler';

const WIDTH = Dimensions.get('window').width;

export default function App() {
  const items = Array(50)
    .fill()
    .map((_, i) => i);
  const itemWidth = 150;
  const itemMargin = 10;
  const posX = useSharedValue(0);

  const handler = useAnimatedGestureHandler({
    onActive: (e, ctx) => {
      let newPosX = posX.value;
      newPosX += e.velocityX;
      newPosX = Math.min(newPosX, 0);
      const minval = (-(itemWidth + itemMargin) * items.length + WIDTH) * 50;
      newPosX = Math.max(newPosX, minval);
      console.log('here new pos', newPosX);
      posX.value = newPosX;
    },
  });

  return (
    <View>
      <PanGestureHandler onGestureEvent={handler}>
        <Animated.View
          style={{
            alignSelf: 'stretch',
            height: 300,
            backgroundColor: 'purple',
          }}>
          {items.map((index) => {

            // this determines whether the styles should calculate
            const udv = useDerivedValue(() => {
              console.log('here mapper #1', index);
              const dist = Math.abs((itemWidth + 10) * index + posX.value / 50);
              return (dist > 500) ? null : posX.value ;
            })

            const style = useAnimatedStyle(() => {
              console.log('here mapper #2', index, udv.value);
              const x =
                udv.value === null
                  ? -itemWidth
                  : (itemWidth + itemMargin) * index + udv.value / 50;
              return {
                transform: [
                  {
                    translateX: x,
                  },
                ],
              };
            });

            const style2 = useAnimatedStyle(() => {
              const dist = (udv.value === 2000) ? null : (itemWidth + 10) * index + udv.value / 50;
              console.log('here mapper #3', index, dist);
              return {
                transform: [{ scale: withTiming(dist === null ? 1 : 0.7) }],
              };
            });

            return (
              <Animated.View
                style={[
                  {
                    position: 'absolute',
                    width: 100,
                    height: 250,
                    backgroundColor: 'orange',
                    marginTop: 25,
                  },
                  style,
                ]}>
                <Text style={{ fontSize: 20 }}>{index}</Text>
                <Animated.View
                  style={[
                    {
                      width: itemWidth / 2,
                      height: itemWidth / 2,
                      backgroundColor: 'green',
                    },
                    style2,
                  ]}
                />
              </Animated.View>
            );
          })}
        </Animated.View>
      </PanGestureHandler>
    </View>
  );
}

@mrousavy what do you think? I’m probably going to try to also refactor your code a little bit in such a manner.

Read more comments on GitHub >

github_iconTop Results From Across the Web

useDerivedValue | React Native Reanimated
This hook allows for creating shared value reference that can change in response to updating of one or more other shared values.
Read more >
A First Look at Reanimated 2 — Mobile - Shopify Engineering
The useDerivedValue() hook creates a shared value based on some worklet execution. For instance, in the code snippet below, the theta value is ......
Read more >
New Reanimated V2 shines React-Native animation ⚡️
When trigger state(the JS thread variable) is changed, the nodes in useCode are re-launched. Problems in Reanimated V1. The Reanimated V1 is ...
Read more >
React Native Reanimated 2 - a webinar by Krzysztof Magiera
Following the recent release of the new open-source library - Reanimated 2, we wanted to share with the community more information about ...
Read more >
Animations in React Native: Performance and Reason-about ...
Introducing React Native Reanimated (Version 2) ... In this code snippet, we use useAnimatedStyle to create a style that is dynamic based on ......
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found