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

Avoid react updates on each animation frame #2623

Closed
1 task done
kacper-mikolajczak opened this issue Dec 21, 2023 · 2 comments
Closed
1 task done

Avoid react updates on each animation frame #2623

kacper-mikolajczak opened this issue Dec 21, 2023 · 2 comments
Labels
enhancement Requires extension or creation of new React Native API

Comments

@kacper-mikolajczak
Copy link

Is there an existing request?

  • I have searched for this request

Describe the feature request

Hi folks! 👋

Intro

In this issue, I wanted 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

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

export default function createAnimatedComponent<TProps: {...}, TInstance>(
Component: React.AbstractComponent<TProps, TInstance>,
): React.AbstractComponent<TProps, TInstance> {
return React.forwardRef((props, forwardedRef) => {
const [reducedProps, callbackRef] = useAnimatedProps<TProps, TInstance>(
props,
);
const ref = useMergeRefs<TInstance | null>(callbackRef, forwardedRef);
// Some components require explicit passthrough values for animation
// to work properly. For example, if an animated component is
// transformed and Pressable, onPress will not work after transform
// without these passthrough values.
// $FlowFixMe[prop-missing]
const {passthroughAnimatedPropExplicitValues, style} = reducedProps;
const {style: passthroughStyle, ...passthroughProps} =
passthroughAnimatedPropExplicitValues ?? {};
const mergedStyle = [style, passthroughStyle];
return (
<Component
{...reducedProps}
{...passthroughProps}
style={mergedStyle}

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

export default function useAnimatedProps<TProps: {...}, TInstance>(
props: TProps,
): [ReducedProps<TProps>, CallbackRef<TInstance | null>] {
const [, scheduleUpdate] = useReducer(count => count + 1, 0);
const onUpdateRef = useRef<?() => void>(null);
// TODO: Only invalidate `node` if animated props or `style` change. In the
// previous implementation, we permitted `style` to override props with the
// same name property name as styles, so we can probably continue doing that.
// The ordering of other props *should* not matter.
const node = useMemo(
() => new AnimatedProps(props, () => onUpdateRef.current?.()),
[props],
);
useAnimatedPropsLifecycle(node);
// TODO: This "effect" does three things:
//
// 1) Call `setNativeView`.
// 2) Update `onUpdateRef`.
// 3) Update listeners for `AnimatedEvent` props.
//
// Ideally, each of these would be separat "effects" so that they are not
// unnecessarily re-run when irrelevant dependencies change. For example, we
// should be able to hoist all `AnimatedEvent` props and only do #3 if either
// the `AnimatedEvent` props change or `instance` changes.
//
// But there is no way to transparently compose three separate callback refs,
// so we just combine them all into one for now.
const refEffect = useCallback(
instance => {
// NOTE: This may be called more often than necessary (e.g. when `props`
// changes), but `setNativeView` already optimizes for that.
node.setNativeView(instance);
// NOTE: This callback is only used by the JavaScript animation driver.
onUpdateRef.current = () => {
// Schedule an update for this component to update `reducedProps`,
// but do not compute it immediately. If a parent also updated, we
// need to merge those new props in before updating.
scheduleUpdate();
};
const target = getEventTarget(instance);
const events = [];
for (const propName in props) {
const propValue = props[propName];
if (propValue instanceof AnimatedEvent && propValue.__isNative) {
propValue.__attach(target, propName);
events.push([propName, propValue]);
}
}
return () => {
onUpdateRef.current = null;
for (const [propName, propValue] of events) {
propValue.__detach(target, propName);
}
};
},
[props, node],
);
const callbackRef = useRefEffect<TInstance>(refEffect);
return [reduceAnimatedProps<TProps>(node), callbackRef];
}

Potential solution

By looking at the implementation of other popular animation libraries, we can see they are purposely trying to avoid such behaviours.

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 none 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 stays 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 even blockers that I am not aware of - that's why I would really appreciate your feedback and insight, thanks a lot! ❤️

@kacper-mikolajczak kacper-mikolajczak added the enhancement Requires extension or creation of new React Native API label Dec 21, 2023
@kacper-mikolajczak kacper-mikolajczak changed the title Animated Avoid react updates on each animation frame Dec 21, 2023
@necolas
Copy link
Owner

necolas commented Dec 21, 2023

Maybe submit this proposal to React Native, which is where Animated comes from?

@kacper-mikolajczak
Copy link
Author

Hi @necolas!

Initially, I was not sure where to post it - thanks for the suggestion!

I will move the proposal there 👍

@kacper-mikolajczak kacper-mikolajczak closed this as not planned Won't fix, can't repro, duplicate, stale Dec 21, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement Requires extension or creation of new React Native API
Projects
None yet
Development

No branches or pull requests

2 participants