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
This commit is contained in:
Joshua Gross
2020-05-05 22:27:47 -07:00
committed by Facebook GitHub Bot
parent edfd965c46
commit 10314fe621
3 changed files with 196 additions and 9 deletions
@@ -7,12 +7,17 @@
package com.facebook.react.views.scroll;
import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.view.FocusFinder;
import android.view.KeyEvent;
import android.view.MotionEvent;
@@ -82,6 +87,10 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
private @Nullable StateWrapper mStateWrapper;
private @Nullable ValueAnimator mScrollAnimator;
private int mFinalAnimatedPositionScrollX = 0;
private int mFinalAnimatedPositionScrollY = 0;
private final Rect mTempRect = new Rect();
public ReactHorizontalScrollView(Context context) {
@@ -648,6 +657,20 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
ReactHorizontalScrollView.this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY);
}
/** Get current X position or position after current animation finishes, if any. */
private int getPostAnimationScrollX() {
return mScrollAnimator != null && mScrollAnimator.isRunning()
? mFinalAnimatedPositionScrollX
: getScrollX();
}
/** Get current X position or position after current animation finishes, if any. */
private int getPostAnimationScrollY() {
return mScrollAnimator != null && mScrollAnimator.isRunning()
? mFinalAnimatedPositionScrollY
: getScrollY();
}
private int predictFinalScrollPosition(int velocityX) {
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
// no way to customize the scroll duration. So, we create a temporary OverScroller
@@ -659,8 +682,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this);
scroller.fling(
getScrollX(), // startX
getScrollY(), // startY
getPostAnimationScrollX(), // startX
getPostAnimationScrollY(), // startY
velocityX, // velocityX
0, // velocityY
0, // minX
@@ -674,13 +697,13 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
}
/**
* This will smooth scroll us to the nearest snap offset point It currently just looks at where
* This will smooth scroll us to the nearest snap offset point. It currently just looks at where
* the content is and slides to the nearest point. It is intended to be run after we are done
* scrolling, and handling any momentum scrolling.
*/
private void smoothScrollAndSnap(int velocity) {
double interval = (double) getSnapInterval();
double currentOffset = (double) getScrollX();
double currentOffset = (double) (getPostAnimationScrollX());
double targetOffset = (double) predictFinalScrollPosition(velocity);
int previousPage = (int) Math.floor(currentOffset / interval);
@@ -914,7 +937,54 @@ public class ReactHorizontalScrollView extends HorizontalScrollView
* scroll view and state. Calling raw `smoothScrollTo` doesn't update state.
*/
public void reactSmoothScrollTo(int x, int y) {
smoothScrollTo(x, y);
// `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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
if (mScrollAnimator != null) {
mScrollAnimator.cancel();
}
mFinalAnimatedPositionScrollX = x;
mFinalAnimatedPositionScrollY = y;
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x);
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y);
mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY);
mScrollAnimator.setDuration(
ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()));
mScrollAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX");
int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY");
ReactHorizontalScrollView.this.scrollTo(scrollValueX, scrollValueY);
}
});
mScrollAnimator.addListener(
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {}
@Override
public void onAnimationEnd(Animator animator) {
mFinalAnimatedPositionScrollX = -1;
mFinalAnimatedPositionScrollY = -1;
mScrollAnimator = null;
}
@Override
public void onAnimationCancel(Animator animator) {}
@Override
public void onAnimationRepeat(Animator animator) {}
});
mScrollAnimator.start();
} else {
smoothScrollTo(x, y);
}
updateStateOnScroll(x, y);
setPendingContentOffsets(x, y);
}
@@ -7,11 +7,16 @@
package com.facebook.react.views.scroll;
import android.animation.Animator;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.animation.ValueAnimator;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
@@ -87,6 +92,10 @@ public class ReactScrollView extends ScrollView
private int pendingContentOffsetY = UNSET_CONTENT_OFFSET;
private @Nullable StateWrapper mStateWrapper;
private @Nullable ValueAnimator mScrollAnimator;
private int mFinalAnimatedPositionScrollX;
private int mFinalAnimatedPositionScrollY;
public ReactScrollView(ReactContext context) {
this(context, null);
}
@@ -536,6 +545,20 @@ public class ReactScrollView extends ScrollView
ReactScrollView.this, mPostTouchRunnable, ReactScrollViewHelper.MOMENTUM_DELAY);
}
/** Get current X position or position after current animation finishes, if any. */
private int getPostAnimationScrollX() {
return mScrollAnimator != null && mScrollAnimator.isRunning()
? mFinalAnimatedPositionScrollX
: getScrollX();
}
/** Get current X position or position after current animation finishes, if any. */
private int getPostAnimationScrollY() {
return mScrollAnimator != null && mScrollAnimator.isRunning()
? mFinalAnimatedPositionScrollY
: getScrollY();
}
private int predictFinalScrollPosition(int velocityY) {
// ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's
// no way to customize the scroll duration. So, we create a temporary OverScroller
@@ -547,8 +570,8 @@ public class ReactScrollView extends ScrollView
int maximumOffset = getMaxScrollY();
int height = getHeight() - getPaddingBottom() - getPaddingTop();
scroller.fling(
getScrollX(), // startX
getScrollY(), // startY
getPostAnimationScrollX(), // startX
getPostAnimationScrollY(), // startY
0, // velocityX
velocityY, // velocityY
0, // minX
@@ -568,7 +591,7 @@ public class ReactScrollView extends ScrollView
*/
private void smoothScrollAndSnap(int velocity) {
double interval = (double) getSnapInterval();
double currentOffset = (double) getScrollY();
double currentOffset = (double) getPostAnimationScrollY();
double targetOffset = (double) predictFinalScrollPosition(velocity);
int previousPage = (int) Math.floor(currentOffset / interval);
@@ -785,7 +808,54 @@ public class ReactScrollView extends ScrollView
* scroll view and state. Calling raw `smoothScrollTo` doesn't update state.
*/
public void reactSmoothScrollTo(int x, int y) {
smoothScrollTo(x, y);
// `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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
if (mScrollAnimator != null) {
mScrollAnimator.cancel();
}
mFinalAnimatedPositionScrollX = x;
mFinalAnimatedPositionScrollY = y;
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", getScrollX(), x);
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", getScrollY(), y);
mScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY);
mScrollAnimator.setDuration(
ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext()));
mScrollAnimator.addUpdateListener(
new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX");
int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY");
ReactScrollView.this.scrollTo(scrollValueX, scrollValueY);
}
});
mScrollAnimator.addListener(
new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {}
@Override
public void onAnimationEnd(Animator animator) {
mFinalAnimatedPositionScrollX = -1;
mFinalAnimatedPositionScrollY = -1;
mScrollAnimator = null;
}
@Override
public void onAnimationCancel(Animator animator) {}
@Override
public void onAnimationRepeat(Animator animator) {}
});
mScrollAnimator.start();
} else {
smoothScrollTo(x, y);
}
updateStateOnScroll(x, y);
setPendingContentOffsets(x, y);
}
@@ -7,8 +7,10 @@
package com.facebook.react.views.scroll;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.OverScroller;
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.uimanager.UIManagerHelper;
@@ -21,6 +23,12 @@ public class ReactScrollViewHelper {
public static final String AUTO = "auto";
public static final String OVER_SCROLL_NEVER = "never";
// If all else fails, this is the hardcoded value in OverScroller.java, in AOSP.
// The default is defined here (as of this diff):
// https://android.googlesource.com/platform/frameworks/base/+/ae5bcf23b5f0875e455790d6af387184dbd009c1/core/java/android/widget/OverScroller.java#44
private static int SMOOTH_SCROLL_DURATION = 250;
private static boolean mSmoothScrollDurationInitialized = false;
/** Shared by {@link ReactScrollView} and {@link ReactHorizontalScrollView}. */
public static void emitScrollEvent(ViewGroup scrollView, float xVelocity, float yVelocity) {
emitScrollEvent(scrollView, ScrollEventType.SCROLL, xVelocity, yVelocity);
@@ -83,4 +91,43 @@ public class ReactScrollViewHelper {
throw new JSApplicationIllegalArgumentException("wrong overScrollMode: " + jsOverScrollMode);
}
}
public static int getDefaultScrollAnimationDuration(Context context) {
if (!mSmoothScrollDurationInitialized) {
mSmoothScrollDurationInitialized = true;
try {
OverScrollerDurationGetter overScrollerDurationGetter =
new OverScrollerDurationGetter(context);
SMOOTH_SCROLL_DURATION = overScrollerDurationGetter.getScrollAnimationDuration();
} catch (Throwable e) {
}
}
return SMOOTH_SCROLL_DURATION;
}
private static class OverScrollerDurationGetter extends OverScroller {
// This is the default in AOSP, hardcoded in OverScroller.java.
private int mScrollAnimationDuration = 250;
OverScrollerDurationGetter(Context context) {
// We call with a null context because OverScroller does not use the context
// in the execution path we're interested in, unless heavily modified in an AOSP fork.
super(context);
}
public int getScrollAnimationDuration() {
// If startScroll is called without a duration, OverScroller will call `startScroll(x, y, dx,
// dy, duration)` with the default duration.
super.startScroll(0, 0, 0, 0);
return mScrollAnimationDuration;
}
@Override
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mScrollAnimationDuration = duration;
}
}
}