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 38a3fdb4d39..c40b224774f 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 @@ -13,6 +13,8 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -44,7 +46,9 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper; import com.facebook.react.uimanager.ReactOverflowView; import com.facebook.react.uimanager.ViewProps; import com.facebook.react.uimanager.events.NativeGestureUtil; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollDirection; import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; import java.lang.reflect.Field; @@ -56,7 +60,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements ReactClippingViewGroup, FabricViewStateManager.HasFabricViewStateManager, ReactOverflowView, - HasScrollState { + HasScrollState, + HasFlingAnimator { private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG; private static String TAG = ReactHorizontalScrollView.class.getSimpleName(); @@ -101,6 +106,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView private int pendingContentOffsetY = UNSET_CONTENT_OFFSET; private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager(); private final ReactScrollViewScrollState mReactScrollViewScrollState; + private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollX", 0, 0); private final Rect mTempRect = new Rect(); @@ -135,7 +141,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView new ReactScrollViewScrollState( I18nUtil.getInstance().isRTL(context) ? ViewCompat.LAYOUT_DIRECTION_RTL - : ViewCompat.LAYOUT_DIRECTION_LTR); + : ViewCompat.LAYOUT_DIRECTION_LTR, + ReactScrollViewScrollDirection.HORIZONTAL); } @Nullable @@ -899,6 +906,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView return; } + boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR; int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); int targetOffset = predictFinalScrollPosition(velocityX); if (mDisableIntervalMomentum) { @@ -1008,13 +1016,19 @@ public class ReactHorizontalScrollView extends HorizontalScrollView targetOffset = firstOffset; } } else if (velocityX > 0) { - // when snapping velocity can feel sluggish for slow swipes - velocityX += (int) ((largerOffset - targetOffset) * 10.0); + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityX += (int) ((largerOffset - targetOffset) * 10.0); + } targetOffset = largerOffset; } else if (velocityX < 0) { - // when snapping velocity can feel sluggish for slow swipes - velocityX -= (int) ((targetOffset - smallerOffset) * 10.0); + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityX -= (int) ((targetOffset - smallerOffset) * 10.0); + } targetOffset = smallerOffset; } else { @@ -1029,11 +1043,13 @@ public class ReactHorizontalScrollView extends HorizontalScrollView velocityX = -velocityX; } - // smoothScrollTo will always scroll over 250ms which is often *waaay* - // too short and will cause the scrolling to feel almost instant - // try to manually interact with OverScroller instead - // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo - if (mScroller != null) { + if (hasCustomizedFlingAnimator || mScroller == null) { + reactSmoothScrollTo(targetOffset, getScrollY()); + } else { + // smoothScrollTo will always scroll over 250ms which is often *waaay* + // too short and will cause the scrolling to feel almost instant + // try to manually interact with OverScroller instead + // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo mActivelyScrolling = true; mScroller.fling( @@ -1055,8 +1071,6 @@ public class ReactHorizontalScrollView extends HorizontalScrollView ); postInvalidateOnAnimation(); - } else { - reactSmoothScrollTo(targetOffset, getScrollY()); } } @@ -1196,4 +1210,28 @@ public class ReactHorizontalScrollView extends HorizontalScrollView public ReactScrollViewScrollState getReactScrollViewScrollState() { return mReactScrollViewScrollState; } + + @Override + public void startFlingAnimator(int start, int end) { + // Always cancel existing animator before starting the new one. `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. + DEFAULT_FLING_ANIMATOR.cancel(); + + // Update the fling animator with new values + DEFAULT_FLING_ANIMATOR + .setDuration(ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext())) + .setIntValues(start, end); + + // Start the animator + DEFAULT_FLING_ANIMATOR.start(); + } + + @Override + public ValueAnimator getFlingAnimator() { + return DEFAULT_FLING_ANIMATOR; + } } 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 2305514a4dc..98abcb48983 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 @@ -13,6 +13,8 @@ import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNME import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_END; import static com.facebook.react.views.scroll.ReactScrollViewHelper.SNAP_ALIGNMENT_START; +import android.animation.ObjectAnimator; +import android.animation.ValueAnimator; import android.content.Context; import android.graphics.Canvas; import android.graphics.Color; @@ -40,7 +42,9 @@ import com.facebook.react.uimanager.ReactClippingViewGroupHelper; import com.facebook.react.uimanager.ReactOverflowView; import com.facebook.react.uimanager.ViewProps; import com.facebook.react.uimanager.events.NativeGestureUtil; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollDirection; import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; import java.lang.reflect.Field; @@ -59,7 +63,8 @@ public class ReactScrollView extends ScrollView View.OnLayoutChangeListener, FabricViewStateManager.HasFabricViewStateManager, ReactOverflowView, - HasScrollState { + HasScrollState, + HasFlingAnimator { private static @Nullable Field sScrollerField; private static boolean sTriedToGetScrollerField = false; @@ -97,7 +102,9 @@ public class ReactScrollView extends ScrollView private int pendingContentOffsetY = UNSET_CONTENT_OFFSET; private final FabricViewStateManager mFabricViewStateManager = new FabricViewStateManager(); private final ReactScrollViewScrollState mReactScrollViewScrollState = - new ReactScrollViewScrollState(ViewCompat.LAYOUT_DIRECTION_LTR); + new ReactScrollViewScrollState( + ViewCompat.LAYOUT_DIRECTION_LTR, ReactScrollViewScrollDirection.VERTICAL); + private final ValueAnimator DEFAULT_FLING_ANIMATOR = ObjectAnimator.ofInt(this, "scrollY", 0, 0); public ReactScrollView(Context context) { this(context, null); @@ -684,6 +691,7 @@ public class ReactScrollView extends ScrollView return; } + boolean hasCustomizedFlingAnimator = getFlingAnimator() != DEFAULT_FLING_ANIMATOR; int maximumOffset = getMaxScrollY(); int targetOffset = predictFinalScrollPosition(velocityY); if (mDisableIntervalMomentum) { @@ -795,13 +803,19 @@ public class ReactScrollView extends ScrollView targetOffset = firstOffset; } } else if (velocityY > 0) { - // when snapping velocity can feel sluggish for slow swipes - velocityY += (int) ((largerOffset - targetOffset) * 10.0); + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityY += (int) ((largerOffset - targetOffset) * 10.0); + } targetOffset = largerOffset; } else if (velocityY < 0) { - // when snapping velocity can feel sluggish for slow swipes - velocityY -= (int) ((targetOffset - smallerOffset) * 10.0); + if (!hasCustomizedFlingAnimator) { + // The default animator requires boost on initial velocity as when snapping velocity can + // feel sluggish for slow swipes + velocityY -= (int) ((targetOffset - smallerOffset) * 10.0); + } targetOffset = smallerOffset; } else { @@ -811,11 +825,13 @@ public class ReactScrollView extends ScrollView // Make sure the new offset isn't out of bounds targetOffset = Math.min(Math.max(0, targetOffset), maximumOffset); - // smoothScrollTo will always scroll over 250ms which is often *waaay* - // too short and will cause the scrolling to feel almost instant - // try to manually interact with OverScroller instead - // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo - if (mScroller != null) { + if (hasCustomizedFlingAnimator || mScroller == null) { + reactSmoothScrollTo(getScrollX(), targetOffset); + } else { + // smoothScrollTo will always scroll over 250ms which is often *waaay* + // too short and will cause the scrolling to feel almost instant + // try to manually interact with OverScroller instead + // if velocity is 0 however, fling() won't work, so we want to use smoothScrollTo mActivelyScrolling = true; mScroller.fling( @@ -837,8 +853,6 @@ public class ReactScrollView extends ScrollView ); postInvalidateOnAnimation(); - } else { - reactSmoothScrollTo(getScrollX(), targetOffset); } } @@ -1060,4 +1074,28 @@ public class ReactScrollView extends ScrollView public ReactScrollViewScrollState getReactScrollViewScrollState() { return mReactScrollViewScrollState; } + + @Override + public void startFlingAnimator(int start, int end) { + // Always cancel existing animator before starting the new one. `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. + DEFAULT_FLING_ANIMATOR.cancel(); + + // Update the fling animator with new values + DEFAULT_FLING_ANIMATOR + .setDuration(ReactScrollViewHelper.getDefaultScrollAnimationDuration(getContext())) + .setIntValues(start, end); + + // Start the animator + DEFAULT_FLING_ANIMATOR.start(); + } + + @Override + public ValueAnimator getFlingAnimator() { + return DEFAULT_FLING_ANIMATOR; + } } 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 7782d82283e..5dc993662d2 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 @@ -8,8 +8,6 @@ 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.Point; @@ -50,8 +48,6 @@ public class ReactScrollViewHelper { public static final int SNAP_ALIGNMENT_CENTER = 2; public static final int SNAP_ALIGNMENT_END = 3; - private static @Nullable ValueAnimator sScrollAnimator; - public interface ScrollListener { void onScroll( ViewGroup scrollView, ScrollEventType scrollEventType, float xVelocity, float yVelocity); @@ -219,14 +215,22 @@ public class ReactScrollViewHelper { sScrollListeners.remove(listener); } + public enum ReactScrollViewScrollDirection { + HORIZONTAL, + VERTICAL, + } + public static class ReactScrollViewScrollState { private final int mLayoutDirection; + private final ReactScrollViewScrollDirection mScrollDirection; private final Point mFinalAnimatedPositionScroll = new Point(); private int mScrollAwayPaddingTop = 0; private final Point mLastStateUpdateScroll = new Point(-1, -1); - public ReactScrollViewScrollState(final int layoutDirection) { + public ReactScrollViewScrollState( + final int layoutDirection, final ReactScrollViewScrollDirection scrollDirection) { mLayoutDirection = layoutDirection; + mScrollDirection = scrollDirection; } /** @@ -237,6 +241,14 @@ public class ReactScrollViewHelper { return mLayoutDirection; } + /** + * Get the scroll direction. Can be either ReactScrollViewScrollDirection.HORIZONTAL or + * ReactScrollViewScrollDirection.VERTICAL. + */ + public ReactScrollViewScrollDirection getScrollDirection() { + return mScrollDirection; + } + /** Get the position after current animation is finished */ public Point getFinalAnimatedPositionScroll() { return mFinalAnimatedPositionScroll; @@ -273,72 +285,58 @@ public class ReactScrollViewHelper { } } + /** + * Scroll the given view to the location (x, y), with provided initial velocity. This method works + * by calculate the "would be" initial velocity with internal friction to move to the point (x, + * y), then apply that to the animator. + */ public static < - T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> void smoothScrollTo(final T scrollView, final int x, final int y) { if (DEBUG_MODE) { FLog.i(TAG, "smoothScrollTo[%d] x %d y %d", scrollView.getId(), 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 (sScrollAnimator != null) { - sScrollAnimator.cancel(); + // Register the listeners for the fling animator if there isn't any + final ValueAnimator flingAnimator = scrollView.getFlingAnimator(); + if (flingAnimator.getListeners() == null || flingAnimator.getListeners().size() == 0) { + registerFlingAnimator(scrollView); } final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); scrollState.setFinalAnimatedPositionScroll(x, y); - PropertyValuesHolder scrollX = - PropertyValuesHolder.ofInt("scrollX", scrollView.getScrollX(), x); - PropertyValuesHolder scrollY = - PropertyValuesHolder.ofInt("scrollY", scrollView.getScrollY(), y); - sScrollAnimator = ObjectAnimator.ofPropertyValuesHolder(scrollX, scrollY); - sScrollAnimator.setDuration(getDefaultScrollAnimationDuration(scrollView.getContext())); - sScrollAnimator.addUpdateListener( - new ValueAnimator.AnimatorUpdateListener() { - @Override - public void onAnimationUpdate(ValueAnimator valueAnimator) { - int scrollValueX = (Integer) valueAnimator.getAnimatedValue("scrollX"); - int scrollValueY = (Integer) valueAnimator.getAnimatedValue("scrollY"); - scrollView.scrollTo(scrollValueX, scrollValueY); - } - }); - sScrollAnimator.addListener( - new Animator.AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) {} - @Override - public void onAnimationEnd(Animator animator) { - scrollState.setFinalAnimatedPositionScroll(-1, -1); - sScrollAnimator = null; - updateStateOnScroll(scrollView); - } + final int scrollX = scrollView.getScrollX(); + final int scrollY = scrollView.getScrollY(); + final ReactScrollViewScrollDirection scrollDirection = scrollState.getScrollDirection(); + if (scrollDirection == ReactScrollViewScrollDirection.HORIZONTAL && scrollX != x) { + scrollView.startFlingAnimator(scrollX, x); + } + if (scrollDirection == ReactScrollViewScrollDirection.VERTICAL && scrollY != y) { + scrollView.startFlingAnimator(scrollY, y); + } - @Override - public void onAnimationCancel(Animator animator) {} - - @Override - public void onAnimationRepeat(Animator animator) {} - }); - sScrollAnimator.start(); updateStateOnScroll(scrollView, x, y); } /** Get current (x, y) position or position after current animation finishes, if any. */ public static < - T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> Point getPostAnimationScroll(final T scrollView) { - return sScrollAnimator != null && sScrollAnimator.isRunning() + final ValueAnimator flingAnimator = scrollView.getFlingAnimator(); + return flingAnimator != null && flingAnimator.isRunning() ? scrollView.getReactScrollViewScrollState().getFinalAnimatedPositionScroll() : new Point(scrollView.getScrollX(), scrollView.getScrollY()); } public static < - T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> boolean updateStateOnScroll(final T scrollView) { return updateStateOnScroll(scrollView, scrollView.getScrollX(), scrollView.getScrollY()); } @@ -347,7 +345,9 @@ public class ReactScrollViewHelper { * Called on any stabilized onScroll change to propagate content offset value to a Shadow Node. */ public static < - T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> boolean updateStateOnScroll(final T scrollView, final int scrollX, final int scrollY) { if (DEBUG_MODE) { FLog.i( @@ -374,7 +374,9 @@ public class ReactScrollViewHelper { } public static < - T extends ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState> + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> void forceUpdateState(final T scrollView) { final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState(); final int scrollAwayPaddingTop = scrollState.getScrollAwayPaddingTop(); @@ -420,8 +422,45 @@ public class ReactScrollViewHelper { }); } + public static < + T extends + ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState + & HasFlingAnimator> + void registerFlingAnimator(final T scrollView) { + scrollView + .getFlingAnimator() + .addListener( + new Animator.AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) {} + + @Override + public void onAnimationEnd(Animator animator) { + scrollView.getReactScrollViewScrollState().setFinalAnimatedPositionScroll(-1, -1); + ReactScrollViewHelper.updateStateOnScroll(scrollView); + } + + @Override + public void onAnimationCancel(Animator animator) {} + + @Override + public void onAnimationRepeat(Animator animator) {} + }); + } + public interface HasScrollState { /** Get the scroll state for the current ScrollView */ ReactScrollViewScrollState getReactScrollViewScrollState(); } + + public interface HasFlingAnimator { + /** + * Start the fling animator that the ScrollView has to go from the start position to end + * position. + */ + void startFlingAnimator(int start, int end); + + /** Get the fling animator that is reused for the ScrollView to handle fling animation. */ + ValueAnimator getFlingAnimator(); + } }