react-native-reanimated icon indicating copy to clipboard operation
react-native-reanimated copied to clipboard

Slow momentum scrolling causes jitter / lag when the momentum scroll slowly comes to a stop

Open kartikk221 opened this issue 2 years ago • 13 comments

Description

When utilizing the onScroll event from a ScrollView to power animation of another component such as an indicator, there is visible stutter towards the last few moments before a momentum scroll stops. It only occurs towards the end of a momentum scroll meaning when the onScroll events are being fired but with very small changes to the content offset hence very small numerical changes.

The only culprit I can think of is Reanimated has trouble interpolating / animating very small numerical changes in a shared value.

Please pay extra attention to the edges of the boxes in relation to their movement around the background white lines to notice the jitter. It is very subtle in this demo I put together but becomes very noticeable when working in a real UI in which a chunk of the screen is being animated. For example, one screen in our app uses react-native-collapsible-tab-view which uses Reanimated to animate the header chunk of the screen and there is noticeable stutter whenever we have the momentum scroll slowly come to a stop.

I tried to mess with the scrollEventThrottle and observed that raising it makes the indicator very laggy and lowering it doesn't affect the jitter at all.

Note! The snack uses reanimated 2.14.4 while I am using 2.17.0 but both of these versions including those between and even v3 can consistently recreate this behavior.

Screen Recording on an iPhone 14 Pro with Snack Demo: https://github.com/software-mansion/react-native-reanimated/assets/55661618/137c67bb-b83b-4b68-80a8-bf15bafc174a

Screen Recording on an iPhone 14 Pro Max with testflight real build of our app: https://github.com/software-mansion/react-native-reanimated/assets/55661618/b4a87d14-79a9-4488-a03c-1ea4040db6bf The above is provided to show this jitter can become much more noticeable in a real world application.

Steps to reproduce

  1. Run the Snack on a real iPhone. In my case, It was on an iPhone 14 Pro
  2. Swipe very slowly once and let go so that the momentum scroll kicks in and takes you to the next or previous snap threshold
  3. Observe the edges or the boxes in relation to the white lines to notice visible stutter / lag as the momentum scroll comes to a slow stop. This will be observable randomly in the last few moments before the momentum scroll stops.
  4. Repeat the above for doing bouncy overscrolls at the beginning or end of the scrollview content to observe the same jitter / lag as the momentum scroll comes to a slow stop.

Snack or a link to a repository

https://snack.expo.dev/@kartikk2212/reanimated-scroll-bug

Reanimated version

2.17.0

React Native version

0.71.8

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo managed workflow

Architecture

Paper (Old Architecture)

Build type

Release mode

Device

Real device

Device model

iPhone 14 Pro

Acknowledgements

Yes

kartikk221 avatar Jul 14 '23 18:07 kartikk221

After doing more digging into the issue to try and resolve it myself. I discovered that the onScroll event's contentOffset.y tends to give values in at most of 0.3333333 accuracy. So the logs would show a gradual decrease of 1.666666, 1.333333, 1, 0.66666, 0.3333, 0. This behavior did not change no matter how much I lowered the scrollEventThrottle, so It seems that some sort of quantization is going on for smaller values from react-native / ScrollView which is giving inaccurate scroll values leading to stutter.

Since the stutter was occuring at slow speeds only, I decided to fork the collapsible tabview library and simply apply a withTiming() of duration scrollEventThrottle to smooth out the small delta changes.

This resolved the stuttering for the most part at the cost of slight drifting / lagging of the actual positioning as it slowly gets animated instead of instantly being synced but this occurs very rarely and is hard to notice for most users.

Here is the commit: https://github.com/kartikk221/react-native-collapsible-tab-view/commit/dca69305ad1c65adeadbc45454de789e08c7db05

I am not sure If this problem's root cause is react-native or react-native-reanimated but would appreciate If a good solution can be found for this problem as it can be intrusive to the user experience, especially on high end devices with 90-120hz refresh rates.

kartikk221 avatar Jul 15 '23 20:07 kartikk221

Hi @kartikk221, this is just how iOS behaves. OnScroll event from useAnimatedScrollHandler are 'inherited' from react-native and they are accurate to one thirds. You can confirm this by rewriting the part that bothers you to pure react-native:

<ScrollView
        horizontal={true}
        onScroll={(event) => {
          console.log(event.nativeEvent.contentOffset.x);
          index.value = event.nativeEvent.contentOffset.x / width;
        }}
        snapToInterval={width / 2}
        scrollEventThrottle={throttle}
        showsHorizontalScrollIndicator={false}
        style={{
          zIndex: 1,
          elevation: 1,
          flexGrow: 0, // This is so the indicator does not get pushed down
        }}>
        {Array(number_of_squares)
          .fill(0)
          .map((_, index) => (
            <View key={index} style={styles.square}>
              <View
                style={[
                  styles.internal_box,
                  {
                    backgroundColor: 'red',
                    flex: 1,
                  },
                ]}>
                <View
                  style={[
                    styles.internal_box,
                    {
                      backgroundColor: 'blue',
                      flex: 1,
                    },
                  ]}>
                  <View
                    style={{
                      flex: 1,
                      marginHorizontal: 50,
                      backgroundColor: 'green',
                    }}
                  />
                </View>
              </View>
            </View>
          ))}
      </ScrollView>

tjzel avatar Jul 17 '23 11:07 tjzel

I see but the problem is that the jitter only occurs If I use the onScroll with the scrollEventThrottle.

If I just use it like a normal ScrollView there is no jitter as it slows down.

Also, why does the jitter occur on the ScrollView even though the onScroll is applying the values on another component being an indicator?

It seems as If reanimated may be causing mini lag spikes that lead to the jitter as otherwise only the component being animated should be jittery not the ScrollView itself?

kartikk221 avatar Jul 17 '23 15:07 kartikk221

I'm not sure if I can help you. First of all, me or my colleagues have some trouble seeing the jitter you are talking about. A video that compares the two behaviors - with jitter and without - would be very helpful for us.

Secondly, I can see the same effect you are talking about without passing scrollEventThrottle property. Also I don't know what you mean when you say 'like a normal ScrollView' - scrollEventThrottle is a property that tells react-native how often an event about scrolling should be sent - on Android it's just fixed to once per frame (if possible).

Thirdly - why does the jitter happen only on ScrollView - it's because it's the only component that's being scrolled. You apply an animation from react-native-reanimated with the following config:

width: withTiming(size_per_square * (index.value + 1), {
        duration: throttle,
      })

What (in simplified terms) means: take current width and increase its value gradually to size_per_square * (index.value + 1) over the period of throttle milliseconds. Since it's a gradual increase it's not jittering. Component that uses this width, as you said, is animated smoothly. Therefore it's not react-native-reanimated what is causing the jittering.

Lastly, you are using snapToInterval prop, which I guess I would suspect in the first place to be the culprit of this undesired behavior - I don't know what's react-native for it but snapping always sounds like something that can induce some jitter.

tjzel avatar Jul 17 '23 16:07 tjzel

I see what you mean. I apologize for my initial snack being hard to debug. I have gone ahead and created a new snack with multiple scenes and a much more detailed way to compare performance right within the snack.

Snack URL: https://snack.expo.dev/@kartikk2212/reanimated-bug-detailed Video URL: https://github.com/software-mansion/react-native-reanimated/assets/55661618/c02400ba-63d1-4d4d-b665-5e1962d709b6

Please note that the video has been compressed hence its frame rate / quality is lower than what is visible on the device so it is much harder to see the stutter occur in the video but If you have access to a real iPhone with 120hz ProMotion support, def try to run the snack for yourself and try to see the slow momentum scroll / overall feel of the scroll across all 3 scenes. Scene 2 is fine and honestly not a problem, Scene 3 should clearly demonstrate the problem through the feel.

I would like to emphasize, try to do slow swipes of scrolls and observe the last few moments of the momentum scroll before it stops as the problem is the most pronounced in those last few 0.3s before the scroll comes to a stop in scene 3.

Hope the above helps!

kartikk221 avatar Jul 17 '23 19:07 kartikk221

@tjzel Updated my project to reanimated v3.3.0 today and this problem is still occurring.

The snaptoInterval prop is not the problem as you may observe in the snack, even without using this property the issue still occurs.

If this problem was the fault of react-native solely through the limitation of the 1/3 accuracy for the smallest scroll values then only the component being animated (The indicator in the demo) would be stuttering / jittery due to it receiving inaccurate values, not the ScrollView.

But as you may observe from the frame counter there are frame rate spikes and visible scroll stutter on the screen which shows that the ScrollView itself is also being affected by whatever is going on within the UI thread and that is the fundamental purpose of this issue I feel like since the ScrollView itself should not jitter or be affected by whatever reanimated is doing unless whatever reanimated is doing is expensive.

Also, I updated the snack to use the translateX transformation to power the indicator rather than width so that cannot be seen as expensive in any way. Issue still persists.

kartikk221 avatar Jul 18 '23 18:07 kartikk221

@kartikk221, I'm sorry, but neither I nor my colleagues have noticed any difference, whether while watching your video or testing it on devices that support 60FPS and 120FPS respectively. The only frame drops that occurred were during scene changes; otherwise, the scrolling was always stable with the maximum frame rate.

When I say we didn't notice a difference, it means that we all observed a slight choppiness during slow-motion scrolling, but its intensity didn't change across any of the scenes. This choppiness is also present when using non-react-native apps on iOS.

I suspect you may have some bias when comparing the scenes, viewing react-native-reanimated as an offender, but that's just my guess. If this behavior on iOS is bothering you, I suggest opening an issue in the react-native GitHub repository. However, if native apps also experience the same choppiness, I'm not sure if it's something that can be easily changed.

tjzel avatar Jul 19 '23 15:07 tjzel

Hi @tjzel when you say you observed the same choppiness during slow-motion scrolling, I'm afraid I don't think that's the case for any other app (native or react-native). In fact, the main reason I purused this issue was because the slow motion scrolling was visibly choppier than on any other react-native app not using the onScroll with reanimated or a fully native app like say Twitter for example.

Now, I have found a bandaid solution which actually solves the stutter but at the cost of accuracy.

  • Decrease the scrollEventThrottle to 8ms to allow for sufficient data up to ~120fps https://github.com/kartikk221/react-native-collapsible-tab-view/blob/43053ea9e3ae3373010d38b77b331c353f4cc575/src/ScrollView.tsx#L125
  • Apply my own smoothing with the withTiming function for very small delta changes of <= 3 which is when the stutter occurs https://github.com/kartikk221/react-native-collapsible-tab-view/blob/43053ea9e3ae3373010d38b77b331c353f4cc575/src/hooks.tsx#L374-L399 (The smoothing is only applied during the momentum scroll as that is only when the problem occurs)

The above two changes to the reanimated dependent library I am using DO fix the issue and make it run smooth again but at the cost of the component (header aka. top half) being sometimes being a bit behind the actual scroll in terms of positioning due to it being animated while the smoothing is being applied. I am assuming the problem here is the scroll has a different velocity than the linear animation being played hence the animation can be behind the actual scroll sometimes.

I would understand having trouble seeing the problem through a video since iOS videos seem to be capped to 60hz and this issue is apparent on 120hz but not being able to see it on a physical device is certainly concerning because multiple members on my team who are using iPhone 14 pro, 13 pro and one with even 12 have reported this problem to me.

When I say we didn't notice a difference, it means that we all observed a slight choppiness during slow-motion scrolling, but its intensity didn't change across any of the scenes. This choppiness is also present when using non-react-native apps on iOS.

Could you provide the other react-native / non react-native apps in which you noticed a choppy slow scroll because I feel like this statement is very drastically different from what me and my team along with our QA have been observing. If the issue was consistent across other apps, then it would not be an issue but a normal. But it is certainly isolated and in the snack Scene tends to have visibly choppier slow momentum scroll than Scene 1.

Would it be possible for you to provide a screen recording of how you are testing the scenes? The issue only occurs once you let go of the scroll and the momentum scroll starts and gradually ends.

I apologize If the above isn't clear enough but would love to get to the bottom of this issue as soon as possible and be willing to provide any information or help needed.

kartikk221 avatar Jul 19 '23 17:07 kartikk221

@tjzel I was given an idea by a colleague to test the native Animated API against reanimated and so I have updated the snack https://snack.expo.dev/@kartikk2212/reanimated-bug-detailed Scene 2 to power the same indicator using the native Animated API while Scene 3 powers it with reanimated.

This has now made it clear to me that the random stutter is being caused by Reanimated as neither Scene 1 nor Scene 2 have any stutter. The problem is not the inaccurate scroll values nor the scroll event throttle. It has something to do with updating the shared value with very small decimal numbers which seem to cause the stutter as the native Animated API does not cause any stutter when provided with the same values at the same rate.

This problem is very hard to convey over video given there is no way to screen record in 120fps on an iPhone. If possible, try and evaluate the snack on a real ProMotion device again and really pay attention to the text to notice the stutter in Scene3 over slow scrolls. I would understand If I was biased against reanimated through only my own debugging but since having shown the same snack to 4 colleagues and them confirming that Scene2 runs smooth while Scene3 has slow motion stutter on their iPhones, I think this issue has merit.

kartikk221 avatar Jul 20 '23 04:07 kartikk221

@kartikk221 I ran your new snack or 120FPS device and nor me nor my colleague who wasn't involved in the process before noticed any differences between the scenes (except for the moving bar of course). The only jitter we saw is the subtle one that comes with normal iOS usage and definitely was not something prominent nor something that was appearing more or less intensive on any of the given scenes.

tjzel avatar Jul 27 '23 14:07 tjzel

@tjzel I see, I guess this problem is just very hard to recreate? because the thing is If It was subtle enough that other people may not see it, then I would not have reported it but it has been very easy to recreate on my iPhone 14 Pro. I have been able to improve my band-aid solution with the following observations:

  • Animating a component outside of the viewport with a translateY caused noticeable stutter on slow down to the ScrollView which was providing the onScroll events. To remedy this, I simply do not update the sharedValue from the onScroll when I notice that the value will animate the component outside of the viewport. https://github.com/kartikk221/react-native-collapsible-tab-view/blob/a9232a85fce3d9065ef8d6ce0661d18dcf22fe57/src/Container.tsx#L258-L268
  • Only apply smoothing after the onEndDrag has been called and stop applying once onBeginDrag is called. This was because the scroll is not tuttering when user is scrolling with their finger, it is only with the momentum scroll after you let go.
  • Store an instant version of the value while the actual shared value may be animated in which case the instant version can be checked to prevent stopping the animation.
  • Cancel shared value animation before starting another one with a withTiming.
  • Use a very fast duration of 1 for withTiming and an Easing.out(Easing.exp) easing to make the animation smooth but as fast as possible to prevent too much drift aka. fallling behind the actual position the component should be at.
  • Only apply Easing for changes of <= 2.7 as It seems the stuttering begins to occur from 2.66666666 and lower for the scroll position changes. Above can be seen at: https://github.com/kartikk221/react-native-collapsible-tab-view/blob/a9232a85fce3d9065ef8d6ce0661d18dcf22fe57/src/hooks.tsx#L388-L407

Video Demonstration: https://github.com/software-mansion/react-native-reanimated/assets/55661618/974f66d7-aefc-41c6-991d-6c5254f225e2

But yeah, I assure you there certainly is a bug but It seems to be hard to put into a Snack which can be re-created. I guess I could isolate out the code from our actual app as shown in the video and provide you a snack of that but not sure how to proceed from here.

kartikk221 avatar Jul 27 '23 16:07 kartikk221

Hey @tjzel I know this issue is stale at the moment due to a lack of your anyone on the team's response to the above. But If anyone else encounters this problem in the future, I have found a workaround which solves this issue:

useFrameCallback(() => {
   // This is an optimization which prevents stutter on slow momentum scrolling
});

I assumed the stutter was occuring due to refresh rate problems and my hypothesis was that iOS may be lowering the refresh rate on 120hz phones prematurely to 60hz for battery savings which visually looks really bad as motion is still occuring. By utilizing the useFrameCallback, I am assuming that reanimated / iOS is always providing the maximum frame rate (I verified this by checking the frame times in the info of the frame callback) which leads to no throttling of the refresh rate hence fixing the problem.

Now I know this solution is a hack and may not be ideal for battery savings and proper usage on iOS but it works for now and at least the Reanimated team has some leads to track down this problem for a fix. Hope this helps!

kartikk221 avatar Sep 21 '23 17:09 kartikk221

Hey @tjzel I know this issue is stale at the moment due to a lack of your anyone on the team's response to the above. But If anyone else encounters this problem in the future, I have found a workaround which solves this issue:

useFrameCallback(() => {
   // This is an optimization which prevents stutter on slow momentum scrolling
});

I assumed the stutter was occuring due to refresh rate problems and my hypothesis was that iOS may be lowering the refresh rate on 120hz phones prematurely to 60hz for battery savings which visually looks really bad as motion is still occuring. By utilizing the useFrameCallback, I am assuming that reanimated / iOS is always providing the maximum frame rate (I verified this by checking the frame times in the info of the frame callback) which leads to no throttling of the refresh rate hence fixing the problem.

Now I know this solution is a hack and may not be ideal for battery savings and proper usage on iOS but it works for now and at least the Reanimated team has some leads to track down this problem for a fix. Hope this helps!

tnx. i have the same probelm, any better solution?

ashkan-esz avatar Jun 20 '24 18:06 ashkan-esz