Skip to content

Commit

Permalink
Add ability to control scroll animation duration for Android (#22884)
Browse files Browse the repository at this point in the history
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: #22884

Differential Revision: D13860038

Pulled By: cpojer

fbshipit-source-id: f06751d063a33d7046241c95348b6abbb327d36f
  • Loading branch information
osdnk authored and facebook-github-bot committed Jan 29, 2019
1 parent 8afa037 commit 7e8b810
Show file tree
Hide file tree
Showing 9 changed files with 131 additions and 26 deletions.
31 changes: 24 additions & 7 deletions Libraries/Components/ScrollResponder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)],
);
},

Expand Down
21 changes: 17 additions & 4 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -695,33 +695,43 @@ class ScrollView extends React.Component<Props, State> {
}

/**
* 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(
'`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._scrollResponder.scrollResponderScrollTo({
x: x || 0,
y: y || 0,
animated: animated !== false,
duration: duration,
});
}

Expand All @@ -731,13 +741,16 @@ class ScrollView extends React.Component<Props, State> {
*
* 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,
});
}

Expand Down
8 changes: 6 additions & 2 deletions React/Views/ScrollView/RCTScrollViewManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSNumber *, UIView *> *viewRegistry){
Expand All @@ -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<NSNumber *, UIView *> *viewRegistry){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,25 @@ public interface ScrollCommandHandler<T> {

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;
}
}

Expand All @@ -74,12 +77,14 @@ public static <T> 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 7e8b810

Please sign in to comment.