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 #22884

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
cpojer marked this conversation as resolved.
Show resolved Hide resolved
// 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