mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
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:
committed by
Facebook GitHub Bot
parent
edfd965c46
commit
10314fe621
+75
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user