software-mansion / react-native-reanimated

React Native's Animated library reimplemented
https://docs.swmansion.com/react-native-reanimated/
MIT License
8.94k stars 1.3k forks source link

Single frame delay on Android between Native, SVG, and Skia #5341

Closed wcandillon closed 11 months ago

wcandillon commented 11 months ago

Description

On Android, when animating SVG and Skia, it looks like there is always a one frame delay between the native view and the canvas view (SVG or Skia). The issue on iOS seems to be reproducible if there is more pressure put on the app.

Not sure if this is on issue with Reanimated or with SVG and Skia.

Below is an example with React Native SVG:

import React, { useEffect } from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
  useAnimatedStyle,
  useAnimatedProps,
  withRepeat,
  withTiming,
  Easing,
  cancelAnimation,
  useSharedValue,
} from "react-native-reanimated";
import { Svg, G, Rect as SvgRect } from "react-native-svg";

const AnimatedG = Animated.createAnimatedComponent(G);

const array = new Array(1).fill(0).map((_, index) => index);

export const useLoop = ({ duration }: { duration: number }) => {
  const progress = useSharedValue(0);
  useEffect(() => {
    progress.value = withRepeat(
      withTiming(1, { duration, easing: Easing.linear }),
      -1,
      true
    );
    return () => {
      cancelAnimation(progress);
    };
  }, [duration, progress]);
  return progress;
};

export default function App() {
  const clock = useLoop({ duration: 3000 });
  const animatedProps = useAnimatedProps(() => ({
    translate: `0, ${300 + clock.value * 300 - 300}`,
  }));
  const style = useAnimatedStyle(() => ({
    ...StyleSheet.absoluteFillObject,
    transform: [{ translateY: 300 + clock.value * 300 - 300 }],
  }));
  return (
    <View
      style={{
        flex: 1,
        flexDirection: "row",
      }}
    >
      <View style={{ flex: 1 }}>
        <Animated.View style={style}>
          {array.map((index) => (
            <View
              key={index}
              style={{
                position: "absolute",
                top: 150 * index + 50,
                left: 15,
                width: 100,
                height: 100,
                backgroundColor: "blue",
              }}
            />
          ))}
        </Animated.View>
      </View>
      <Svg style={StyleSheet.absoluteFill}>
        <AnimatedG animatedProps={animatedProps}>
          {array.map((index) => (
            <SvgRect
              key={index}
              x={15}
              y={150 * index + 50}
              width={100}
              height={100}
              fill="red"
            />
          ))}
        </AnimatedG>
      </Svg>
    </View>
  );
}

We also have the same behaviour with Skia:

import {
  Canvas,
  Group,
  Rect,
  Skia,
  useClock,
} from "@shopify/react-native-skia";
import React from "react";
import { StyleSheet, View } from "react-native";
import Animated, {
  useAnimatedStyle,
  useDerivedValue,
} from "react-native-reanimated";

const array = new Array(1).fill(0).map((_, index) => index);
const paint = Skia.Paint();
paint.setColor(Skia.Color("pink"));

export function Breathe() {
  const clock = useClock();
  const offsetTx = useDerivedValue(() => [
    { translateY: 300 + Math.sin(clock.value / 300) * 300 - 300 },
  ]);
  const style = useAnimatedStyle(() => ({
    ...StyleSheet.absoluteFillObject,
    transform: [{ translateY: 300 + Math.sin(clock.value / 300) * 300 - 300 }],
  }));
  return (
    <View
      style={{
        flex: 1,
        flexDirection: "row",
      }}
    >
      <View style={{ flex: 1 }}>
        <Animated.View style={style}>
          {array.map((index) => (
            <View
              key={index}
              style={{
                position: "absolute",
                top: 150 * index + 50,
                left: 15,
                width: 100,
                height: 100,
                backgroundColor: "blue",
              }}
            />
          ))}
        </Animated.View>
      </View>

      <Canvas
        mode="continuous"
        style={StyleSheet.absoluteFill}
        pointerEvents="none"
      >
        <Group transform={offsetTx}>
          {array.map((index) => (
            <Rect
              key={index}
              x={15}
              y={150 * index + 50}
              width={100}
              height={100}
              color="green"
            />
          ))}
        </Group>
      </Canvas>
    </View>
  );
}

Steps to reproduce

Run the code snippet, the expected behaviour is that the color blue should never be visible.

Example with Skia: https://gist.github.com/wcandillon/da5715b7b107bee808193cb637f8539f (contains the SVG example as well).

Snack or a link to a repository

https://gist.github.com/wcandillon/da5715b7b107bee808193cb637f8539f

Reanimated version

3.5.4

React Native version

0.71.0

Platforms

Android

JavaScript runtime

Hermes

Workflow

React Native

Architecture

Paper (Old Architecture)

Build type

Debug mode

Device

Real device

Device model

Pixel 4

Acknowledgements

Yes

github-actions[bot] commented 11 months ago

Hey! 👋

The issue doesn't seem to contain a minimal reproduction.

Could you provide a snack or a link to a GitHub repository under your username that reproduces the problem?

tomekzaw commented 11 months ago

Hey @wcandillon! Thanks for reporting this issue.

In Reanimated, we distinguish 3 categories of props depending on how we animate them:

AnimatedG from react-native-svg is one of those components which we need to animate using JS props mechanism. Effectively, this works like calling setNativeProps via runOnJS. Here's how it works on the native side:

https://github.com/software-mansion/react-native-reanimated/blob/c56d44fd99e7fb3f46f1868e1cfc6167bb759309/apple/REANodesManager.mm#L508-L517

On the JS side, we add a listener for onReanimatedPropsChange event that eventually calls setNativeProps, here's the whole flow:

https://github.com/software-mansion/react-native-reanimated/blob/c56d44fd99e7fb3f46f1868e1cfc6167bb759309/src/createAnimatedComponent/JSPropUpdater.ts#L70-L73

https://github.com/software-mansion/react-native-reanimated/blob/c56d44fd99e7fb3f46f1868e1cfc6167bb759309/src/createAnimatedComponent/JSPropUpdater.ts#L47-L50

https://github.com/software-mansion/react-native-reanimated/blob/c56d44fd99e7fb3f46f1868e1cfc6167bb759309/src/createAnimatedComponent/createAnimatedComponent.tsx#L247-L255

Because of asynchronicity, there's always some amount of delay. When the JS thread is busy, obviously some animation frames will be dropped. We can easily simulate busy JS thread with the following hook which you can call from App component:

function useJSThreadKiller() {
  useEffect(() => {
    const id = setInterval(() => {
      const until = performance.now() + 200;
      while (performance.now() < until) {}
    }, 300);
    return () => clearInterval(id);
  }, []);
}

Here's how it behaves (notice that the blue square is lagging when JS thread is busy):

https://github.com/software-mansion/react-native-reanimated/assets/20516055/98f6af68-ea92-4ed8-a977-af693a41281f

This is why there's a lag when using react-native-svg, let me check out the repro with Skia to see what's happening there.

wcandillon commented 11 months ago

@tomekzaw thank you for providing context. I'm very appreciating 🙏

In the case of Skia, on Android, it the delay can easily be explained because of our rendering pipeline there (see https://github.com/Shopify/react-native-skia/pull/1965), for iOS, I would need to do more testing and see if it can be easily reproduced on iOS. If not, we can probably close this.

tomekzaw commented 11 months ago

@wcandillon Thanks for the response! I've also prepared a fresh React Native app with latest version of Skia and Reanimated. I'm running the example you have provided (the one with <Group transform={offsetTx}>) but it doesn't seem to use Reanimated for the animation (the breakpoint in REANodesManager never gets hit). Do you know what might be the cause here?

wcandillon commented 11 months ago

Thank you so much for looking into this. We are aware of what could cause a similar delay for Skia on Android. So now I'd be curious to see if we can reproduce the delay on iOS.

To answer your question, this is because Skia has its own reconciller and we integrate with Reanimated here: https://github.com/Shopify/react-native-skia/blob/main/package/src/external/reanimated/renderHelpers.ts And this is the Skia reconciler: https://github.com/Shopify/react-native-skia/blob/main/package/src/renderer/HostConfig.ts

tomekzaw commented 11 months ago

Okay, so effectively it updates props on the JS thread, correct? https://github.com/Shopify/react-native-skia/blob/d3881d084a0d1eb96460acfbb816832ead92d1bc/package/src/external/reanimated/renderHelpers.ts#L59-L76

Would be great to animate everything on the UI thread if that's possible.

wcandillon commented 11 months ago

Sorry for the confusion bindReanimatedProps2 uses the JS thread but if you use Reanimated 3, it uses the UI thread bindReanimatedProps

tomekzaw commented 11 months ago

Oh, you're right, I've linked the wrong function from Rea2. So the question is still valid, why the breakpoint in REANodesManager is not hit but the animation is running? 🤔

edit: I see it now (here), so you're using Reanimated for calculating shared values and animated styles but have your own code to update props (node.setProp). It all makes sense now, thank you!

wcandillon commented 11 months ago

We are not using createAnimatedComponent, we just use a mapper to update the node property directly. This is why this will not get it.

On Thu, Nov 9, 2023 at 12:24 PM Tomek Zawadzki @.***> wrote:

Oh, you're right, I've linked the wrong function from Rea2. So the question is still valid, why the breakpoint in REANodesManager is not hit but the animation is running? 🤔

— Reply to this email directly, view it on GitHub or unsubscribe. You are receiving this email because you authored the thread.

Triage notifications on the go with GitHub Mobile for iOS or Android.

wcandillon commented 11 months ago

@tomekzaw Thank you so much for taking the time to look into this and providing the context of why this happens on SVG. In Skia, we don't think reanimated is the culprit either, we have an issue on our side to track this: https://github.com/Shopify/react-native-skia/issues/1960

tomekzaw commented 11 months ago

Always happy to help! Thanks ❤️