From 7e8b810499117900118f0df64791dd8c3d52e699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Osadnik?= Date: Tue, 29 Jan 2019 07:10:37 -0800 Subject: [PATCH] Add ability to control scroll animation duration for Android (#22884) Summary: Motivation: ---------- This is one of the more sought after feature requests for RN: react-native.canny.io/feature-requests/p/add-speed-attribute-to-scrollto This PR adds the support to add a "duration" whenever using "scrollTo" or "scrollToEnd" with a scrollView. Currently this only exists for Android as the iOS implementation will be somewhat more involved. This PR is also backwards compatible and does not yet deprecate the "animated" boolean. It may not make sense to ever deprecate "animated", as it could be the flag that is used when devs want the system default duration (which is 250ms for Android). I'm not sure what it is for iOS. It would simplify things to remove "animated", though. Pull Request resolved: https://github.com/facebook/react-native/pull/22884 Differential Revision: D13860038 Pulled By: cpojer fbshipit-source-id: f06751d063a33d7046241c95348b6abbb327d36f --- Libraries/Components/ScrollResponder.js | 31 ++++++++++++++----- Libraries/Components/ScrollView/ScrollView.js | 21 ++++++++++--- React/Views/ScrollView/RCTScrollViewManager.m | 8 +++-- .../scroll/ReactHorizontalScrollView.java | 22 +++++++++++++ .../ReactHorizontalScrollViewManager.java | 18 ++++++++--- .../react/views/scroll/ReactScrollView.java | 22 +++++++++++++ .../scroll/ReactScrollViewCommandHelper.java | 15 ++++++--- .../views/scroll/ReactScrollViewHelper.java | 2 ++ .../views/scroll/ReactScrollViewManager.java | 18 ++++++++--- 9 files changed, 131 insertions(+), 26 deletions(-) diff --git a/Libraries/Components/ScrollResponder.js b/Libraries/Components/ScrollResponder.js index 6b37dadda3b..7b3256ad3e0 100644 --- a/Libraries/Components/ScrollResponder.js +++ b/Libraries/Components/ScrollResponder.js @@ -116,6 +116,14 @@ export type State = {| becameResponderWhileAnimating: boolean, |}; +/** + * If a user has specified a duration, we will use it. Otherwise, + * set it to -1 as the bridge cannot handle undefined / null values. + */ +function getDuration(duration?: number): number { + return duration === undefined ? -1 : Math.max(duration, 0); +} + const ScrollResponderMixin = { _subscriptionKeyboardWillShow: (null: ?EmitterSubscription), _subscriptionKeyboardWillHide: (null: ?EmitterSubscription), @@ -424,46 +432,55 @@ const ScrollResponderMixin = { * This is currently used to help focus child TextViews, but can also * be used to quickly scroll to any element we want to focus. Syntax: * - * `scrollResponderScrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true})` + * `scrollResponderScrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true, duration: number = 0})` * * Note: The weird argument signature is due to the fact that, for historical reasons, * the function also accepts separate arguments as as alternative to the options object. * This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED. */ scrollResponderScrollTo: function( - x?: number | {x?: number, y?: number, animated?: boolean}, + x?: + | number + | {x?: number, y?: number, animated?: boolean, duration?: number}, y?: number, animated?: boolean, + duration?: number, ) { if (typeof x === 'number') { console.warn( '`scrollResponderScrollTo(x, y, animated)` is deprecated. Use `scrollResponderScrollTo({x: 5, y: 5, animated: true})` instead.', ); } else { - ({x, y, animated} = x || {}); + ({x, y, animated, duration} = x || {}); } UIManager.dispatchViewManagerCommand( nullthrows(this.scrollResponderGetScrollableNode()), UIManager.getViewManagerConfig('RCTScrollView').Commands.scrollTo, - [x || 0, y || 0, animated !== false], + [x || 0, y || 0, animated !== false, getDuration(duration)], ); }, /** * Scrolls to the end of the ScrollView, either immediately or with a smooth - * animation. + * animation. For Android, you may specify a "duration" number instead of the + * "animated" boolean. * * Example: * * `scrollResponderScrollToEnd({animated: true})` + * or for Android, you can do: + * `scrollResponderScrollToEnd({duration: 500})` */ - scrollResponderScrollToEnd: function(options?: {animated?: boolean}) { + scrollResponderScrollToEnd: function(options?: { + animated?: boolean, + duration?: number, + }) { // Default to true const animated = (options && options.animated) !== false; UIManager.dispatchViewManagerCommand( this.scrollResponderGetScrollableNode(), UIManager.getViewManagerConfig('RCTScrollView').Commands.scrollToEnd, - [animated], + [animated, getDuration(options && options.duration)], ); }, diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 1f9527b2bdb..8030fd46d61 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -695,20 +695,29 @@ class ScrollView extends React.Component { } /** - * Scrolls to a given x, y offset, either immediately or with a smooth animation. + * Scrolls to a given x, y offset, either immediately, with a smooth animation, or, + * for Android only, a custom animation duration time. * * Example: * * `scrollTo({x: 0, y: 0, animated: true})` * + * Example with duration (Android only): + * + * `scrollTo({x: 0, y: 0, duration: 500})` + * * Note: The weird function signature is due to the fact that, for historical reasons, * the function also accepts separate arguments as an alternative to the options object. * This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED. + * */ scrollTo( - y?: number | {x?: number, y?: number, animated?: boolean}, + y?: + | number + | {x?: number, y?: number, animated?: boolean, duration?: number}, x?: number, animated?: boolean, + duration?: number, ) { if (typeof y === 'number') { console.warn( @@ -716,12 +725,13 @@ class ScrollView extends React.Component { 'animated: true})` instead.', ); } else { - ({x, y, animated} = y || {}); + ({x, y, animated, duration} = y || {}); } this._scrollResponder.scrollResponderScrollTo({ x: x || 0, y: y || 0, animated: animated !== false, + duration: duration, }); } @@ -731,13 +741,16 @@ class ScrollView extends React.Component { * * Use `scrollToEnd({animated: true})` for smooth animated scrolling, * `scrollToEnd({animated: false})` for immediate scrolling. + * For Android, you may specify a duration, e.g. `scrollToEnd({duration: 500})` + * for a controlled duration scroll. * If no options are passed, `animated` defaults to true. */ - scrollToEnd(options?: {animated?: boolean}) { + scrollToEnd(options?: {animated?: boolean, duration?: number}) { // Default to true const animated = (options && options.animated) !== false; this._scrollResponder.scrollResponderScrollToEnd({ animated: animated, + duration: options && options.duration, }); } diff --git a/React/Views/ScrollView/RCTScrollViewManager.m b/React/Views/ScrollView/RCTScrollViewManager.m index 6494d52f48f..d726bae2cda 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -149,7 +149,9 @@ RCT_EXPORT_METHOD(calculateChildFrames:(nonnull NSNumber *)reactTag RCT_EXPORT_METHOD(scrollTo:(nonnull NSNumber *)reactTag offsetX:(CGFloat)x offsetY:(CGFloat)y - animated:(BOOL)animated) + animated:(BOOL)animated + // TODO(dannycochran) Use the duration here for a ScrollView. + duration:(CGFloat __unused)duration) { [self.bridge.uiManager addUIBlock: ^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry){ @@ -164,7 +166,9 @@ RCT_EXPORT_METHOD(scrollTo:(nonnull NSNumber *)reactTag } RCT_EXPORT_METHOD(scrollToEnd:(nonnull NSNumber *)reactTag - animated:(BOOL)animated) + animated:(BOOL)animated + // TODO(dannycochran) Use the duration here for a ScrollView. + duration:(CGFloat __unused)duration) { [self.bridge.uiManager addUIBlock: ^(__unused RCTUIManager *uiManager, NSDictionary *viewRegistry){ 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 bf6c3bd51a1..4c7dad31977 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,6 +7,8 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; @@ -55,6 +57,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements private final Rect mRect = new Rect(); private boolean mActivelyScrolling; + private @Nullable ObjectAnimator mAnimator = null; private @Nullable Rect mClippingRect; private @Nullable String mOverflow = ViewProps.HIDDEN; private boolean mDragging; @@ -183,6 +186,20 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements awakenScrollBars(); } + /** + * Method for animating to a ScrollView position with a given duration, + * instead of using "smoothScrollTo", which does not expose a duration argument. + */ + public void animateScroll(int mDestX, int mDestY, int mDuration) { + if (mAnimator != null) { + mAnimator.cancel(); + } + PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", mDestX); + PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", mDestY); + mAnimator = ObjectAnimator.ofPropertyValuesHolder(this, scrollX, scrollY); + mAnimator.setDuration(mDuration).start(); + } + public void setOverflow(String overflow) { mOverflow = overflow; invalidate(); @@ -266,6 +283,11 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements return false; } + if (mAnimator != null) { + mAnimator.cancel(); + mAnimator = null; + } + mVelocityHelper.calculateVelocity(ev); int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_UP && mDragging) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java index 6c7ff1a2179..254cae5bbd4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java @@ -171,8 +171,13 @@ public class ReactHorizontalScrollViewManager @Override public void scrollTo( ReactHorizontalScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) { - if (data.mAnimated) { - scrollView.smoothScrollTo(data.mDestX, data.mDestY); + if (data.mAnimated && data.mDuration != 0) { + if (data.mDuration > 0) { + // data.mDuration set to -1 to fallbacks to default platform behavior + scrollView.animateScroll(data.mDestX, data.mDestY, data.mDuration); + } else { + scrollView.smoothScrollTo(data.mDestX, data.mDestY); + } } else { scrollView.scrollTo(data.mDestX, data.mDestY); } @@ -185,8 +190,13 @@ public class ReactHorizontalScrollViewManager // ScrollView always has one child - the scrollable area int right = scrollView.getChildAt(0).getWidth() + scrollView.getPaddingRight(); - if (data.mAnimated) { - scrollView.smoothScrollTo(right, scrollView.getScrollY()); + if (data.mAnimated && data.mDuration != 0) { + if (data.mDuration > 0) { + // data.mDuration set to -1 to fallbacks to default platform behavior + scrollView.animateScroll(right, scrollView.getScrollY(), data.mDuration); + } else { + scrollView.smoothScrollTo(right, scrollView.getScrollY()); + } } else { scrollView.scrollTo(right, scrollView.getScrollY()); } 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 d39159a8b9d..74769e5e6a2 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,6 +7,8 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; import android.annotation.TargetApi; import android.graphics.Canvas; import android.graphics.Color; @@ -53,6 +55,7 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou private final VelocityHelper mVelocityHelper = new VelocityHelper(); private final Rect mRect = new Rect(); // for reuse to avoid allocation + private @Nullable ObjectAnimator mAnimator = null; private boolean mActivelyScrolling; private @Nullable Rect mClippingRect; private @Nullable String mOverflow = ViewProps.HIDDEN; @@ -171,6 +174,20 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou awakenScrollBars(); } + /** + * Method for animating to a ScrollView position with a given duration, + * instead of using "smoothScrollTo", which does not expose a duration argument. + */ + public void animateScroll(int mDestX, int mDestY, int mDuration) { + if (mAnimator != null) { + mAnimator.cancel(); + } + PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", mDestX); + PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", mDestY); + mAnimator = ObjectAnimator.ofPropertyValuesHolder(this, scrollX, scrollY); + mAnimator.setDuration(mDuration).start(); + } + public void setOverflow(String overflow) { mOverflow = overflow; invalidate(); @@ -255,6 +272,11 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou return false; } + if (mAnimator != null) { + mAnimator.cancel(); + mAnimator = null; + } + mVelocityHelper.calculateVelocity(ev); int action = ev.getAction() & MotionEvent.ACTION_MASK; if (action == MotionEvent.ACTION_UP && mDragging) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.java index eb5989be48c..83abf0a60c4 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.java @@ -32,22 +32,25 @@ public class ReactScrollViewCommandHelper { public static class ScrollToCommandData { - public final int mDestX, mDestY; + public final int mDestX, mDestY, mDuration; public final boolean mAnimated; - ScrollToCommandData(int destX, int destY, boolean animated) { + ScrollToCommandData(int destX, int destY, boolean animated, int duration) { mDestX = destX; mDestY = destY; mAnimated = animated; + mDuration = duration; } } public static class ScrollToEndCommandData { + public final int mDuration; public final boolean mAnimated; - ScrollToEndCommandData(boolean animated) { + ScrollToEndCommandData(boolean animated, int duration) { mAnimated = animated; + mDuration = duration; } } @@ -74,12 +77,14 @@ public class ReactScrollViewCommandHelper { int destX = Math.round(PixelUtil.toPixelFromDIP(args.getDouble(0))); int destY = Math.round(PixelUtil.toPixelFromDIP(args.getDouble(1))); boolean animated = args.getBoolean(2); - viewManager.scrollTo(scrollView, new ScrollToCommandData(destX, destY, animated)); + int duration = (int) Math.round(args.getDouble(3)); + viewManager.scrollTo(scrollView, new ScrollToCommandData(destX, destY, animated, duration)); return; } case COMMAND_SCROLL_TO_END: { boolean animated = args.getBoolean(0); - viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(animated)); + int duration = (int) Math.round(args.getDouble(1)); + viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(animated, duration)); return; } case COMMAND_FLASH_SCROLL_INDICATORS: 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 1e8cdae053f..85a85085529 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,6 +7,8 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; import android.view.View; import android.view.ViewGroup; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java index b0a77556be0..48ceef3dae8 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java @@ -191,8 +191,13 @@ public class ReactScrollViewManager @Override public void scrollTo( ReactScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) { - if (data.mAnimated) { - scrollView.smoothScrollTo(data.mDestX, data.mDestY); + if (data.mAnimated && data.mDuration != 0) { + if (data.mDuration > 0) { + // data.mDuration set to -1 to fallbacks to default platform behavior + scrollView.animateScroll(data.mDestX, data.mDestY, data.mDuration); + } else { + scrollView.smoothScrollTo(data.mDestX, data.mDestY); + } } else { scrollView.scrollTo(data.mDestX, data.mDestY); } @@ -257,8 +262,13 @@ public class ReactScrollViewManager // ScrollView always has one child - the scrollable area int bottom = scrollView.getChildAt(0).getHeight() + scrollView.getPaddingBottom(); - if (data.mAnimated) { - scrollView.smoothScrollTo(scrollView.getScrollX(), bottom); + if (data.mAnimated && data.mDuration != 0) { + if (data.mDuration > 0) { + // data.mDuration set to -1 to fallbacks to default platform behavior + scrollView.animateScroll(scrollView.getScrollX(), bottom, data.mDuration); + } else { + scrollView.smoothScrollTo(scrollView.getScrollX(), bottom); + } } else { scrollView.scrollTo(scrollView.getScrollX(), bottom); }