react-native-community / discussions-and-proposals

Discussions and proposals related to the main React Native project
https://reactnative.dev
1.66k stars 126 forks source link

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

Open kacper-mikolajczak opened 9 months ago

kacper-mikolajczak commented 9 months ago

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 ```jsx 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 ( <>

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 ```jsx 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 ( <>

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:

https://github.com/necolas/react-native-web/assets/62747088/1354aef8-b718-443f-8c07-4033f4402925

Source code of above example ```jsx 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 ( <>

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 ```diff export default function createAnimatedComponent( Component: React.AbstractComponent ): React.AbstractComponent { 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( props, + callback ); const ref = useMergeRefs( callbackRef, forwardedRef, + innerRef ); ```
useAnimatedProps modifications ```diff export default function useAnimatedProps( props: TProps, callback: ({ style: any }) => void ): [ReducedProps, CallbackRef] { - 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(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 commented 8 months ago

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!