Skip to content

Commit d73afc0

Browse files
osdnkfacebook-github-bot
authored andcommitted
Add ability to control scroll animation duration for Android (facebook#22884)
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: facebook#22884 Differential Revision: D13860038 Pulled By: cpojer fbshipit-source-id: f06751d063a33d7046241c95348b6abbb327d36f
1 parent 92a18ba commit d73afc0

File tree

9 files changed

+131
-26
lines changed

9 files changed

+131
-26
lines changed

Libraries/Components/ScrollResponder.js

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,14 @@ export type State = {|
116116
becameResponderWhileAnimating: boolean,
117117
|};
118118

119+
/**
120+
* If a user has specified a duration, we will use it. Otherwise,
121+
* set it to -1 as the bridge cannot handle undefined / null values.
122+
*/
123+
function getDuration(duration?: number): number {
124+
return duration === undefined ? -1 : Math.max(duration, 0);
125+
}
126+
119127
const ScrollResponderMixin = {
120128
_subscriptionKeyboardWillShow: (null: ?EmitterSubscription),
121129
_subscriptionKeyboardWillHide: (null: ?EmitterSubscription),
@@ -424,46 +432,55 @@ const ScrollResponderMixin = {
424432
* This is currently used to help focus child TextViews, but can also
425433
* be used to quickly scroll to any element we want to focus. Syntax:
426434
*
427-
* `scrollResponderScrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true})`
435+
* `scrollResponderScrollTo(options: {x: number = 0; y: number = 0; animated: boolean = true, duration: number = 0})`
428436
*
429437
* Note: The weird argument signature is due to the fact that, for historical reasons,
430438
* the function also accepts separate arguments as as alternative to the options object.
431439
* This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED.
432440
*/
433441
scrollResponderScrollTo: function(
434-
x?: number | {x?: number, y?: number, animated?: boolean},
442+
x?:
443+
| number
444+
| {x?: number, y?: number, animated?: boolean, duration?: number},
435445
y?: number,
436446
animated?: boolean,
447+
duration?: number,
437448
) {
438449
if (typeof x === 'number') {
439450
console.warn(
440451
'`scrollResponderScrollTo(x, y, animated)` is deprecated. Use `scrollResponderScrollTo({x: 5, y: 5, animated: true})` instead.',
441452
);
442453
} else {
443-
({x, y, animated} = x || {});
454+
({x, y, animated, duration} = x || {});
444455
}
445456
UIManager.dispatchViewManagerCommand(
446457
nullthrows(this.scrollResponderGetScrollableNode()),
447458
UIManager.getViewManagerConfig('RCTScrollView').Commands.scrollTo,
448-
[x || 0, y || 0, animated !== false],
459+
[x || 0, y || 0, animated !== false, getDuration(duration)],
449460
);
450461
},
451462

452463
/**
453464
* Scrolls to the end of the ScrollView, either immediately or with a smooth
454-
* animation.
465+
* animation. For Android, you may specify a "duration" number instead of the
466+
* "animated" boolean.
455467
*
456468
* Example:
457469
*
458470
* `scrollResponderScrollToEnd({animated: true})`
471+
* or for Android, you can do:
472+
* `scrollResponderScrollToEnd({duration: 500})`
459473
*/
460-
scrollResponderScrollToEnd: function(options?: {animated?: boolean}) {
474+
scrollResponderScrollToEnd: function(options?: {
475+
animated?: boolean,
476+
duration?: number,
477+
}) {
461478
// Default to true
462479
const animated = (options && options.animated) !== false;
463480
UIManager.dispatchViewManagerCommand(
464481
this.scrollResponderGetScrollableNode(),
465482
UIManager.getViewManagerConfig('RCTScrollView').Commands.scrollToEnd,
466-
[animated],
483+
[animated, getDuration(options && options.duration)],
467484
);
468485
},
469486

Libraries/Components/ScrollView/ScrollView.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -695,33 +695,43 @@ class ScrollView extends React.Component<Props, State> {
695695
}
696696

697697
/**
698-
* Scrolls to a given x, y offset, either immediately or with a smooth animation.
698+
* Scrolls to a given x, y offset, either immediately, with a smooth animation, or,
699+
* for Android only, a custom animation duration time.
699700
*
700701
* Example:
701702
*
702703
* `scrollTo({x: 0, y: 0, animated: true})`
703704
*
705+
* Example with duration (Android only):
706+
*
707+
* `scrollTo({x: 0, y: 0, duration: 500})`
708+
*
704709
* Note: The weird function signature is due to the fact that, for historical reasons,
705710
* the function also accepts separate arguments as an alternative to the options object.
706711
* This is deprecated due to ambiguity (y before x), and SHOULD NOT BE USED.
712+
*
707713
*/
708714
scrollTo(
709-
y?: number | {x?: number, y?: number, animated?: boolean},
715+
y?:
716+
| number
717+
| {x?: number, y?: number, animated?: boolean, duration?: number},
710718
x?: number,
711719
animated?: boolean,
720+
duration?: number,
712721
) {
713722
if (typeof y === 'number') {
714723
console.warn(
715724
'`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' +
716725
'animated: true})` instead.',
717726
);
718727
} else {
719-
({x, y, animated} = y || {});
728+
({x, y, animated, duration} = y || {});
720729
}
721730
this._scrollResponder.scrollResponderScrollTo({
722731
x: x || 0,
723732
y: y || 0,
724733
animated: animated !== false,
734+
duration: duration,
725735
});
726736
}
727737

@@ -731,13 +741,16 @@ class ScrollView extends React.Component<Props, State> {
731741
*
732742
* Use `scrollToEnd({animated: true})` for smooth animated scrolling,
733743
* `scrollToEnd({animated: false})` for immediate scrolling.
744+
* For Android, you may specify a duration, e.g. `scrollToEnd({duration: 500})`
745+
* for a controlled duration scroll.
734746
* If no options are passed, `animated` defaults to true.
735747
*/
736-
scrollToEnd(options?: {animated?: boolean}) {
748+
scrollToEnd(options?: {animated?: boolean, duration?: number}) {
737749
// Default to true
738750
const animated = (options && options.animated) !== false;
739751
this._scrollResponder.scrollResponderScrollToEnd({
740752
animated: animated,
753+
duration: options && options.duration,
741754
});
742755
}
743756

React/Views/ScrollView/RCTScrollViewManager.m

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ - (UIView *)view
149149
RCT_EXPORT_METHOD(scrollTo:(nonnull NSNumber *)reactTag
150150
offsetX:(CGFloat)x
151151
offsetY:(CGFloat)y
152-
animated:(BOOL)animated)
152+
animated:(BOOL)animated
153+
// TODO(dannycochran) Use the duration here for a ScrollView.
154+
duration:(CGFloat __unused)duration)
153155
{
154156
[self.bridge.uiManager addUIBlock:
155157
^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry){
@@ -164,7 +166,9 @@ - (UIView *)view
164166
}
165167

166168
RCT_EXPORT_METHOD(scrollToEnd:(nonnull NSNumber *)reactTag
167-
animated:(BOOL)animated)
169+
animated:(BOOL)animated
170+
// TODO(dannycochran) Use the duration here for a ScrollView.
171+
duration:(CGFloat __unused)duration)
168172
{
169173
[self.bridge.uiManager addUIBlock:
170174
^(__unused RCTUIManager *uiManager, NSDictionary<NSNumber *, UIView *> *viewRegistry){

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.animation.ObjectAnimator;
11+
import android.animation.PropertyValuesHolder;
1012
import android.annotation.TargetApi;
1113
import android.content.Context;
1214
import android.graphics.Canvas;
@@ -55,6 +57,7 @@ public class ReactHorizontalScrollView extends HorizontalScrollView implements
5557
private final Rect mRect = new Rect();
5658

5759
private boolean mActivelyScrolling;
60+
private @Nullable ObjectAnimator mAnimator = null;
5861
private @Nullable Rect mClippingRect;
5962
private @Nullable String mOverflow = ViewProps.HIDDEN;
6063
private boolean mDragging;
@@ -183,6 +186,20 @@ public void flashScrollIndicators() {
183186
awakenScrollBars();
184187
}
185188

189+
/**
190+
* Method for animating to a ScrollView position with a given duration,
191+
* instead of using "smoothScrollTo", which does not expose a duration argument.
192+
*/
193+
public void animateScroll(int mDestX, int mDestY, int mDuration) {
194+
if (mAnimator != null) {
195+
mAnimator.cancel();
196+
}
197+
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", mDestX);
198+
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", mDestY);
199+
mAnimator = ObjectAnimator.ofPropertyValuesHolder(this, scrollX, scrollY);
200+
mAnimator.setDuration(mDuration).start();
201+
}
202+
186203
public void setOverflow(String overflow) {
187204
mOverflow = overflow;
188205
invalidate();
@@ -266,6 +283,11 @@ public boolean onTouchEvent(MotionEvent ev) {
266283
return false;
267284
}
268285

286+
if (mAnimator != null) {
287+
mAnimator.cancel();
288+
mAnimator = null;
289+
}
290+
269291
mVelocityHelper.calculateVelocity(ev);
270292
int action = ev.getAction() & MotionEvent.ACTION_MASK;
271293
if (action == MotionEvent.ACTION_UP && mDragging) {

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,13 @@ public void flashScrollIndicators(ReactHorizontalScrollView scrollView) {
171171
@Override
172172
public void scrollTo(
173173
ReactHorizontalScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) {
174-
if (data.mAnimated) {
175-
scrollView.smoothScrollTo(data.mDestX, data.mDestY);
174+
if (data.mAnimated && data.mDuration != 0) {
175+
if (data.mDuration > 0) {
176+
// data.mDuration set to -1 to fallbacks to default platform behavior
177+
scrollView.animateScroll(data.mDestX, data.mDestY, data.mDuration);
178+
} else {
179+
scrollView.smoothScrollTo(data.mDestX, data.mDestY);
180+
}
176181
} else {
177182
scrollView.scrollTo(data.mDestX, data.mDestY);
178183
}
@@ -185,8 +190,13 @@ public void scrollToEnd(
185190
// ScrollView always has one child - the scrollable area
186191
int right =
187192
scrollView.getChildAt(0).getWidth() + scrollView.getPaddingRight();
188-
if (data.mAnimated) {
189-
scrollView.smoothScrollTo(right, scrollView.getScrollY());
193+
if (data.mAnimated && data.mDuration != 0) {
194+
if (data.mDuration > 0) {
195+
// data.mDuration set to -1 to fallbacks to default platform behavior
196+
scrollView.animateScroll(right, scrollView.getScrollY(), data.mDuration);
197+
} else {
198+
scrollView.smoothScrollTo(right, scrollView.getScrollY());
199+
}
190200
} else {
191201
scrollView.scrollTo(right, scrollView.getScrollY());
192202
}

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.animation.ObjectAnimator;
11+
import android.animation.PropertyValuesHolder;
1012
import android.annotation.TargetApi;
1113
import android.graphics.Canvas;
1214
import android.graphics.Color;
@@ -53,6 +55,7 @@ public class ReactScrollView extends ScrollView implements ReactClippingViewGrou
5355
private final VelocityHelper mVelocityHelper = new VelocityHelper();
5456
private final Rect mRect = new Rect(); // for reuse to avoid allocation
5557

58+
private @Nullable ObjectAnimator mAnimator = null;
5659
private boolean mActivelyScrolling;
5760
private @Nullable Rect mClippingRect;
5861
private @Nullable String mOverflow = ViewProps.HIDDEN;
@@ -171,6 +174,20 @@ public void flashScrollIndicators() {
171174
awakenScrollBars();
172175
}
173176

177+
/**
178+
* Method for animating to a ScrollView position with a given duration,
179+
* instead of using "smoothScrollTo", which does not expose a duration argument.
180+
*/
181+
public void animateScroll(int mDestX, int mDestY, int mDuration) {
182+
if (mAnimator != null) {
183+
mAnimator.cancel();
184+
}
185+
PropertyValuesHolder scrollX = PropertyValuesHolder.ofInt("scrollX", mDestX);
186+
PropertyValuesHolder scrollY = PropertyValuesHolder.ofInt("scrollY", mDestY);
187+
mAnimator = ObjectAnimator.ofPropertyValuesHolder(this, scrollX, scrollY);
188+
mAnimator.setDuration(mDuration).start();
189+
}
190+
174191
public void setOverflow(String overflow) {
175192
mOverflow = overflow;
176193
invalidate();
@@ -255,6 +272,11 @@ public boolean onTouchEvent(MotionEvent ev) {
255272
return false;
256273
}
257274

275+
if (mAnimator != null) {
276+
mAnimator.cancel();
277+
mAnimator = null;
278+
}
279+
258280
mVelocityHelper.calculateVelocity(ev);
259281
int action = ev.getAction() & MotionEvent.ACTION_MASK;
260282
if (action == MotionEvent.ACTION_UP && mDragging) {

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewCommandHelper.java

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,22 +32,25 @@ public interface ScrollCommandHandler<T> {
3232

3333
public static class ScrollToCommandData {
3434

35-
public final int mDestX, mDestY;
35+
public final int mDestX, mDestY, mDuration;
3636
public final boolean mAnimated;
3737

38-
ScrollToCommandData(int destX, int destY, boolean animated) {
38+
ScrollToCommandData(int destX, int destY, boolean animated, int duration) {
3939
mDestX = destX;
4040
mDestY = destY;
4141
mAnimated = animated;
42+
mDuration = duration;
4243
}
4344
}
4445

4546
public static class ScrollToEndCommandData {
4647

48+
public final int mDuration;
4749
public final boolean mAnimated;
4850

49-
ScrollToEndCommandData(boolean animated) {
51+
ScrollToEndCommandData(boolean animated, int duration) {
5052
mAnimated = animated;
53+
mDuration = duration;
5154
}
5255
}
5356

@@ -74,12 +77,14 @@ public static <T> void receiveCommand(
7477
int destX = Math.round(PixelUtil.toPixelFromDIP(args.getDouble(0)));
7578
int destY = Math.round(PixelUtil.toPixelFromDIP(args.getDouble(1)));
7679
boolean animated = args.getBoolean(2);
77-
viewManager.scrollTo(scrollView, new ScrollToCommandData(destX, destY, animated));
80+
int duration = (int) Math.round(args.getDouble(3));
81+
viewManager.scrollTo(scrollView, new ScrollToCommandData(destX, destY, animated, duration));
7882
return;
7983
}
8084
case COMMAND_SCROLL_TO_END: {
8185
boolean animated = args.getBoolean(0);
82-
viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(animated));
86+
int duration = (int) Math.round(args.getDouble(1));
87+
viewManager.scrollToEnd(scrollView, new ScrollToEndCommandData(animated, duration));
8388
return;
8489
}
8590
case COMMAND_FLASH_SCROLL_INDICATORS:

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
package com.facebook.react.views.scroll;
99

10+
import android.animation.ObjectAnimator;
11+
import android.animation.PropertyValuesHolder;
1012
import android.view.View;
1113
import android.view.ViewGroup;
1214
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,13 @@ public void flashScrollIndicators(ReactScrollView scrollView) {
191191
@Override
192192
public void scrollTo(
193193
ReactScrollView scrollView, ReactScrollViewCommandHelper.ScrollToCommandData data) {
194-
if (data.mAnimated) {
195-
scrollView.smoothScrollTo(data.mDestX, data.mDestY);
194+
if (data.mAnimated && data.mDuration != 0) {
195+
if (data.mDuration > 0) {
196+
// data.mDuration set to -1 to fallbacks to default platform behavior
197+
scrollView.animateScroll(data.mDestX, data.mDestY, data.mDuration);
198+
} else {
199+
scrollView.smoothScrollTo(data.mDestX, data.mDestY);
200+
}
196201
} else {
197202
scrollView.scrollTo(data.mDestX, data.mDestY);
198203
}
@@ -257,8 +262,13 @@ public void scrollToEnd(
257262
// ScrollView always has one child - the scrollable area
258263
int bottom =
259264
scrollView.getChildAt(0).getHeight() + scrollView.getPaddingBottom();
260-
if (data.mAnimated) {
261-
scrollView.smoothScrollTo(scrollView.getScrollX(), bottom);
265+
if (data.mAnimated && data.mDuration != 0) {
266+
if (data.mDuration > 0) {
267+
// data.mDuration set to -1 to fallbacks to default platform behavior
268+
scrollView.animateScroll(scrollView.getScrollX(), bottom, data.mDuration);
269+
} else {
270+
scrollView.smoothScrollTo(scrollView.getScrollX(), bottom);
271+
}
262272
} else {
263273
scrollView.scrollTo(scrollView.getScrollX(), bottom);
264274
}

0 commit comments

Comments
 (0)