software-mansion / react-native-reanimated

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

withTiming / withRepeat slowdown on Android #6531

Closed deanhet closed 2 weeks ago

deanhet commented 1 month ago

Description

I'm building a metronome-like app and am using reanimated to animate a box moving to indicate a "tick" of the metronome.

I've successfully got my code to make a box bouncing back and forth across the screen in time with my measure. When I hear a click using another metronome app, I see the box hit a side of the view. This works perfectly on iOS, when I sync it with an external click, it never falls out of sync. However on Android it doesn't take long before it wavers off and the click is nowhere near in sync. That is to say that instead of the box travelling across the screen every 1000ms, it drifts to more.

I've boiled down the code to a bare minimum use-case, I think (hope) there's nothing in there that should be causing a slowdown like this. I've tried on all sorts of simulators and the app just seems to drop frames and fail to keep up. The longer it's open for, the more out of sync it gets. Again, no issues at all on iOS, only Android.

The problem presents itself in dev and production builds, bridgeless and not bridgeless modes too.

Any help would be greatly appreciated πŸ™

Steps to reproduce

import { useEffect, useMemo, useState } from 'react'
import { Button, Dimensions, SafeAreaView, View } from 'react-native'
import Reanimated, {
  cancelAnimation,
  Easing,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withTiming,
} from 'react-native-reanimated'

const SCREEN_WIDTH = Dimensions.get('screen').width

export default function App() {
  const [isPlaying, setIsplaying] = useState(false)
  const [bpm, setBpm] = useState(60)
  const intervalTime = useMemo(() => (60 / bpm) * 1000, [bpm]) // Converts BPM to milliseconds per beat

  console.log(`Should hit a wall every ${intervalTime}ms`)
  const bpmMeasureShared = useSharedValue(0)

  useEffect(() => {
    if (!isPlaying) {
      cancelAnimation(bpmMeasureShared)
      bpmMeasureShared.value = 0
    } else {
      bpmMeasureShared.value = withRepeat(
        withTiming(SCREEN_WIDTH - 50, {
          duration: intervalTime,
          easing: Easing.linear,
        }),
        0,
        true
      )
    }
  }, [isPlaying])

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateX: bpmMeasureShared.value,
        },
      ],
    }
  }, [bpmMeasureShared])

  const togglePlaying = () => {
    setIsplaying(!isPlaying)
  }

  return (
    <SafeAreaView>
      <View style={{ flexDirection: 'row', paddingTop: 50 }}>
        <Button onPress={togglePlaying} title={isPlaying ? 'Stop' : 'Play'} />
      </View>

      <Reanimated.View
        style={[
          { width: 50, height: 50, backgroundColor: 'orange' },
          animatedStyle,
        ]}
      />
    </SafeAreaView>
  )
}

Snack or a link to a repository

See above

Reanimated version

3.15.3

React Native version

0.74.5

Platforms

Android

JavaScript runtime

Hermes

Workflow

Expo Dev Client

Architecture

Fabric (New Architecture)

Build type

None

Device

Android emulator

Device model

No response

Acknowledgements

Yes

github-actions[bot] commented 1 month 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?

bartlomiejbloniarz commented 1 month ago

Was this issue present in previous versions, or is it only on 3.15.3?

deanhet commented 1 month ago

3.15.2 too, I just updated in hope it might fix it

deanhet commented 1 month ago

If this info helps, I've tried my hand at not using withTiming / withRepeat at all with:

  const intervalTime = 1000
  const tickDirection = useSharedValue(1)
  const progress = useSharedValue(0)
  const startTime = useSharedValue(0)

  useFrameCallback((frameTime) => {
    if (startTime.value === 0) {
      startTime.value = frameTime.timeSincePreviousFrame
    }

    const elapsed = frameTime.timeSincePreviousFrame ?? 0 - startTime.value
    const incrementedValue = 1 / (intervalTime / elapsed)

    if (tickDirection.value > 0) {
      progress.value += incrementedValue
      if (progress.value >= 1) {
        tickDirection.value = -1
      }
    } else {
      progress.value -= incrementedValue
      if (progress.value <= 0) {
        tickDirection.value = 1
      }
    }
  })

  const animatedStyle = useAnimatedStyle(() => {
    return {
      width: 50,
      height: 50,
      backgroundColor: 'orange',
      transform: [
        {
          translateX: interpolate(
            progress.value,
            [0, 1],
            [0, SCREEN_WIDTH - 50],
            {
              extrapolateRight: Extrapolation.CLAMP,
            }
          ),
        },
      ],
    }
  }, [progress])

Exact same results as previous. iOS is rock solid, Android drifts.

dppo commented 1 month ago

Had the same problem using withSpring in useAnimatedStyle It's normal on ios, but there's something wrong with Android

const animatedStyle = useAnimatedStyle(() => {
    const zIndex = isGestureActive.value || isAnimating.value ? 100 : 0;
    const scale = withSpring(isGestureActive.value ? 1.05 : 1);
    return {
      zIndex,
      transform: [{ translateX: translate.value.x }, { translateY: translate.value.y }, { scale }],
      opacity: translate.value.x === Number.MAX_SAFE_INTEGER && translate.value.y === Number.MAX_SAFE_INTEGER ? 0 : 1,
    };
  }, [isGestureActive, isAnimating, translate]);
sssajjad007 commented 1 month ago

Hi, any update for this issue?

bahadiraraz commented 1 month ago

same

SamuraiF0x commented 3 weeks ago

I have the same problem. I'm mapping lines every 100ms and it works before adding withSpring. [Android]

const lineStyle = [
   {
    minWidth: 1,
    width: 1,
    borderRadius: 10,
   },
   useAnimatedStyle(() => ({
    height: withSpring(
        interpolate(
             db,
             [MIN, MAX],
             [1, 25],
             Extrapolation.CLAMP,
        ),
        SPRING_FAST_CONFIG,
    ),
   })),
];
bartlomiejbloniarz commented 2 weeks ago

@deanhet I think that the issue is not exclusive to Android. Have you checked with the other metronome, whether values like 55 bpm work on iOS? I think this reproduction works fine on iOS, because each frame is displayed in exactly 16.666ms intervals. If the animation duration is divisible by the frame duration (here it is), then the animation will be always synced. But if we choose 55 bpm, then the duration will be 1090 ms, which is not nicely divisible by 16.666, causing the animation to fall out of sync. I think this is caused by how we handle animation start/finish in withRepeat - at a timestamp t, we figure out that the previous animation has finished (e.g. its duration has been reached between the previous timestamp and t), and then we start the next animation with timestamp t. But if the next animation started exactly when the previous one ended, then the next animation should already have made some progress by timestamp t.

obraz

This issue is also present in the useFrameCallback reproduction you provided. You change the direction, when the progress surpasses 1, but the progress that goes over 1 is effectively lost.

dppo commented 2 weeks ago

@deanhet I think that the issue is not exclusive to Android. Have you checked with the other metronome, whether values like 55 bpm work on iOS? I think this reproduction works fine on iOS, because each frame is displayed in exactly 16.666ms intervals. If the animation duration is divisible by the frame duration (here it is), then the animation will be always synced. But if we choose 55 bpm, then the duration will be 1090 ms, which is not nicely divisible by 16.666, causing the animation to fall out of sync. I think this is caused by how we handle animation start/finish in withRepeat - at a timestamp t, we figure out that the previous animation has finished (e.g. its duration has been reached between the previous timestamp and t), and then we start the next animation with timestamp t. But if the next animation started exactly when the previous one ended, then the next animation should already have made some progress by timestamp t.

obraz

This issue is also present in the useFrameCallback reproduction you provided. You change the direction, when the progress surpasses 1, but the progress that goes over 1 is effectively lost.

I found that not only does it occur on Android devices, but also the withSpring animation fails in chrome on Windows 11, but it is indeed normal on chrome on macOS.

bartlomiejbloniarz commented 2 weeks ago

@dppo I think this is a different issue, I don't see how withSpring could be used to implement a metronome. Could you open an issue with a description of your problem>

bartlomiejbloniarz commented 2 weeks ago

@deanhet I wrote an example implementation of a metronome using useFrameCallback, that doesn't suffer from this problem:

  useFrameCallback((frameTime) => {
    const elapsed = frameTime.timeSinceFirstFrame;

    const bigProgress = elapsed / intervalTime;
    const numOfRounds = Math.floor(bigProgress);

    if (numOfRounds % 2 === 0) {
      progress.value = bigProgress - numOfRounds;
    } else {
      progress.value = 1 - (bigProgress - numOfRounds);
    }
  });
bartlomiejbloniarz commented 2 weeks ago

After some internal discussion, I don't think that withRepeat and other with* functions should be used for precise calculations. withRepeat should be though of as a way to schedule a repetition of animations, not a precise timeline of animations.

For those scenarios you should use useFrameCallback, as this gives you access to the exact frame timestamp.

deanhet commented 2 weeks ago

I wrote an example implementation of a metronome using useFrameCallback, that doesn't suffer from this problem:

@bartlomiejbloniarz Thanks so much for taking the time to explain the cause of what I was seeing. Really appreciate it! I've just got time to try your solution and it works great. It all makes sense, I just got caught on a track and needed another pair of eyes on it.