From 0869e546fe1448f6c56b4ae97e41e8a67278d7dd Mon Sep 17 00:00:00 2001 From: Oleg Lokhvitsky Date: Mon, 24 Sep 2018 10:14:57 -0700 Subject: [PATCH] Android ScrollView fix for pagingEnabled Summary: The snapToOffsets changes improved the flinging algorithm for snapToInterval/snapToOffsets but actually broke it for pagingEnabled because it's meant to only scroll one page at a time. First, I just brough back the old algorithm, but noticed that it has a bunch of issues (e.g. #20155). So, I tried to improve the algorithm to make sure it uses the proper target offset prediction using the same physics model that Android uses for it's normal scrolling but still be limited to one page scrolls. This resolves #21116. Reviewed By: shergin Differential Revision: D9945017 fbshipit-source-id: be7d4dfd1140f4c4d32bad93a03812dc80286069 --- .../scroll/ReactHorizontalScrollView.java | 92 +++++++++++++++---- .../react/views/scroll/ReactScrollView.java | 92 +++++++++++++++---- 2 files changed, 148 insertions(+), 36 deletions(-) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 5c9e48a8aafc70..c10b6691a6a803 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -277,7 +277,7 @@ public boolean onTouchEvent(MotionEvent ev) { @Override public void fling(int velocityX) { if (mPagingEnabled) { - smoothScrollAndSnap(velocityX); + flingAndSnap(velocityX); } else if (mScroller != null) { // FB SCROLLVIEW CHANGE @@ -455,7 +455,7 @@ public void run() { // Only if we have pagingEnabled and we have not snapped to the page do we // need to continue checking for the scroll. And we cause that scroll by asking for it mSnappingToPage = true; - smoothScrollAndSnap(0); + flingAndSnap(0); ViewCompat.postOnAnimationDelayed(ReactHorizontalScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY); @@ -474,21 +474,7 @@ public void run() { ReactScrollViewHelper.MOMENTUM_DELAY); } - /** - * This will smooth scroll us to the nearest snap offset point - * It currently just looks at where the content is and slides to the nearest point. - * It is intended to be run after we are done scrolling, and handling any momentum scrolling. - */ - private void smoothScrollAndSnap(int velocityX) { - if (getChildCount() <= 0) { - return; - } - - int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); - int targetOffset = 0; - int smallerOffset = 0; - int largerOffset = maximumOffset; - + private int predictFinalScrollPosition(int velocityX) { // ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // no way to customize the scroll duration. So, we create a temporary OverScroller // so we can predict where a fling would land and snap to nearby that point. @@ -496,6 +482,7 @@ private void smoothScrollAndSnap(int velocityX) { scroller.setFriction(1.0f - mDecelerationRate); // predict where a fling would end up so we can scroll to the nearest snap offset + int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); int width = getWidth() - getPaddingStart() - getPaddingEnd(); scroller.fling( getScrollX(), // startX @@ -509,7 +496,76 @@ private void smoothScrollAndSnap(int velocityX) { width/2, // overX 0 // overY ); - targetOffset = scroller.getFinalX(); + return scroller.getFinalX(); + } + + /** + * This will smooth scroll us to the nearest snap offset point + * It currently just looks at where the content is and slides to the nearest point. + * It is intended to be run after we are done scrolling, and handling any momentum scrolling. + */ + private void smoothScrollAndSnap(int velocity) { + double interval = (double) getSnapInterval(); + double currentOffset = (double) getScrollX(); + double targetOffset = (double) predictFinalScrollPosition(velocity); + + int previousPage = (int) Math.floor(currentOffset / interval); + int nextPage = (int) Math.ceil(currentOffset / interval); + int currentPage = (int) Math.round(currentOffset / interval); + int targetPage = (int) Math.round(targetOffset / interval); + + if (velocity > 0 && nextPage == previousPage) { + nextPage ++; + } else if (velocity < 0 && previousPage == nextPage) { + previousPage --; + } + + if ( + // if scrolling towards next page + velocity > 0 && + // and the middle of the page hasn't been crossed already + currentPage < nextPage && + // and it would have been crossed after flinging + targetPage > previousPage + ) { + currentPage = nextPage; + } + else if ( + // if scrolling towards previous page + velocity < 0 && + // and the middle of the page hasn't been crossed already + currentPage > previousPage && + // and it would have been crossed after flinging + targetPage < nextPage + ) { + currentPage = previousPage; + } + + targetOffset = currentPage * interval; + if (targetOffset != currentOffset) { + mActivelyScrolling = true; + smoothScrollTo((int) targetOffset, getScrollY()); + } + } + + private void flingAndSnap(int velocityX) { + if (getChildCount() <= 0) { + return; + } + + // pagingEnabled only allows snapping one interval at a time + if (mSnapInterval == 0 && mSnapOffsets == null) { + smoothScrollAndSnap(velocityX); + return; + } + + int maximumOffset = Math.max(0, computeHorizontalScrollRange() - getWidth()); + int targetOffset = predictFinalScrollPosition(velocityX); + int smallerOffset = 0; + int largerOffset = maximumOffset; + int firstOffset = 0; + int lastOffset = maximumOffset; + int width = getWidth() - getPaddingStart() - getPaddingEnd(); // offsets are from the right edge in RTL layouts boolean isRTL = TextUtilsCompat.getLayoutDirectionFromLocale(Locale.getDefault()) == ViewCompat.LAYOUT_DIRECTION_RTL; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 198ae57daeb6bf..5222a552b10b03 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -300,7 +300,7 @@ public void getClippingRect(Rect outClippingRect) { @Override public void fling(int velocityY) { if (mPagingEnabled) { - smoothScrollAndSnap(velocityY); + flingAndSnap(velocityY); } else if (mScroller != null) { // FB SCROLLVIEW CHANGE @@ -423,7 +423,7 @@ public void run() { // Only if we have pagingEnabled and we have not snapped to the page do we // need to continue checking for the scroll. And we cause that scroll by asking for it mSnappingToPage = true; - smoothScrollAndSnap(0); + flingAndSnap(0); ViewCompat.postOnAnimationDelayed(ReactScrollView.this, this, ReactScrollViewHelper.MOMENTUM_DELAY); @@ -442,21 +442,7 @@ public void run() { ReactScrollViewHelper.MOMENTUM_DELAY); } - /** - * This will smooth scroll us to the nearest snap offset point - * It currently just looks at where the content is and slides to the nearest point. - * It is intended to be run after we are done scrolling, and handling any momentum scrolling. - */ - private void smoothScrollAndSnap(int velocityY) { - if (getChildCount() <= 0) { - return; - } - - int maximumOffset = getMaxScrollY(); - int targetOffset = 0; - int smallerOffset = 0; - int largerOffset = maximumOffset; - + private int predictFinalScrollPosition(int velocityY) { // ScrollView can *only* scroll for 250ms when using smoothScrollTo and there's // no way to customize the scroll duration. So, we create a temporary OverScroller // so we can predict where a fling would land and snap to nearby that point. @@ -464,6 +450,7 @@ private void smoothScrollAndSnap(int velocityY) { scroller.setFriction(1.0f - mDecelerationRate); // predict where a fling would end up so we can scroll to the nearest snap offset + int maximumOffset = getMaxScrollY(); int height = getHeight() - getPaddingBottom() - getPaddingTop(); scroller.fling( getScrollX(), // startX @@ -477,7 +464,76 @@ private void smoothScrollAndSnap(int velocityY) { 0, // overX height/2 // overY ); - targetOffset = scroller.getFinalY(); + return scroller.getFinalY(); + } + + /** + * This will smooth scroll us to the nearest snap offset point + * It currently just looks at where the content is and slides to the nearest point. + * It is intended to be run after we are done scrolling, and handling any momentum scrolling. + */ + private void smoothScrollAndSnap(int velocity) { + double interval = (double) getSnapInterval(); + double currentOffset = (double) getScrollY(); + double targetOffset = (double) predictFinalScrollPosition(velocity); + + int previousPage = (int) Math.floor(currentOffset / interval); + int nextPage = (int) Math.ceil(currentOffset / interval); + int currentPage = (int) Math.round(currentOffset / interval); + int targetPage = (int) Math.round(targetOffset / interval); + + if (velocity > 0 && nextPage == previousPage) { + nextPage ++; + } else if (velocity < 0 && previousPage == nextPage) { + previousPage --; + } + + if ( + // if scrolling towards next page + velocity > 0 && + // and the middle of the page hasn't been crossed already + currentPage < nextPage && + // and it would have been crossed after flinging + targetPage > previousPage + ) { + currentPage = nextPage; + } + else if ( + // if scrolling towards previous page + velocity < 0 && + // and the middle of the page hasn't been crossed already + currentPage > previousPage && + // and it would have been crossed after flinging + targetPage < nextPage + ) { + currentPage = previousPage; + } + + targetOffset = currentPage * interval; + if (targetOffset != currentOffset) { + mActivelyScrolling = true; + smoothScrollTo(getScrollX(), (int) targetOffset); + } + } + + private void flingAndSnap(int velocityY) { + if (getChildCount() <= 0) { + return; + } + + // pagingEnabled only allows snapping one interval at a time + if (mSnapInterval == 0 && mSnapOffsets == null) { + smoothScrollAndSnap(velocityY); + return; + } + + int maximumOffset = getMaxScrollY(); + int targetOffset = predictFinalScrollPosition(velocityY); + int smallerOffset = 0; + int largerOffset = maximumOffset; + int firstOffset = 0; + int lastOffset = maximumOffset; + int height = getHeight() - getPaddingBottom() - getPaddingTop(); // get the nearest snap points to the target offset if (mSnapOffsets != null) {