Skip to content

Commit 10314fe

Browse files
JoshuaGrossfacebook-github-bot
authored andcommitted
Smoother scrolling in ScrollView, HorizontalScrollView
Summary: Android ScrollView/HorizontalScrollView `smoothScrollTo` contains some logic that, if called multiple times in a short amount of time, will treat all calls as part of the same animation and will not lengthen the duration of the animation. This means that, for example, if the user is scrolling rapidly, multiple pages could be considered part of one animation, causing some page animations to be animated very rapidly - looking like they're not animated at all. We use a custom animation to perform `smoothScrollTo` to improve the UX. This resolves a longstanding issue in non-Fabric RN, as well as Fabric, since this code is shared between the platforms. Changelog: [Update] Android ScrollView/HorizontalScrollView scrolls using custom animations instead of default Android `smoothScrollTo` implementation, leading to smoother scrolls for paginated ScrollViews Reviewed By: mdvacca Differential Revision: D21416520 fbshipit-source-id: 6ebe63cb054a98336b6e81253d35623fe5522f89
1 parent edfd965 commit 10314fe

File tree

3 files changed

+196
-9
lines changed

3 files changed

+196
-9
lines changed

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java

+75-5
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.animation.Animator;
11+
import android.animation.ObjectAnimator;
12+
import android.animation.PropertyValuesHolder;
13+
import android.animation.ValueAnimator;
1014
import android.content.Context;
1115
import android.graphics.Canvas;
1216
import android.graphics.Color;
1317
import android.graphics.Rect;
1418
import android.graphics.drawable.ColorDrawable;
1519
import android.graphics.drawable.Drawable;
20+
import android.os.Build;
1621
import android.view.FocusFinder;
1722
import android.view.KeyEvent;
1823
import android.view.MotionEvent;
@@ -82,6 +87,10 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
8287
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
8388
private @Nullable StateWrapper mStateWrapper;
8489

90+
private @Nullable ValueAnimator mScrollAnimator;
91+
private int mFinalAnimatedPositionScrollX = 0;
92+
private int mFinalAnimatedPositionScrollY = 0;
93+
8594
private final Rect mTempRect = new Rect();
8695

8796
public ReactHorizontalScrollView(Context context) {
@@ -648,6 +657,20 @@ public void run() {
648657
ReactHorizontalScrollView.this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY);
649658
}
650659

660+
/** Get current X position or position after current animation finishes, if any. */
661+
private int getPostAnimationScrollX() {
662+
return mScrollAnimator != null && mScrollAnimator.isRunning()
663+
? mFinalAnimatedPositionScrollX
664+
: getScrollX();
665+
}
666+
667+
/** Get current X position or position after current animation finishes, if any. */
668+
private int getPostAnimationScrollY() {
669+
return mScrollAnimator != null && mScrollAnimator.isRunning()
670+
? mFinalAnimatedPositionScrollY
671+
: getScrollY();
672+
}
673+
651674
private int predictFinalScrollPosition(int velocityX) {
652675
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
653676
// no way to customize the scroll duration. So, we create a temporary OverScroller
@@ -659,8 +682,8 @@ private int predictFinalScrollPosition(int velocityX) {
659682
int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
660683
int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this);
661684
scroller.fling(
662-
getScrollX(), // startX
663-
getScrollY(), // startY
685+
getPostAnimationScrollX(), // startX
686+
getPostAnimationScrollY(), // startY
664687
velocityX, // velocityX
665688
0, // velocityY
666689
0, // minX
@@ -674,13 +697,13 @@ private int predictFinalScrollPosition(int velocityX) {
674697
}
675698

676699
/**
677-
* This will smooth scroll us to the nearest snap offset point It currently just looks at where
700+
* This will smooth scroll us to the nearest snap offset point. It currently just looks at where
678701
* the content is and slides to the nearest point. It is intended to be run after we are done
679702
* scrolling, and handling any momentum scrolling.
680703
*/
681704
private void smoothScrollAndSnap(int velocity) {
682705
double interval = (double) getSnapInterval();
683-
double currentOffset = (double) getScrollX();
706+
double currentOffset = (double) (getPostAnimationScrollX());
684707
double targetOffset = (double) predictFinalScrollPosition(velocity);
685708

686709
int previousPage = (int) Math.floor(currentOffset / interval);
@@ -914,7 +937,54 @@ public void setBorderStyle(@Nullable String style) {
914937
* scroll view and state. Calling raw `smoothScrollTo` doesn't update state.
915938
*/
916939
public void reactSmoothScrollTo(int x, int y) {
917-
smoothScrollTo(x, y);
940+
// `smoothScrollTo` contains some logic that, if called multiple times in a short amount of
941+
// time, will treat all calls as part of the same animation and will not lengthen the duration
942+
// of the animation. This means that, for example, if the user is scrolling rapidly, multiple
943+
// pages could be considered part of one animation, causing some page animations to be animated
944+
// very rapidly - looking like they're not animated at all.
945+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
946+
if (mScrollAnimator != null) {
947+
mScrollAnimator.cancel();
948+
}
949+
950+
mFinalAnimatedPositionScrollX = x;
951+
mFinalAnimatedPositionScrollY = y;
952+
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x);
953+
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y);
954+
mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY);
955+
mScrollAnimator.setDuration(
956+
ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()));
957+
mScrollAnimator.addUpdateListener(
958+
new ValueAnimator.AnimatorUpdateListener() {
959+
@Override
960+
public void onAnimationUpdate(ValueAnimator valueAnimator) {
961+
int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX");
962+
int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY");
963+
ReactHorizontalScrollView.this.scrollTo(scrollValueX, scrollValueY);
964+
}
965+
});
966+
mScrollAnimator.addListener(
967+
new Animator.AnimatorListener() {
968+
@Override
969+
public void onAnimationStart(Animator animator) {}
970+
971+
@Override
972+
public void onAnimationEnd(Animator animator) {
973+
mFinalAnimatedPositionScrollX = -1;
974+
mFinalAnimatedPositionScrollY = -1;
975+
mScrollAnimator = null;
976+
}
977+
978+
@Override
979+
public void onAnimationCancel(Animator animator) {}
980+
981+
@Override
982+
public void onAnimationRepeat(Animator animator) {}
983+
});
984+
mScrollAnimator.start();
985+
} else {
986+
smoothScrollTo(x, y);
987+
}
918988
updateStateOnScroll(x, y);
919989
setPendingContentOffsets(x, y);
920990
}

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java

+74-4
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.animation.Animator;
11+
import android.animation.ObjectAnimator;
12+
import android.animation.PropertyValuesHolder;
13+
import android.animation.ValueAnimator;
1014
import android.graphics.Canvas;
1115
import android.graphics.Color;
1216
import android.graphics.Rect;
1317
import android.graphics.drawable.ColorDrawable;
1418
import android.graphics.drawable.Drawable;
19+
import android.os.Build;
1520
import android.view.KeyEvent;
1621
import android.view.MotionEvent;
1722
import android.view.View;
@@ -87,6 +92,10 @@ public class ReactScrollView extends ScrollView
8792
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
8893
private @Nullable StateWrapper mStateWrapper;
8994

95+
private @Nullable ValueAnimator mScrollAnimator;
96+
private int mFinalAnimatedPositionScrollX;
97+
private int mFinalAnimatedPositionScrollY;
98+
9099
public ReactScrollView(ReactContext context) {
91100
this(context, null);
92101
}
@@ -536,6 +545,20 @@ public void run() {
536545
ReactScrollView.this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY);
537546
}
538547

548+
/** Get current X position or position after current animation finishes, if any. */
549+
private int getPostAnimationScrollX() {
550+
return mScrollAnimator != null && mScrollAnimator.isRunning()
551+
? mFinalAnimatedPositionScrollX
552+
: getScrollX();
553+
}
554+
555+
/** Get current X position or position after current animation finishes, if any. */
556+
private int getPostAnimationScrollY() {
557+
return mScrollAnimator != null && mScrollAnimator.isRunning()
558+
? mFinalAnimatedPositionScrollY
559+
: getScrollY();
560+
}
561+
539562
private int predictFinalScrollPosition(int velocityY) {
540563
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
541564
// no way to customize the scroll duration. So, we create a temporary OverScroller
@@ -547,8 +570,8 @@ private int predictFinalScrollPosition(int velocityY) {
547570
int maximumOffset = getMaxScrollY();
548571
int height = getHeight() - getPaddingBottom() - getPaddingTop();
549572
scroller.fling(
550-
getScrollX(), // startX
551-
getScrollY(), // startY
573+
getPostAnimationScrollX(), // startX
574+
getPostAnimationScrollY(), // startY
552575
0, // velocityX
553576
velocityY, // velocityY
554577
0, // minX
@@ -568,7 +591,7 @@ private int predictFinalScrollPosition(int velocityY) {
568591
*/
569592
private void smoothScrollAndSnap(int velocity) {
570593
double interval = (double) getSnapInterval();
571-
double currentOffset = (double) getScrollY();
594+
double currentOffset = (double) getPostAnimationScrollY();
572595
double targetOffset = (double) predictFinalScrollPosition(velocity);
573596

574597
int previousPage = (int) Math.floor(currentOffset / interval);
@@ -785,7 +808,54 @@ public void onChildViewRemoved(View parent, View child) {
785808
* scroll view and state. Calling raw `smoothScrollTo` doesn't update state.
786809
*/
787810
public void reactSmoothScrollTo(int x, int y) {
788-
smoothScrollTo(x, y);
811+
// `smoothScrollTo` contains some logic that, if called multiple times in a short amount of
812+
// time, will treat all calls as part of the same animation and will not lengthen the duration
813+
// of the animation. This means that, for example, if the user is scrolling rapidly, multiple
814+
// pages could be considered part of one animation, causing some page animations to be animated
815+
// very rapidly - looking like they're not animated at all.
816+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
817+
if (mScrollAnimator != null) {
818+
mScrollAnimator.cancel();
819+
}
820+
821+
mFinalAnimatedPositionScrollX = x;
822+
mFinalAnimatedPositionScrollY = y;
823+
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x);
824+
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y);
825+
mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY);
826+
mScrollAnimator.setDuration(
827+
ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()));
828+
mScrollAnimator.addUpdateListener(
829+
new ValueAnimator.AnimatorUpdateListener() {
830+
@Override
831+
public void onAnimationUpdate(ValueAnimator valueAnimator) {
832+
int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX");
833+
int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY");
834+
ReactScrollView.this.scrollTo(scrollValueX, scrollValueY);
835+
}
836+
});
837+
mScrollAnimator.addListener(
838+
new Animator.AnimatorListener() {
839+
@Override
840+
public void onAnimationStart(Animator animator) {}
841+
842+
@Override
843+
public void onAnimationEnd(Animator animator) {
844+
mFinalAnimatedPositionScrollX = -1;
845+
mFinalAnimatedPositionScrollY = -1;
846+
mScrollAnimator = null;
847+
}
848+
849+
@Override
850+
public void onAnimationCancel(Animator animator) {}
851+
852+
@Override
853+
public void onAnimationRepeat(Animator animator) {}
854+
});
855+
mScrollAnimator.start();
856+
} else {
857+
smoothScrollTo(x, y);
858+
}
789859
updateStateOnScroll(x, y);
790860
setPendingContentOffsets(x, y);
791861
}

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java

+47
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.content.Context;
1011
import android.view.View;
1112
import android.view.ViewGroup;
13+
import android.widget.OverScroller;
1214
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
1315
import com.facebook.react.bridge.ReactContext;
1416
import com.facebook.react.uimanager.UIManagerHelper;
@@ -21,6 +23,12 @@ public class ReactScrollViewHelper {
2123
public static final String AUTO = "auto";
2224
public static final String OVER_SCROLL_NEVER = "never";
2325

26+
// If all else fails, this is the hardcoded value in OverScroller.java, in AOSP.
27+
// The default is defined here (as of this diff):
28+
// https://android.googlesource.com/platform/frameworks/base/+/ae5bcf23b5f0875e455790d6af387184dbd009c1/core/java/android/widget/OverScroller.java#44
29+
private static int SMOOTH_SCROLL_DURATION = 250;
30+
private static boolean mSmoothScrollDurationInitialized = false;
31+
2432
/** Shared by {@link ReactScrollView} and {@link ReactHorizontalScrollView}. */
2533
public static void emitScrollEvent(ViewGroup scrollView, float xVelocity, float yVelocity) {
2634
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity);
@@ -83,4 +91,43 @@ public static int parseOverScrollMode(String jsOverScrollMode) {
8391
throw new JSApplicationIllegalArgumentException("wrong overScrollMode: " + jsOverScrollMode);
8492
}
8593
}
94+
95+
public static int getDefaultScrollAnimationDuration(Context context) {
96+
if (!mSmoothScrollDurationInitialized) {
97+
mSmoothScrollDurationInitialized = true;
98+
99+
try {
100+
OverScrollerDurationGetter overScrollerDurationGetter =
101+
new OverScrollerDurationGetter(context);
102+
SMOOTH_SCROLL_DURATION = overScrollerDurationGetter.getScrollAnimationDuration();
103+
} catch (Throwable e) {
104+
}
105+
}
106+
107+
return SMOOTH_SCROLL_DURATION;
108+
}
109+
110+
private static class OverScrollerDurationGetter extends OverScroller {
111+
// This is the default in AOSP, hardcoded in OverScroller.java.
112+
private int mScrollAnimationDuration = 250;
113+
114+
OverScrollerDurationGetter(Context context) {
115+
// We call with a null context because OverScroller does not use the context
116+
// in the execution path we're interested in, unless heavily modified in an AOSP fork.
117+
super(context);
118+
}
119+
120+
public int getScrollAnimationDuration() {
121+
// If startScroll is called without a duration, OverScroller will call `startScroll(x, y, dx,
122+
// dy, duration)` with the default duration.
123+
super.startScroll(0, 0, 0, 0);
124+
125+
return mScrollAnimationDuration;
126+
}
127+
128+
@Override
129+
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
130+
mScrollAnimationDuration = duration;
131+
}
132+
}
86133
}

0 commit comments

Comments
 (0)