diff --git a/Libraries/Components/ScrollResponder.js b/Libraries/Components/ScrollResponder.js index 6b37dadda3b1bc..7b3256ad3e0bb3 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 1f9527b2bdb4fa..8030fd46d61d1b 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 6494d52f48fb29..d726bae2cda826 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -149,7 +149,9 @@ - (UIView *)view 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 @@ - (UIView *)view } 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 bf6c3bd51a1554..4c7dad31977a93 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 void flashScrollIndicators() { 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 boolean onTouchEvent(MotionEvent ev) { 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 6c7ff1a2179d57..254cae5bbd4ebe 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 void flashScrollIndicators(ReactHorizontalScrollView scrollView) { @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 void scrollToEnd( // 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 d39159a8b9de9b..74769e5e6a2103 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 void flashScrollIndicators() { 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 boolean onTouchEvent(MotionEvent ev) { 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 eb5989be48c68c..83abf0a60c4362 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 interface ScrollCommandHandler { 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 static void receiveCommand( 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 1e8cdae053f427..85a85085529b80 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 b0a77556be0e83..48ceef3dae82f4 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 void flashScrollIndicators(ReactScrollView scrollView) { @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 void scrollToEnd( // 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); }