(Android/ScrollView) Fix onMomentumScrollEnd being called multiple times (#32433)

Summary:
I noticed that `onMomentumScrollEnd` is called multiple times on Android.

1. It is always called three times with the last value
2. It is sometimes called many times befire the scrolling stops when the pagingEnabled prop is true

See:
<img src="https://user-images.githubusercontent.com/17070498/137640334-301b32a7-3f59-403f-ba7e-a898666aaf3e.png" width="400"/>

I used the following code to get the logs:
```
import React from 'react';
import {SafeAreaView, ScrollView, Text, View} from 'react-native';

const App = () => {
  const onMomentumScrollEnd = ({nativeEvent}) => {
    console.log(
      'onMomentumScrollEnd',
      nativeEvent.contentOffset.x,
      nativeEvent.contentOffset.y,
    );
  };

  const onMomentumScrollBegin = ({nativeEvent}) => {
    console.log(
      'onMomentumScrollBegin',
      nativeEvent.contentOffset.x,
      nativeEvent.contentOffset.y,
    );
  };

  return (
    <SafeAreaView>
      <ScrollView
        horizontal
        pagingEnabled
        onMomentumScrollEnd={onMomentumScrollEnd}
        onMomentumScrollBegin={onMomentumScrollBegin}>
        {new Array(10).fill(0).map((_, index) => {
          return (
            <View
              style={{width: 400, height: 400, backgroundColor: 'red'}}
              key={index}>
              <Text>{index}</Text>
            </View>
          );
        })}
      </ScrollView>
    </SafeAreaView>
  );
};

export default App;

```

From what I understood:

1. We do not check that `mStableFrames` is >= 3 when emitting the event (line 798) and we keep executing the runnable, so it is emitted until `mStableFrames` equals  3. When `mStableFrames` equals 3 we stop executing the runnable (line 809). That's why it gets called 3 times.
2. When `pagingEnabled` is true, the `postOnAnimationDelayed` method is called twice (line 794 and line 806). I believe it causes the runnable to be executing too often, and the `onMomentumScrollEnd` event to be emitted too many times.

I updated the code so:

1.  The event is emitted only once, and at the same time we stop executing the runnable
2. The `postOnAnimationDelayed` method is called at most once per execution of the runnable

## Changelog

[Android] [Fixed] - Fix ScrollView's onMomentumScrollEnd being called multiple times on Android

Pull Request resolved: https://github.com/facebook/react-native/pull/32433

Test Plan: I tested using the code above with every combination of `horizontal` and `pagingEnabled` values.

Reviewed By: NickGerleman

Differential Revision: D47297163

Pulled By: ryancat

fbshipit-source-id: 7c31175d941ff13bed20dac03fb92d2b56e94dec
This commit is contained in:
AntoineDoubovetzky
2023-07-13 20:32:21 -07:00
committed by Facebook GitHub Bot
parent 6d206a3f54
commit 06668fcbac
2 changed files with 28 additions and 40 deletions
@@ -825,7 +825,6 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
new Runnable() {
private boolean mSnappingToPage = false;
private boolean mRunning = true;
private int mStableFrames = 0;
@Override
@@ -834,7 +833,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
// We are still scrolling.
mActivelyScrolling = false;
mStableFrames = 0;
mRunning = true;
ViewCompat.postOnAnimationDelayed(
ReactHorizontalScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
// There has not been a scroll update since the last time this Runnable executed.
ReactScrollViewHelper.updateFabricScrollState(ReactHorizontalScrollView.this);
@@ -847,31 +847,25 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
// fire before the first scroll event of an animated scroll/fling, and stop
// immediately.
mStableFrames++;
mRunning = (mStableFrames < 3);
if (mPagingEnabled && !mSnappingToPage) {
// Only if we have pagingEnabled and we have not snapped to the page do we
// need to continue checking for the scroll. And we cause that scroll by asking for
// it
mSnappingToPage = true;
flingAndSnap(0);
ViewCompat.postOnAnimationDelayed(
ReactHorizontalScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
if (mStableFrames >= 3) {
mPostTouchRunnable = null;
if (mSendMomentumEvents) {
ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactHorizontalScrollView.this);
}
disableFpsListener();
} else {
if (mPagingEnabled && !mSnappingToPage) {
// If we have pagingEnabled and we have not snapped to the page
// we need to cause that scroll by asking for it
mSnappingToPage = true;
flingAndSnap(0);
}
// The scrollview has not "stabilized" so we just post to check again a frame later
ViewCompat.postOnAnimationDelayed(
ReactHorizontalScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
}
}
// We are still scrolling so we just post to check again a frame later
if (mRunning) {
ViewCompat.postOnAnimationDelayed(
ReactHorizontalScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
mPostTouchRunnable = null;
}
}
};
ViewCompat.postOnAnimationDelayed(
@@ -645,7 +645,6 @@ public class ReactScrollView extends ScrollView
new Runnable() {
private boolean mSnappingToPage = false;
private boolean mRunning = true;
private int mStableFrames = 0;
@Override
@@ -654,7 +653,8 @@ public class ReactScrollView extends ScrollView
// We are still scrolling.
mActivelyScrolling = false;
mStableFrames = 0;
mRunning = true;
ViewCompat.postOnAnimationDelayed(
ReactScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
// There has not been a scroll update since the last time this Runnable executed.
ReactScrollViewHelper.updateFabricScrollState(ReactScrollView.this);
@@ -667,31 +667,25 @@ public class ReactScrollView extends ScrollView
// fire before the first scroll event of an animated scroll/fling, and stop
// immediately.
mStableFrames++;
mRunning = (mStableFrames < 3);
if (mPagingEnabled && !mSnappingToPage) {
// Only if we have pagingEnabled and we have not snapped to the page do we
// need to continue checking for the scroll. And we cause that scroll by asking for
// it
mSnappingToPage = true;
flingAndSnap(0);
ViewCompat.postOnAnimationDelayed(
ReactScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
if (mStableFrames >= 3) {
mPostTouchRunnable = null;
if (mSendMomentumEvents) {
ReactScrollViewHelper.emitScrollMomentumEndEvent(ReactScrollView.this);
}
disableFpsListener();
} else {
if (mPagingEnabled && !mSnappingToPage) {
// If we have pagingEnabled and we have not snapped to the page
// we need to cause that scroll by asking for it
mSnappingToPage = true;
flingAndSnap(0);
}
// The scrollview has not "stabilized" so we just post to check again a frame later
ViewCompat.postOnAnimationDelayed(
ReactScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
}
}
// We are still scrolling so we just post to check again a frame later
if (mRunning) {
ViewCompat.postOnAnimationDelayed(
ReactScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY);
} else {
mPostTouchRunnable = null;
}
}
};
ViewCompat.postOnAnimationDelayed(