diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 28f012e0b2cacf..173b7b75813eec 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -6319,6 +6319,7 @@ public class com/facebook/react/views/scroll/ReactHorizontalScrollView : android public fun reactSmoothScrollTo (II)V public fun requestChildFocus (Landroid/view/View;Landroid/view/View;)V public fun scrollTo (II)V + public fun scrollToPreservingMomentum (II)V public fun setBackgroundColor (I)V public fun setBorderColor (IFF)V public fun setBorderRadius (F)V @@ -6443,6 +6444,7 @@ public class com/facebook/react/views/scroll/ReactScrollView : android/widget/Sc public fun reactSmoothScrollTo (II)V public fun requestChildFocus (Landroid/view/View;Landroid/view/View;)V public fun scrollTo (II)V + public fun scrollToPreservingMomentum (II)V public fun setBackgroundColor (I)V public fun setBorderColor (IFF)V public fun setBorderRadius (F)V @@ -6551,6 +6553,7 @@ public abstract interface class com/facebook/react/views/scroll/ReactScrollViewH public abstract interface class com/facebook/react/views/scroll/ReactScrollViewHelper$HasSmoothScroll { public abstract fun reactSmoothScrollTo (II)V + public abstract fun scrollToPreservingMomentum (II)V } public abstract interface class com/facebook/react/views/scroll/ReactScrollViewHelper$HasStateWrapper { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java index 20ab0d71a47b83..26c9aebc8388ad 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java @@ -118,7 +118,7 @@ private void updateScrollPositionInternal() { int deltaX = newFrame.left - mPrevFirstVisibleFrame.left; if (deltaX != 0) { int scrollX = mScrollView.getScrollX(); - mScrollView.scrollTo(scrollX + deltaX, mScrollView.getScrollY()); + mScrollView.scrollToPreservingMomentum(scrollX + deltaX, mScrollView.getScrollY()); mPrevFirstVisibleFrame = newFrame; if (mConfig.autoScrollToTopThreshold != null && scrollX <= mConfig.autoScrollToTopThreshold) { @@ -129,7 +129,7 @@ private void updateScrollPositionInternal() { int deltaY = newFrame.top - mPrevFirstVisibleFrame.top; if (deltaY != 0) { int scrollY = mScrollView.getScrollY(); - mScrollView.scrollTo(mScrollView.getScrollX(), scrollY + deltaY); + mScrollView.scrollToPreservingMomentum(mScrollView.getScrollX(), scrollY + deltaY); mPrevFirstVisibleFrame = newFrame; if (mConfig.autoScrollToTopThreshold != null && scrollY <= mConfig.autoScrollToTopThreshold) { diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 3ec4c5c96e4ee7..8a3eedc4b2f75b 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -1324,6 +1324,13 @@ public void scrollTo(int x, int y) { setPendingContentOffsets(x, y); } + /** Scrolls to a new position preserving any momentum scrolling animation. */ + @Override + public void scrollToPreservingMomentum(int x, int y) { + scrollTo(x, y); + recreateFlingAnimation(x, Integer.MAX_VALUE); + } + private boolean isContentReady() { View child = getContentView(); return child != null && child.getWidth() != 0 && child.getHeight() != 0; @@ -1377,24 +1384,21 @@ public void onLayoutChange( } } - private void adjustPositionForContentChangeRTL(int left, int right, int oldLeft, int oldRight) { - // If we have any pending custon flings (e.g. from aninmated `scrollTo`, or flinging to a snap - // point), finish them, commiting the final `scrollX`. + /** + * If we are in the middle of a fling animation from the user removing their finger (OverScroller + * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against + * outdated scroll offsets. + */ + private void recreateFlingAnimation(int scrollX, int maxX) { + // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap + // point), cancel them. // TODO: Can we be more graceful (like OverScroller flings)? if (getFlingAnimator().isRunning()) { - getFlingAnimator().end(); + getFlingAnimator().cancel(); } - int distanceToRightEdge = oldRight - getScrollX(); - int newWidth = right - left; - int scrollX = newWidth - distanceToRightEdge; - scrollTo(scrollX, getScrollY()); - - // If we are in the middle of a fling animation from the user removing their finger - // (OverScroller is in `FLING_MODE`), we must cancel and recreate the existing fling animation - // since it was calculated against outdated scroll offsets. if (mScroller != null && !mScroller.isFinished()) { - // Calculate the veliocity and position of the fling animation at the time of this layout + // Calculate the velocity and position of the fling animation at the time of this layout // event, which may be later than the last ScrollView tick. These values are not commited to // the underlying ScrollView, which will recalculate positions on its next tick. int scrollerXBeforeTick = mScroller.getCurrX(); @@ -1413,14 +1417,29 @@ private void adjustPositionForContentChangeRTL(int left, int right, int oldLeft, float direction = Math.signum(mScroller.getFinalX() - mScroller.getStartX()); float flingVelocityX = mScroller.getCurrVelocity() * direction; - mScroller.fling( - scrollX, getScrollY(), (int) flingVelocityX, 0, 0, newWidth - getWidth(), 0, 0); + mScroller.fling(scrollX, getScrollY(), (int) flingVelocityX, 0, 0, maxX, 0, 0); } else { scrollTo(scrollX + (mScroller.getCurrX() - scrollerXBeforeTick), getScrollY()); } } } + private void adjustPositionForContentChangeRTL(int left, int right, int oldLeft, int oldRight) { + // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap + // point), finish them, committing the final `scrollX`. + // TODO: Can we be more graceful (like OverScroller flings)? + if (getFlingAnimator().isRunning()) { + getFlingAnimator().end(); + } + + int distanceToRightEdge = oldRight - getScrollX(); + int newWidth = right - left; + int scrollX = newWidth - distanceToRightEdge; + scrollTo(scrollX, getScrollY()); + + recreateFlingAnimation(scrollX, newWidth - getWidth()); + } + @Nullable public StateWrapper getStateWrapper() { return mStateWrapper; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 26950b2ea9f958..a501f90f9a7434 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -1091,6 +1091,53 @@ public void scrollTo(int x, int y) { setPendingContentOffsets(x, y); } + /** + * If we are in the middle of a fling animation from the user removing their finger (OverScroller + * is in `FLING_MODE`), recreate the existing fling animation since it was calculated against + * outdated scroll offsets. + */ + private void recreateFlingAnimation(int scrollY) { + // If we have any pending custom flings (e.g. from animated `scrollTo`, or flinging to a snap + // point), cancel them. + // TODO: Can we be more graceful (like OverScroller flings)? + if (getFlingAnimator().isRunning()) { + getFlingAnimator().cancel(); + } + + if (mScroller != null && !mScroller.isFinished()) { + // Calculate the velocity and position of the fling animation at the time of this layout + // event, which may be later than the last ScrollView tick. These values are not committed to + // the underlying ScrollView, which will recalculate positions on its next tick. + int scrollerYBeforeTick = mScroller.getCurrY(); + boolean hasMoreTicks = mScroller.computeScrollOffset(); + + // Stop the existing animation at the current state of the scroller. We will then recreate + // it starting at the adjusted y offset. + mScroller.forceFinished(true); + + if (hasMoreTicks) { + // OverScroller.getCurrVelocity() returns an absolute value of the velocity a current fling + // animation (only FLING_MODE animations). We derive direction along the Y axis from the + // start and end of the, animation assuming ScrollView never fires horizontal fling + // animations. + // TODO: This does not fully handle overscroll. + float direction = Math.signum(mScroller.getFinalY() - mScroller.getStartY()); + float flingVelocityY = mScroller.getCurrVelocity() * direction; + + mScroller.fling(getScrollX(), scrollY, 0, (int) flingVelocityY, 0, 0, 0, Integer.MAX_VALUE); + } else { + scrollTo(getScrollX(), scrollY + (mScroller.getCurrX() - scrollerYBeforeTick)); + } + } + } + + /** Scrolls to a new position preserving any momentum scrolling animation. */ + @Override + public void scrollToPreservingMomentum(int x, int y) { + scrollTo(x, y); + recreateFlingAnimation(y); + } + private boolean isContentReady() { View child = getContentView(); return child != null && child.getWidth() != 0 && child.getHeight() != 0; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java index 7a9348259d473e..85ed97ec40f496 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java @@ -597,5 +597,7 @@ public interface HasScrollEventThrottle { public interface HasSmoothScroll { void reactSmoothScrollTo(int x, int y); + + void scrollToPreservingMomentum(int x, int y); } }