software-mansion/react-native-reanimated

Animation Very Glitchy in Audio Frequency Visualization when Interrupting 'withTiming' ANDROID ONLY

Opened this issue ยท 6 comments

Description

I am trying to create an animation for frequency band visualization using withTiming. The useEffect hook listens for frequency data changes and applies interpolated height values to animated bands, but the updates, which use withTiming with a specified animation duration, do not appear smooth whenever the frequency data is coming in faster than the animation duration. However, this works totally fine on iOS and is able to replace and continue each animation smoothly, but totally breaks down when on android.

According to the react-native-reanimated docs:

Whenever you make animated updates of Shared Values, or you specify animations in the useAnimatedStyle hook, those animations are fully interruptible. In the former case, when you make an update to a Shared Value that is being animated, the framework won't wait for the previous animation to finish, but will immediately initiate a new transition starting from the current position of the previous animation.

Expected Behavior:

  • The frequency bands should animate smoothly between heights as data updates (works on iOS)

Observed Behavior:

  • On any Android device, the animations heavily lag and stutter whenever frequency data updates are more frequent than the animation duration

Steps to reproduce

  1. Run an update on an interval faster than the animation duration of the frequency bands that updates the height of a set of react-native-svg rectangles:
const animatedBandHeights = [0, 0, 0, 0].map(() => useSharedValue(MIN_BAR_HEIGHT));
useEffect(() => {
    // Fake listener that generates random frequency bands
    const fakeFrequencyDataSubscription = setInterval(() => {
      const freqBands = Array.from({ length: 4 }, () =>
        Math.random() * (ESTIMATE_BAND_MAX_MAGNITUDE - ESTIMATE_BAND_MIN_MAGNITUDE) + ESTIMATE_BAND_MIN_MAGNITUDE
      );

      freqBands.forEach((band, index) => {
        const newHeight = interpolate(
          band,
          [ESTIMATE_BAND_MIN_MAGNITUDE, ESTIMATE_BAND_MAX_MAGNITUDE],
          [MIN_BAR_HEIGHT, MAX_BAR_HEIGHT],
        );

        animatedBandHeights[index].value = withTiming(newHeight, { duration: 150 });
      });
    }, 30); // make the updates occur faster than withTiming

    return () => {
      clearInterval(fakeFrequencyDataSubscription);
    };
  }, []);
  1. Display the react-native-svg rectangles:
<Svg height={SVG_CONTAINER_HEIGHT} width={SVG_CONTAINER_WIDTH}>
  {animatedBandHeights.map((h, index) => {
    const animatedProps = useAnimatedProps(() => ({
      y: (SVG_CONTAINER_HEIGHT - h.value) / 2,
      height: h.value,
    }));

    return (
      <AnimatedRect
        key={index}
        x={index * (BAND_WIDTH + BAND_SEPERATION) + BAND_SEPERATION}
        width={BAND_WIDTH}
        ry={BAND_WIDTH / 2}
        fill={"black"}
        animatedProps={animatedProps}
      />
    );
  })}
</Svg>
  1. The rectangles will animate properly on iOS, but not on android

Snack or a link to a repository

https://github.com/mrmuke/animating_rectangles

Reanimated version

3.16.1

React Native version

0.76.5

Platforms

Android

JavaScript runtime

None

Workflow

None

Architecture

None

Build type

None

Device

None

Device model

No response

Acknowledgements

Yes

@mrmuke hey, thanks for the detailed issue and sorry for the late reply (holidays got us haha). Could you please share some recordings pointing out what exactly feels glitchy? I am currently comparing iOS and Android side by side and the animations are super janky but this is solely because of super often updates - both platforms behave the same

Also, I played a bit with the parameters and making the BAND_ANIMATION_DURATION just a bit longer drastically improves the smoothness. See, if, for example, each 30ms the values we roll are alternating between small and big heights, the animations are gonna snap pretty fast because the speed of animation is ultimately difference between two height values divided by the time (which is a constant here). If we add the frequency of updates (30ms is quite often), we just get some super snappy spikes. Making the animation duration longer OR introducing some sort of a throttle seems to be a bit helpful here

Okay I played even more with it and found some way to make it smoother without changing parameters of the animation: (sorry for typing your App.js into App.tsx haha):

import React, { useEffect } from 'react';
import { Svg, Rect } from 'react-native-svg';
import Animated, { useSharedValue, useAnimatedProps, withTiming, interpolate, SharedValue, useDerivedValue, Easing } from 'react-native-reanimated';
import { View } from 'react-native';

const AnimatedRect = Animated.createAnimatedComponent(Rect);

const SVG_CONTAINER_WIDTH = 300
const SVG_CONTAINER_HEIGHT = 300

const ESTIMATE_BAND_MIN_MAGNITUDE = 0
const ESTIMATE_BAND_MAX_MAGNITUDE = 750 // Arbitrary estimate that seems to work for both platforms
const MIN_BAR_HEIGHT = 75;
const MAX_BAR_HEIGHT = 175;

const BAND_ANIMATION_DURATION = 150

const BAND_WIDTH = 60
const BAND_SEPERATION = 12

function BandRect({magnitudeSv, index}: {magnitudeSv: SharedValue<number>, index: number}) {
  const heightSv = useSharedValue(MIN_BAR_HEIGHT)
  
  useDerivedValue(() => {
    // react to each mutation of magnitudeSv by calculating new height and modifying heightSv
    const interpolatedHeight = interpolate(
      magnitudeSv.value,
      [ESTIMATE_BAND_MIN_MAGNITUDE, ESTIMATE_BAND_MAX_MAGNITUDE],
      [MIN_BAR_HEIGHT, MAX_BAR_HEIGHT],
    )
    heightSv.value = withTiming(interpolatedHeight, { duration: BAND_ANIMATION_DURATION })
  })
  
  const animatedProps = useAnimatedProps(() => ({
    y: (SVG_CONTAINER_HEIGHT - heightSv.value) / 2,
    height: heightSv.value,
  }))

  return (
    <AnimatedRect
      x={index * (BAND_WIDTH + BAND_SEPERATION) + BAND_SEPERATION}
      width={BAND_WIDTH}
      ry={BAND_WIDTH / 2}
      fill={"black"}
      animatedProps={animatedProps}
    />
  );
}

export default function App() {
  // Shared values for magnitudes
  const magnitudeSvs = [0, 0, 0, 0].map(() => useSharedValue(ESTIMATE_BAND_MIN_MAGNITUDE));

  useEffect(() => {
    // Fake listener that generates random frequency bands
    const fakeFrequencyDataSubscription = setInterval(() => {
      const freqBands = Array.from({ length: 4 }, () =>
        Math.random() * (ESTIMATE_BAND_MAX_MAGNITUDE - ESTIMATE_BAND_MIN_MAGNITUDE) + ESTIMATE_BAND_MIN_MAGNITUDE
      );

      freqBands.forEach((band, index) => {
        magnitudeSvs[index].value = band
      });
    }, 30); // make the updates occur faster than withTiming

    return () => {
      clearInterval(fakeFrequencyDataSubscription);
    };
  }, []);

  // const 

  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Svg height={SVG_CONTAINER_HEIGHT} width={SVG_CONTAINER_WIDTH}>
        {magnitudeSvs.map((h, index) => {
          
          return (
            <BandRect magnitudeSv={magnitudeSvs[index]} index={index} key={index}/>
          );
        })}
      </Svg>
    </View>
  );
};

So what I did is: you both interpolated the height and assigned shared values on JS thread inside setInterval callback. This isn't too effective and at least the interpolation could be moved to a block that executes on the UI. So I created components for each single rectangle, which gets a shared value with magnitude and reacts to its changes in useDerivedValue block - and then on the UI interpolates it and uses with timing.

In general, the code in setInterval was running synchronously, so each shared value assignment was waiting for the interpolation and other calculations to be finished - and then also sent to UI thread. This made some delays and some of the updates could just cumulate, resulting in such choppy animations. After moving some calculations to the UI, stuff generally works better.

Also a second thing - android emulator usually works worse than iOS simulator - only physical android devices show the true performance ๐Ÿ˜„ LMK if you need anything else!

P.S. I'd still try adding some baseline throttle for the updates, if we got some updates in less than a single frame time there still could be some glitches even with this approach

Hi! Thank you so much for your detailed testing and responding with so much care, I really appreciate it. I also noticed iOS and Android performed similarly on this code after posting this but forgot to change my post details. However, I also realized that it works better on newer androids such as Google Pixel 8 that I tested on and my iPhone 15, but is very laggy on an older Android A32 5G. Awesome that you found something that works without changing params! I will try out your solution and get back to you on how it performs on the older Android phone.

Also, I tried throttling before and the problem with it is that it doesn't look nearly as snappy and reactive to your voice even with a slight delay.

I get it, throttling might limit the usefulness in your use case. Regarding the devices, it is usually true that simulators/emulators will be working worse than mid- and high-end devices and better than some older ones. Unfortunately that's an arms race that will always be a case.
Also, you can sometimes try building the apps in release instead of debug - it also show the true performance without any logistics and other production-wise useless processes.