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 c905c5dc9a4f37..1b2c5312381c47 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 void run() { 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 @@ private int predictFinalScrollPosition(int velocityX) { 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 @@ private int predictFinalScrollPosition(int velocityX) { } /** - * 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 void setBorderStyle(@Nullable String style) { * 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 f0d4e99658553a..7a77b954415cda 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 void run() { 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 @@ private int predictFinalScrollPosition(int velocityY) { 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 @@ private int predictFinalScrollPosition(int velocityY) { */ 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 void onChildViewRemoved(View parent, View child) { * 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 3f7b829d4eea08..5183f24c44bd80 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 static int parseOverScrollMode(String jsOverScrollMode) { 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; + } + } }