diff --git a/Libraries/Components/ScrollResponder.js b/Libraries/Components/ScrollResponder.js index 6c926b9d40e135..34be849f001a09 100644 --- a/Libraries/Components/ScrollResponder.js +++ b/Libraries/Components/ScrollResponder.js @@ -113,6 +113,14 @@ type State = { }; type Event = Object; +/** + * 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 = { mixins: [Subscribable.Mixin], scrollResponderMixinGetInitialState: function(): State { @@ -403,46 +411,52 @@ 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. + * + * Also note "duration" is currently only supported for Android. */ scrollResponderScrollTo: function( - x?: number | { x?: number, y?: number, animated?: boolean }, + x?: number | { x?: number, y?: number, animated?: boolean, duration?: number }, y?: number, - animated?: boolean + 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.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 }, + options?: { animated?: boolean, duration?: number }, ) { // Default to true const animated = (options && options.animated) !== false; UIManager.dispatchViewManagerCommand( this.scrollResponderGetScrollableNode(), UIManager.RCTScrollView.Commands.scrollToEnd, - [animated], + [animated, getDuration(options && options.duration)], ); }, diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 2d9d031027ca92..9aee345521c1c8 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -558,29 +558,36 @@ const ScrollView = createReactClass({ }, /** - * 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: function( - y?: number | { x?: number, y?: number, animated?: boolean }, + y?: number | { x?: number, y?: number, animated?: boolean, duration?: number }, x?: number, - animated?: boolean + animated?: boolean, + duration?: number ) { if (typeof y === 'number') { console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' + 'animated: true})` instead.'); } else { - ({x, y, animated} = y || {}); + ({x, y, animated, duration} = y || {}); } this.getScrollResponder().scrollResponderScrollTo( - {x: x || 0, y: y || 0, animated: animated !== false} + {x: x || 0, y: y || 0, animated: animated !== false, duration: duration} ); }, @@ -590,15 +597,18 @@ const ScrollView = createReactClass({ * * 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: function( - options?: { animated?: boolean }, + options?: { animated?: boolean, duration?: number }, ) { // Default to true const animated = (options && options.animated) !== false; this.getScrollResponder().scrollResponderScrollToEnd({ animated: animated, + duration: options && options.duration }); }, diff --git a/React/Views/ScrollView/RCTScrollViewManager.m b/React/Views/ScrollView/RCTScrollViewManager.m index e5824afd3f386d..36b2eb47c5dafc 100644 --- a/React/Views/ScrollView/RCTScrollViewManager.m +++ b/React/Views/ScrollView/RCTScrollViewManager.m @@ -145,7 +145,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){ @@ -160,7 +162,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 90b9d1fc4d3a9c..9e530aaff8f020 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,7 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; @@ -40,6 +41,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements private final VelocityHelper mVelocityHelper = new VelocityHelper(); private boolean mActivelyScrolling; + private @Nullable ObjectAnimator mAnimator = null; private @Nullable Rect mClippingRect; private boolean mDragging; private boolean mPagingEnabled = false; @@ -102,6 +104,13 @@ public void flashScrollIndicators() { awakenScrollBars(); } + public void animateScroll(ReactHorizontalScrollView view, int mDestX, int mDestY, int mDuration) { + if (mAnimator != null) { + mAnimator.cancel(); + } + mAnimator = ReactScrollViewHelper.animateScroll(view, mDestX, mDestY, mDuration); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec); @@ -165,6 +174,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 e6536a6e53eb76..70e33db8674c07 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 @@ -144,8 +144,8 @@ 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.mDuration > 0) { + scrollView.animateScroll(scrollView, data.mDestX, data.mDestY, data.mDuration); } else { scrollView.scrollTo(data.mDestX, data.mDestY); } @@ -158,8 +158,8 @@ 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.mDuration > 0) { + scrollView.animateScroll(scrollView, right, scrollView.getScrollY(), data.mDuration); } 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 acf3458ef5f023..96d8801ad0d75b 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,7 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.graphics.Canvas; import android.graphics.Color; @@ -48,6 +49,7 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou private final @Nullable OverScroller mScroller; private final VelocityHelper mVelocityHelper = new VelocityHelper(); + private @Nullable ObjectAnimator mAnimator = null; private @Nullable Rect mClippingRect; private boolean mDoneFlinging; private boolean mDragging; @@ -131,6 +133,13 @@ public void flashScrollIndicators() { awakenScrollBars(); } + public void animateScroll(ReactScrollView view, int mDestX, int mDestY, int mDuration) { + if (mAnimator != null) { + mAnimator.cancel(); + } + mAnimator = ReactScrollViewHelper.animateScroll(view, mDestX, mDestY, mDuration); + } + @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { MeasureSpecAssertions.assertExplicitMeasureSpec(widthMeasureSpec, heightMeasureSpec); @@ -212,6 +221,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 7eaa06386d5b71..b7b0b290065be9 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 @@ -24,6 +24,15 @@ public class ReactScrollViewCommandHelper { public static final int COMMAND_SCROLL_TO_END = 2; public static final int COMMAND_FLASH_SCROLL_INDICATORS = 3; + /** + * Prior to users being able to specify a duration when calling "scrollTo", + * they could specify an "animate" boolean, which would use Android's + * "smoothScrollTo" method, which defaulted to a 250 millisecond + * animation: + * https://developer.android.com/reference/android/widget/Scroller.html#startScroll + */ + public static final int LEGACY_ANIMATION_DURATION = 250; + public interface ScrollCommandHandler { void scrollTo(T scrollView, ScrollToCommandData data); void scrollToEnd(T scrollView, ScrollToEndCommandData data); @@ -32,22 +41,21 @@ public interface ScrollCommandHandler { public static class ScrollToCommandData { - public final int mDestX, mDestY; - public final boolean mAnimated; + public final int mDestX, mDestY, mDuration; - ScrollToCommandData(int destX, int destY, boolean animated) { + ScrollToCommandData(int destX, int destY, int duration) { mDestX = destX; mDestY = destY; - mAnimated = animated; + mDuration = duration; } } public static class ScrollToEndCommandData { - public final boolean mAnimated; + public final int mDuration; - ScrollToEndCommandData(boolean animated) { - mAnimated = animated; + ScrollToEndCommandData(int duration) { + mDuration = duration; } } @@ -73,13 +81,32 @@ public static void receiveCommand( case COMMAND_SCROLL_TO: { 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)); + + // Defer to the "duration" argument to determine if we should animate the + // scrollTo, otherwise use the legacy "animated" boolean. + // TODO(dannycochran) Eventually this can be removed in favor of just + // looking at "duration" once support also exists on iOS. + int duration = 0; + if (args.size() == 4 && args.getDouble(3) >= 0) { + duration = (int) Math.round(args.getDouble(3)); + } else { + duration = args.getBoolean(2) ? LEGACY_ANIMATION_DURATION : 0; + } + viewManager.scrollTo(scrollView, new ScrollToCommandData(destX, destY, duration)); return; } case COMMAND_SCROLL_TO_END: { - boolean animated = args.getBoolean(0); - viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(animated)); + // Defer to the "duration" argument to determine if we should animate the + // scrollTo, otherwise use the legacy "animated" boolean. + // TODO(dannycochran) Eventually this can be removed in favor of just + // looking at "duration" once support also exists on iOS. + int duration = 0; + if (args.size() == 2 && args.getDouble(1) >= 0) { + duration = (int) Math.round(args.getDouble(1)); + } else { + duration = args.getBoolean(0) ? LEGACY_ANIMATION_DURATION : 0; + } + viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(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 a034694b4bd9d7..c5b8af65ceac91 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,9 @@ package com.facebook.react.views.scroll; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; + import android.view.View; import android.view.ViewGroup; @@ -94,4 +97,18 @@ public static int parseOverScrollMode(String jsOverScrollMode) { throw new JSApplicationIllegalArgumentException("wrong overScrollMode: " + jsOverScrollMode); } } + + /** + * Helper method for animating to a ScrollView position with a given duration, + * instead of using "smoothScrollTo", which does not expose a duration argument. + */ + public static ObjectAnimator animateScroll(final ViewGroup scrollView, int mDestX, int mDestY, int mDuration) { + PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", mDestX); + PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", mDestY); + + final ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(scrollView, scrollX, scrollY); + + animator.setDuration(mDuration).start(); + return animator; + } } 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 df520b5e4669f4..53f2153f4a44a9 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 @@ -150,8 +150,8 @@ 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.mDuration > 0) { + scrollView.animateScroll(scrollView, data.mDestX, data.mDestY, data.mDuration); } else { scrollView.scrollTo(data.mDestX, data.mDestY); } @@ -211,8 +211,8 @@ 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.mDuration > 0) { + scrollView.animateScroll(scrollView, scrollView.getScrollX(), bottom, data.mDuration); } else { scrollView.scrollTo(scrollView.getScrollX(), bottom); }