Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ability to control scroll animation duration for Android #17422

Closed
wants to merge 15 commits into from
30 changes: 22 additions & 8 deletions Libraries/Components/ScrollResponder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)],
);
},

Expand Down
22 changes: 16 additions & 6 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you open a PR that applies these same changes to https://github.com/facebook/react-native-website/blob/master/docs/scrollview.md ?

These comments are not synced. At some point we need to update these comments with links to the generated docs at http://facebook.github.io/react-native instead (I've done this with a few source files already but had not gotten to ScrollView yet).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* 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}
);
},

Expand All @@ -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
});
},

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 @@ -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<NSNumber *, UIView *> *viewRegistry){
Expand All @@ -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<NSNumber *, UIView *> *viewRegistry){
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
void scrollTo(T scrollView, ScrollToCommandData data);
void scrollToEnd(T scrollView, ScrollToEndCommandData data);
Expand All @@ -32,22 +41,21 @@ public interface ScrollCommandHandler<T> {

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

Expand All @@ -73,13 +81,32 @@ public static <T> 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea if this Animator object is legit - @janicduplessis or @mdvacca?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It has support from API 11:

https://developer.android.com/reference/android/animation/ObjectAnimator.html

FWIW we've been using this ScrollView (via overwriting the ScrollView package in MainApplication.Java) in production for the past 6 weeks, in production / release. We also use it in a fairly expensive SectionList which does all sorts of crazy optimizations and is a good stress test for performance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would still like signoff from someone that knows our android codebase...@janicduplessis, @mdvacca, or @axe-fb?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good.

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down