From 10314fe621e1649654e83df197adf657e0ca8363 Mon Sep 17 00:00:00 2001 From: Joshua Gross Date: Tue, 5 May 2020 22:24:23 -0700 Subject: [PATCH] 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 --- .../scroll/ReactHorizontalScrollView.java | 80 +++++++++++++++++-- .../react/views/scroll/ReactScrollView.java | 78 +++++++++++++++++- .../views/scroll/ReactScrollViewHelper.java | 47 +++++++++++ 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index c905c5dc9a4..1b2c5312381 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -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); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index f0d4e996585..7a77b954415 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -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); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java index 3f7b829d4ee..5183f24c44b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java @@ -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; + } + } }