Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Web] Avoid react updates on each animation frame #749

Open
kacper-mikolajczak opened this issue Dec 22, 2023 · 1 comment
Open

[Web] Avoid react updates on each animation frame #749

kacper-mikolajczak opened this issue Dec 22, 2023 · 1 comment

Comments

@kacper-mikolajczak
Copy link

Intro

Hi folks! 👋

In this issue, I want to discuss with you how Animated module handles animations on web and potential improvement of it.

Problem

When I was debugging one of the react-navigation navigators animations, it turned out that during screen transitions there are many React's commits triggered that looks like purely related to the animation itself - the number of commits grew in relation to display refresh rate and length of animation.

Here are the results of animating the opacity of a View in a simple demo:

Profiler trace of Animated
Source code of above example
import { useRef } from "react";
import { Animated } from "react-native";
import { Button, baseStyle } from "./utils";

export default function AnimatedExample() {
  const opacity = useRef(new Animated.Value(1)).current;

  const handlePress = () => {
    Animated.timing(opacity, {
      // Just for simplicity
      toValue: opacity.__getValue() === 1 ? 0.2 : 1,
      duration: 1000,
      useNativeDriver: false,
    }).start();
  };

  const animatedStyle = {
    opacity,
  };

  return (
    <>
      <Animated.View style={[baseStyle, animatedStyle]} />
      <Button title={"Animate me, Animated!"} onPress={handlePress} />
    </>
  );
}

Question is, how would that impact things in real world app scenario, where there might be some heavy, not properly memoized components?

Analysis

createAnimatedComponent
When animating things, we need to use an animated component created via createAnimatedComponent. It is a higher order function which receives a component and wraps it into animation-aware updating logic. In order to update Component's animation, createAnimatedComponent uses props returned by useAnimatedProps hook. Those props are merged into Component styles after every animation tick.

useAnimatedProps
To update the styles, the useAnimatedProps forces change of internal dummy React's state by calling scheduleUpdate. This is the place where the bulk of commits is coming from.

Potential solution

By looking at the implementation of other popular animation libraries, we can see they are purposely trying to avoid such behaviour, making updates "outside of react".

Reanimated

For example, here are the results of simple demo mentioned above for react-native-reanimated. The two visible commits are ones coming from TouchableOpacity, so there is effectively no commits related to opacity animation:

reanimated
Source code of above example
import Animated, {
  useSharedValue,
  withTiming,
  useAnimatedStyle,
} from "react-native-reanimated";
import { Button, baseStyle } from "./utils";

export default function Reanimated() {
  const opacity = useSharedValue(1);

  const animatedStyle = useAnimatedStyle(() => {
    return {
      opacity: opacity.value,
    };
  });

  const handlePress = () => {
    opacity.value = withTiming(opacity.value === 1 ? 0.2 : 1, {
      duration: 1000,
    });
  };

  return (
    <>
      <Animated.View style={[baseStyle, animatedStyle]} />
      <Button title={"Animate me, Reanimated!"} onPress={handlePress} />
    </>
  );
}

React Spring

Similar thing happens in react-spring. Example is taken from web version of the library, but the notion is the same. Here is a recording that shows no actual commits as we are using native button as well:

spring.mp4
Source code of above example
import { useSpring, animated } from "react-spring";
import { Button, baseStyle } from "./utils.js";

export default function SpringApp() {
  const [styles, set] = useSpring(() => ({ opacity: 1 }));

  const handlePress = () => {
    set({
      opacity: styles.opacity.get() === 1 ? 0.2 : 1,
    });
  };

  return (
    <>
      <animated.div style={{ ...baseStyle, ...styles }} />
      <Button title="Animate, react-spring!" onPress={handlePress} />
    </>
  );
}

POC

As a POC the pattern that react-spring uses to update the styles during animation was followed. Instead of updating them by forcing the React's state, a callback was passed from createAnimatedComponent to useAnimatedProps. The callback is responsible to directly change the styles of an animated element.

This approach resulted in 0 commits taking place while animating:
animated-after

createAnimatedComponent modifications
export default function createAnimatedComponent<TProps: { ... }, TInstance>(
  Component: React.AbstractComponent<TProps, TInstance>
): React.AbstractComponent<TProps, TInstance> {
  return React.forwardRef((props, forwardedRef) => {
+    const innerRef = React.useRef(null);
+
+    const callback = React.useCallback(({ style }) => {
+      setValueForStyles(innerRef.current, StyleSheet.flatten(style));
+    }, []);

    const [reducedProps, callbackRef] = useAnimatedProps<TProps, TInstance>(
      props,
+      callback
    );
    const ref = useMergeRefs<TInstance | null>(
      callbackRef,
      forwardedRef,
+      innerRef
    );

useAnimatedProps modifications
export default function useAnimatedProps<TProps: { ... }, TInstance>(
  props: TProps,
  callback: ({ style: any }) => void
): [ReducedProps<TProps>, CallbackRef<TInstance | null>] {
- const [, scheduleUpdate] = useReducer(count => count + 1, 0);
  const onUpdateRef = useRef<?() => void>(null);

  const node = useMemo(
    () => new AnimatedProps(props, () => onUpdateRef.current?.()),
    [props]
  );
  useAnimatedPropsLifecycle(node);

  const refEffect = useCallback(
    (instance) => {
      node.setNativeView(instance);

      onUpdateRef.current = () => {
-         scheduleUpdate();
+        callback(reduceAnimatedProps<TProps>(node));
      };

...

-     [props, node]
+    [props, node, callback]

Outro

It is very provisional implementation, which goal is to convey the idea. There are definitely reasons to be sceptic about described approach or blockers that I am not aware of.

With that in mind, I am looking forward for your feedback and insight, thanks a lot! ❤️

@kacper-mikolajczak
Copy link
Author

kacper-mikolajczak commented Jan 2, 2024

As it is not necessarily a bug I posted it here, but maybe main react-native repo would have been a better fit for that issue? Thanks for suggestions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant