From 28b3b51c4e2361197836636870fa80c8cc1a0658 Mon Sep 17 00:00:00 2001 From: George Mount Date: Tue, 28 Jun 2022 09:20:17 -0700 Subject: [PATCH] Support fling back in scrolling containers Bug: 232903223 When a user flings the overscroll toward the scrolling content, the user might think that any left-over velocity should apply to scrolling the content. This CL makes that happen. If the fling is less than the overscroll distance, then the normal spring is applied. This CL changes ListView, ScrollView, and HorizontalScrollView. Test: manual testing and new CTS tests Change-Id: I1a759377f5183eec805598a6b016515297191624 --- core/java/android/widget/AbsListView.java | 88 ++++++++++++++++++- .../android/widget/HorizontalScrollView.java | 82 +++++++++++++++-- core/java/android/widget/OverScroller.java | 4 + core/java/android/widget/ScrollView.java | 78 ++++++++++++++-- 4 files changed, 239 insertions(+), 13 deletions(-) diff --git a/core/java/android/widget/AbsListView.java b/core/java/android/widget/AbsListView.java index 184e7bca963b7..1cb96b15b8ef2 100644 --- a/core/java/android/widget/AbsListView.java +++ b/core/java/android/widget/AbsListView.java @@ -241,6 +241,14 @@ public abstract class AbsListView extends AdapterView implements Te */ public static final int CHOICE_MODE_MULTIPLE_MODAL = 3; + /** + * When flinging the stretch towards scrolling content, it should destretch quicker than the + * fling would normally do. The visual effect of flinging the stretch looks strange as little + * appears to happen at first and then when the stretch disappears, the content starts + * scrolling quickly. + */ + private static final float FLING_DESTRETCH_FACTOR = 4f; + /** * The thread that created this view. */ @@ -4216,9 +4224,23 @@ public abstract class AbsListView extends AdapterView implements Te // fling further. boolean flingVelocity = Math.abs(initialVelocity) > mMinimumVelocity; if (flingVelocity && !mEdgeGlowTop.isFinished()) { - mEdgeGlowTop.onAbsorb(initialVelocity); + if (shouldAbsorb(mEdgeGlowTop, initialVelocity)) { + mEdgeGlowTop.onAbsorb(initialVelocity); + } else { + if (mFlingRunnable == null) { + mFlingRunnable = new FlingRunnable(); + } + mFlingRunnable.start(-initialVelocity); + } } else if (flingVelocity && !mEdgeGlowBottom.isFinished()) { - mEdgeGlowBottom.onAbsorb(-initialVelocity); + if (shouldAbsorb(mEdgeGlowBottom, -initialVelocity)) { + mEdgeGlowBottom.onAbsorb(-initialVelocity); + } else { + if (mFlingRunnable == null) { + mFlingRunnable = new FlingRunnable(); + } + mFlingRunnable.start(-initialVelocity); + } } else if (flingVelocity && !((mFirstPosition == 0 && firstChildTop == contentTop - mOverscrollDistance) @@ -4301,6 +4323,60 @@ public abstract class AbsListView extends AdapterView implements Te } } + /** + * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should + * animate with a fling. It will animate with a fling if the velocity will remove the + * EdgeEffect through its normal operation. + * + * @param edgeEffect The EdgeEffect that might absorb the velocity. + * @param velocity The velocity of the fling motion + * @return true if the velocity should be absorbed or false if it should be flung. + */ + private boolean shouldAbsorb(EdgeEffect edgeEffect, int velocity) { + if (velocity > 0) { + return true; + } + float distance = edgeEffect.getDistance() * getHeight(); + + // This is flinging without the spring, so let's see if it will fling past the overscroll + if (mFlingRunnable == null) { + mFlingRunnable = new FlingRunnable(); + } + float flingDistance = mFlingRunnable.getSplineFlingDistance(-velocity); + + return flingDistance < distance; + } + + /** + * Used by consumeFlingInHorizontalStretch() and consumeFlinInVerticalStretch() for + * consuming deltas from EdgeEffects + * @param unconsumed The unconsumed delta that the EdgeEffets may consume + * @return The unconsumed delta after the EdgeEffects have had an opportunity to consume. + */ + private int consumeFlingInStretch(int unconsumed) { + if (unconsumed < 0 && mEdgeGlowTop != null && mEdgeGlowTop.getDistance() != 0f) { + int size = getHeight(); + float deltaDistance = unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(size / FLING_DESTRETCH_FACTOR + * mEdgeGlowTop.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowTop.finish(); + } + return unconsumed - consumed; + } + if (unconsumed > 0 && mEdgeGlowBottom != null && mEdgeGlowBottom.getDistance() != 0f) { + int size = getHeight(); + float deltaDistance = -unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(-size / FLING_DESTRETCH_FACTOR + * mEdgeGlowBottom.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowBottom.finish(); + } + return unconsumed - consumed; + } + return unconsumed; + } + private boolean shouldDisplayEdgeEffects() { return getOverScrollMode() != OVER_SCROLL_NEVER; } @@ -4783,6 +4859,10 @@ public abstract class AbsListView extends AdapterView implements Te mScroller = new OverScroller(getContext()); } + float getSplineFlingDistance(int velocity) { + return (float) mScroller.getSplineFlingDistance(velocity); + } + // Use AbsListView#fling(int) instead @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P) void start(int initialVelocity) { @@ -4905,6 +4985,8 @@ public abstract class AbsListView extends AdapterView implements Te } if (mItemCount == 0 || getChildCount() == 0) { + mEdgeGlowBottom.onRelease(); + mEdgeGlowTop.onRelease(); endFling(); return; } @@ -4915,7 +4997,7 @@ public abstract class AbsListView extends AdapterView implements Te // Flip sign to convert finger direction to list items direction // (e.g. finger moving down means list is moving towards the top) - int delta = mLastFlingY - y; + int delta = consumeFlingInStretch(mLastFlingY - y); // Pretend that each frame of a fling scroll is a touch scroll if (delta > 0) { diff --git a/core/java/android/widget/HorizontalScrollView.java b/core/java/android/widget/HorizontalScrollView.java index 2dbfd7e5b2e26..1d6778b8a4a96 100644 --- a/core/java/android/widget/HorizontalScrollView.java +++ b/core/java/android/widget/HorizontalScrollView.java @@ -77,6 +77,14 @@ public class HorizontalScrollView extends FrameLayout { private static final String TAG = "HorizontalScrollView"; + /** + * When flinging the stretch towards scrolling content, it should destretch quicker than the + * fling would normally do. The visual effect of flinging the stretch looks strange as little + * appears to happen at first and then when the stretch disappears, the content starts + * scrolling quickly. + */ + private static final float FLING_DESTRETCH_FACTOR = 4f; + private long mLastScroll; private final Rect mTempRect = new Rect(); @@ -1456,18 +1464,19 @@ public class HorizontalScrollView extends FrameLayout { int oldY = mScrollY; int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); + int deltaX = consumeFlingInStretch(x - oldX); - if (oldX != x || oldY != y) { + if (deltaX != 0 || oldY != y) { final int range = getScrollRange(); final int overscrollMode = getOverScrollMode(); final boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); - overScrollBy(x - oldX, y - oldY, oldX, oldY, range, 0, + overScrollBy(deltaX, y - oldY, oldX, oldY, range, 0, mOverflingDistance, 0, false); onScrollChanged(mScrollX, mScrollY, oldX, oldY); - if (canOverscroll) { + if (canOverscroll && deltaX != 0) { if (x < 0 && oldX >= 0) { mEdgeGlowLeft.onAbsorb((int) mScroller.getCurrVelocity()); } else if (x > range && oldX <= range) { @@ -1482,6 +1491,36 @@ public class HorizontalScrollView extends FrameLayout { } } + /** + * Used by consumeFlingInHorizontalStretch() and consumeFlinInVerticalStretch() for + * consuming deltas from EdgeEffects + * @param unconsumed The unconsumed delta that the EdgeEffets may consume + * @return The unconsumed delta after the EdgeEffects have had an opportunity to consume. + */ + private int consumeFlingInStretch(int unconsumed) { + if (unconsumed > 0 && mEdgeGlowLeft != null && mEdgeGlowLeft.getDistance() != 0f) { + int size = getWidth(); + float deltaDistance = -unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(-size / FLING_DESTRETCH_FACTOR + * mEdgeGlowLeft.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowLeft.finish(); + } + return unconsumed - consumed; + } + if (unconsumed < 0 && mEdgeGlowRight != null && mEdgeGlowRight.getDistance() != 0f) { + int size = getWidth(); + float deltaDistance = unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(size / FLING_DESTRETCH_FACTOR + * mEdgeGlowRight.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowRight.finish(); + } + return unconsumed - consumed; + } + return unconsumed; + } + /** * Scrolls the view to the given child. * @@ -1746,11 +1785,23 @@ public class HorizontalScrollView extends FrameLayout { int maxScroll = Math.max(0, right - width); + boolean shouldFling = false; if (mScrollX == 0 && !mEdgeGlowLeft.isFinished()) { - mEdgeGlowLeft.onAbsorb(-velocityX); + if (shouldAbsorb(mEdgeGlowLeft, -velocityX)) { + mEdgeGlowLeft.onAbsorb(-velocityX); + } else { + shouldFling = true; + } } else if (mScrollX == maxScroll && !mEdgeGlowRight.isFinished()) { - mEdgeGlowRight.onAbsorb(velocityX); + if (shouldAbsorb(mEdgeGlowRight, velocityX)) { + mEdgeGlowRight.onAbsorb(velocityX); + } else { + shouldFling = true; + } } else { + shouldFling = true; + } + if (shouldFling) { mScroller.fling(mScrollX, mScrollY, velocityX, 0, 0, maxScroll, 0, 0, width / 2, 0); @@ -1773,6 +1824,27 @@ public class HorizontalScrollView extends FrameLayout { } } + /** + * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should + * animate with a fling. It will animate with a fling if the velocity will remove the + * EdgeEffect through its normal operation. + * + * @param edgeEffect The EdgeEffect that might absorb the velocity. + * @param velocity The velocity of the fling motion + * @return true if the velocity should be absorbed or false if it should be flung. + */ + private boolean shouldAbsorb(EdgeEffect edgeEffect, int velocity) { + if (velocity > 0) { + return true; + } + float distance = edgeEffect.getDistance() * getWidth(); + + // This is flinging without the spring, so let's see if it will fling past the overscroll + float flingDistance = (float) mScroller.getSplineFlingDistance(-velocity); + + return flingDistance < distance; + } + /** * {@inheritDoc} * diff --git a/core/java/android/widget/OverScroller.java b/core/java/android/widget/OverScroller.java index 1683878cd8b23..d00fc1c89d8fb 100644 --- a/core/java/android/widget/OverScroller.java +++ b/core/java/android/widget/OverScroller.java @@ -527,6 +527,10 @@ public class OverScroller { Math.signum(yvel) == Math.signum(dy); } + double getSplineFlingDistance(int velocity) { + return mScrollerY.getSplineFlingDistance(velocity); + } + static class SplineOverScroller { // Initial position private int mStart; diff --git a/core/java/android/widget/ScrollView.java b/core/java/android/widget/ScrollView.java index 2acd50c9e169b..84b6f65ffea63 100644 --- a/core/java/android/widget/ScrollView.java +++ b/core/java/android/widget/ScrollView.java @@ -85,6 +85,14 @@ public class ScrollView extends FrameLayout { private static final String TAG = "ScrollView"; + /** + * When flinging the stretch towards scrolling content, it should destretch quicker than the + * fling would normally do. The visual effect of flinging the stretch looks strange as little + * appears to happen at first and then when the stretch disappears, the content starts + * scrolling quickly. + */ + private static final float FLING_DESTRETCH_FACTOR = 4f; + @UnsupportedAppUsage private long mLastScroll; @@ -1488,18 +1496,19 @@ public class ScrollView extends FrameLayout { int oldY = mScrollY; int x = mScroller.getCurrX(); int y = mScroller.getCurrY(); + int deltaY = consumeFlingInStretch(y - oldY); - if (oldX != x || oldY != y) { + if (oldX != x || deltaY != 0) { final int range = getScrollRange(); final int overscrollMode = getOverScrollMode(); final boolean canOverscroll = overscrollMode == OVER_SCROLL_ALWAYS || (overscrollMode == OVER_SCROLL_IF_CONTENT_SCROLLS && range > 0); - overScrollBy(x - oldX, y - oldY, oldX, oldY, 0, range, + overScrollBy(x - oldX, deltaY, oldX, oldY, 0, range, 0, mOverflingDistance, false); onScrollChanged(mScrollX, mScrollY, oldX, oldY); - if (canOverscroll) { + if (canOverscroll && deltaY != 0) { if (y < 0 && oldY >= 0) { mEdgeGlowTop.onAbsorb((int) mScroller.getCurrVelocity()); } else if (y > range && oldY <= range) { @@ -1520,6 +1529,36 @@ public class ScrollView extends FrameLayout { } } + /** + * Used by consumeFlingInHorizontalStretch() and consumeFlinInVerticalStretch() for + * consuming deltas from EdgeEffects + * @param unconsumed The unconsumed delta that the EdgeEffets may consume + * @return The unconsumed delta after the EdgeEffects have had an opportunity to consume. + */ + private int consumeFlingInStretch(int unconsumed) { + if (unconsumed > 0 && mEdgeGlowTop != null && mEdgeGlowTop.getDistance() != 0f) { + int size = getHeight(); + float deltaDistance = -unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(-size / FLING_DESTRETCH_FACTOR + * mEdgeGlowTop.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowTop.finish(); + } + return unconsumed - consumed; + } + if (unconsumed < 0 && mEdgeGlowBottom != null && mEdgeGlowBottom.getDistance() != 0f) { + int size = getHeight(); + float deltaDistance = unconsumed * FLING_DESTRETCH_FACTOR / size; + int consumed = Math.round(size / FLING_DESTRETCH_FACTOR + * mEdgeGlowBottom.onPullDistance(deltaDistance, 0.5f)); + if (consumed != unconsumed) { + mEdgeGlowBottom.finish(); + } + return unconsumed - consumed; + } + return unconsumed; + } + /** * Scrolls the view to the given child. * @@ -1803,14 +1842,43 @@ public class ScrollView extends FrameLayout { fling(velocityY); } else if (!consumed) { if (!mEdgeGlowTop.isFinished()) { - mEdgeGlowTop.onAbsorb(-velocityY); + if (shouldAbsorb(mEdgeGlowTop, -velocityY)) { + mEdgeGlowTop.onAbsorb(-velocityY); + } else { + fling(velocityY); + } } else if (!mEdgeGlowBottom.isFinished()) { - mEdgeGlowBottom.onAbsorb(velocityY); + if (shouldAbsorb(mEdgeGlowBottom, velocityY)) { + mEdgeGlowBottom.onAbsorb(velocityY); + } else { + fling(velocityY); + } } } } } + /** + * Returns true if edgeEffect should call onAbsorb() with veclocity or false if it should + * animate with a fling. It will animate with a fling if the velocity will remove the + * EdgeEffect through its normal operation. + * + * @param edgeEffect The EdgeEffect that might absorb the velocity. + * @param velocity The velocity of the fling motion + * @return true if the velocity should be absorbed or false if it should be flung. + */ + private boolean shouldAbsorb(EdgeEffect edgeEffect, int velocity) { + if (velocity > 0) { + return true; + } + float distance = edgeEffect.getDistance() * getHeight(); + + // This is flinging without the spring, so let's see if it will fling past the overscroll + float flingDistance = (float) mScroller.getSplineFlingDistance(-velocity); + + return flingDistance < distance; + } + @UnsupportedAppUsage private void endDrag() { mIsBeingDragged = false;