Skip to content

Commit f70018b

Browse files
ryancatfacebook-github-bot
authored andcommitted
Fix quick small scroll gesture race issues
Summary: This diff fixes two edge case (similar to a race condition) that caused unexpected behaviors. **Problem one** {F680816408} The previous fling animation is not canceled when user starts to scroll or drag. This is causing both the animation and scroll are setting the scroll position. Depends on the animation path and scroll speed, there may be cases where the [velocity calculation](https://fburl.com/code/010lsu72) ends up getting reversed values. See P467905091 as an example where you can see `mXFlingVelocity` goes back and forth from positive to negative. It's hard to see if the wrong values are in the middle, but if that happens in the end of user gesture, the velocity for the next fling would be wrong. It shows a "bounce back" effect, and can be triggered when user makes small quick joystick scrolls in one direction. **Problem two** {F680821494} There is a gap between animator's `onAnimationEnd` lifecycle method [finished](https://fburl.com/code/6baq04ne) and the `Animator#isRunning` API to return false. This is causing issues for `getPostAnimationScrollX` where we [decide to return](https://fburl.com/code/hzzugvch) the animated final value or the scroll value. User may see the `-1` value got used for the next fling start value, and the whole scroll view goes back to the beginning of scroll view and starts to fling. This happens when the previous fling animation finishes and the animated final value is set to -1, but at the same time the next fling starts before `isRunning` returns false for the previous animation. **Solution** The problems are fixed by - Do not reset animated final value to -1 in `onAnimationEnd` method - Add `mIsFinished` states and use it to track animation finish signal, instead of using `isRunning` API - Update logic where we decide to return the correct value for the next animation starts point. We will return previous animated final value when the animation got canceled, and user is going towards that value from the current scroll value. Changelog: [Android][Fixed] - Fixed edge case for quick small scrolls causing unexpected scrolling behaviors. Reviewed By: javache Differential Revision: D32487846 fbshipit-source-id: f1b0647656e021390e3a05de5846251a4a2647ff
1 parent 79d20a1 commit f70018b

File tree

3 files changed

+72
-12
lines changed

3 files changed

+72
-12
lines changed

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
457457
ReactScrollViewHelper.emitScrollBeginDragEvent(this);
458458
mDragging = true;
459459
enableFpsListener();
460+
getFlingAnimator().cancel();
460461
return true;
461462
}
462463
} catch (IllegalArgumentException e) {
@@ -819,7 +820,7 @@ private int predictFinalScrollPosition(int velocityX) {
819820
// predict where a fling would end up so we can scroll to the nearest snap offset
820821
int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth());
821822
int width = getWidth() - ViewCompat.getPaddingStart(this) - ViewCompat.getPaddingEnd(this);
822-
Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this);
823+
Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this, velocityX > 0);
823824
scroller.fling(
824825
postAnimationScroll.x, // startX
825826
postAnimationScroll.y, // startY
@@ -846,7 +847,8 @@ private void smoothScrollAndSnap(int velocity) {
846847
}
847848

848849
double interval = (double) getSnapInterval();
849-
double currentOffset = (double) (ReactScrollViewHelper.getPostAnimationScroll(this).x);
850+
double currentOffset =
851+
(double) (ReactScrollViewHelper.getPostAnimationScroll(this, velocity > 0).x);
850852
double targetOffset = (double) predictFinalScrollPosition(velocity);
851853

852854
int previousPage = (int) Math.floor(currentOffset / interval);

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ public boolean onInterceptTouchEvent(MotionEvent ev) {
337337
ReactScrollViewHelper.emitScrollBeginDragEvent(this);
338338
mDragging = true;
339339
enableFpsListener();
340+
getFlingAnimator().cancel();
340341
return true;
341342
}
342343
} catch (IllegalArgumentException e) {
@@ -608,7 +609,7 @@ private int predictFinalScrollPosition(int velocityY) {
608609
// predict where a fling would end up so we can scroll to the nearest snap offset
609610
int maximumOffset = getMaxScrollY();
610611
int height = getHeight() - getPaddingBottom() - getPaddingTop();
611-
Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this);
612+
Point postAnimationScroll = ReactScrollViewHelper.getPostAnimationScroll(this, velocityY > 0);
612613
scroller.fling(
613614
postAnimationScroll.x, // startX
614615
postAnimationScroll.y, // startY
@@ -635,7 +636,8 @@ private View getContentView() {
635636
*/
636637
private void smoothScrollAndSnap(int velocity) {
637638
double interval = (double) getSnapInterval();
638-
double currentOffset = (double) (ReactScrollViewHelper.getPostAnimationScroll(this).y);
639+
double currentOffset =
640+
(double) (ReactScrollViewHelper.getPostAnimationScroll(this, velocity > 0).y);
639641
double targetOffset = (double) predictFinalScrollPosition(velocity);
640642

641643
int previousPage = (int) Math.floor(currentOffset / interval);

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

+64-8
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ public static class ReactScrollViewScrollState {
226226
private final Point mFinalAnimatedPositionScroll = new Point();
227227
private int mScrollAwayPaddingTop = 0;
228228
private final Point mLastStateUpdateScroll = new Point(-1, -1);
229+
private boolean mIsCanceled = false;
230+
private boolean mIsFinished = true;
229231

230232
public ReactScrollViewScrollState(
231233
final int layoutDirection, final ReactScrollViewScrollDirection scrollDirection) {
@@ -283,6 +285,28 @@ public ReactScrollViewScrollState setScrollAwayPaddingTop(int scrollAwayPaddingT
283285
mScrollAwayPaddingTop = scrollAwayPaddingTop;
284286
return this;
285287
}
288+
289+
/** Get true if the previous animation was canceled */
290+
public boolean getIsCanceled() {
291+
return mIsCanceled;
292+
}
293+
294+
/** Set the state of current animation is canceled or not */
295+
public ReactScrollViewScrollState setIsCanceled(boolean isCanceled) {
296+
mIsCanceled = isCanceled;
297+
return this;
298+
}
299+
300+
/** Get true if previous animation was finished */
301+
public boolean getIsFinished() {
302+
return mIsFinished;
303+
}
304+
305+
/** Set the state of current animation is finished or not */
306+
public ReactScrollViewScrollState setIsFinished(boolean isFinished) {
307+
mIsFinished = isFinished;
308+
return this;
309+
}
286310
}
287311

288312
/**
@@ -326,11 +350,36 @@ void smoothScrollTo(final T scrollView, final int x, final int y) {
326350
T extends
327351
ViewGroup & FabricViewStateManager.HasFabricViewStateManager & HasScrollState
328352
& HasFlingAnimator>
329-
Point getPostAnimationScroll(final T scrollView) {
330-
final ValueAnimator flingAnimator = scrollView.getFlingAnimator();
331-
return flingAnimator != null && flingAnimator.isRunning()
332-
? scrollView.getReactScrollViewScrollState().getFinalAnimatedPositionScroll()
333-
: new Point(scrollView.getScrollX(), scrollView.getScrollY());
353+
Point getPostAnimationScroll(final T scrollView, final boolean isPositiveVelocity) {
354+
final ReactScrollViewScrollState scrollState = scrollView.getReactScrollViewScrollState();
355+
final int velocityDirectionMask = isPositiveVelocity ? 1 : -1;
356+
final Point animatedScrollPos = scrollState.getFinalAnimatedPositionScroll();
357+
final Point currentScrollPos = new Point(scrollView.getScrollX(), scrollView.getScrollY());
358+
359+
boolean isMovingTowardsAnimatedValue = false;
360+
switch (scrollState.getScrollDirection()) {
361+
case HORIZONTAL:
362+
isMovingTowardsAnimatedValue =
363+
velocityDirectionMask * (animatedScrollPos.x - currentScrollPos.x) > 0;
364+
break;
365+
366+
case VERTICAL:
367+
isMovingTowardsAnimatedValue =
368+
velocityDirectionMask * (animatedScrollPos.y - currentScrollPos.y) > 0;
369+
break;
370+
371+
default:
372+
throw new IllegalArgumentException("ScrollView has unexpected scroll direction.");
373+
}
374+
375+
// When the fling animation is not finished, or it was canceled and now we are moving towards
376+
// the final animated value, we will return the final animated value. This is because follow up
377+
// animation should consider the "would be" animated location, so that previous quick small
378+
// scrolls are still working.
379+
return !scrollState.getIsFinished()
380+
|| (scrollState.getIsCanceled() && isMovingTowardsAnimatedValue)
381+
? animatedScrollPos
382+
: currentScrollPos;
334383
}
335384

336385
public static <
@@ -432,16 +481,23 @@ void registerFlingAnimator(final T scrollView) {
432481
.addListener(
433482
new Animator.AnimatorListener() {
434483
@Override
435-
public void onAnimationStart(Animator animator) {}
484+
public void onAnimationStart(Animator animator) {
485+
final ReactScrollViewScrollState scrollState =
486+
scrollView.getReactScrollViewScrollState();
487+
scrollState.setIsCanceled(false);
488+
scrollState.setIsFinished(false);
489+
}
436490

437491
@Override
438492
public void onAnimationEnd(Animator animator) {
439-
scrollView.getReactScrollViewScrollState().setFinalAnimatedPositionScroll(-1, -1);
493+
scrollView.getReactScrollViewScrollState().setIsFinished(true);
440494
ReactScrollViewHelper.updateStateOnScroll(scrollView);
441495
}
442496

443497
@Override
444-
public void onAnimationCancel(Animator animator) {}
498+
public void onAnimationCancel(Animator animator) {
499+
scrollView.getReactScrollViewScrollState().setIsCanceled(true);
500+
}
445501

446502
@Override
447503
public void onAnimationRepeat(Animator animator) {}

0 commit comments

Comments
 (0)