Skip to content

Commit

Permalink
Smoother scrolling in ScrollView, HorizontalScrollView
Browse files Browse the repository at this point in the history
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
  • Loading branch information
JoshuaGross authored and facebook-github-bot committed May 6, 2020
1 parent edfd965 commit 10314fe
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 9 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
}
}

0 comments on commit 10314fe

Please sign in to comment.