From 764f652b451d27282cfaf73407d31c9522e6cb0e Mon Sep 17 00:00:00 2001 From: Brian Attwell Date: Mon, 22 Sep 2014 14:17:11 -0700 Subject: Improve scrolling, handle onNestedPreFling This contains three main changes. 1 Carry momentum from flings in the header into the ListView. 2 The header now snaps into a semi collapsed state more often then it used to 3 The current scrolling direction is now a larger factor in deciding where the header position will snap to upon finishing a scroll I coupled ViewDragHelper a bit closer to OverlappingPaneLayout. At first I tried to avoid this. But I think this was a wasted effort. ViewDragHelper is specifically forked for OverlappingPaneLayout. Some behaviors I made sure to test manually: 1 When expanding/collapsing the header the direction of motion should determine where the header snaps to upon release. 2 Collapsing from fully open to intermediate (not previously possible) 3 Drag tabs up/down regardless of whether at top of ListView or not (unchanged) 4 Dragging and releasing the tabs should cause the same sort of snapping behavior as scrolling and releasing the nested ListView (this still isn't exactly the same. I don't think this is important enough to dig into more) 5 After fully expanding the header by grabbing on the tabs, you can collapse the header normally via nested scrolling. 6 Scroll down the ListView. Then expand the header by dragging the tabs. Now scroll up and down in the ListView a bit. 7 Quickly fling up, down, up, down, up, down, up, down, up, down. Should feel the same as scrolling a regular ListView. 8 Fling upwards, stop the fling prematurly then release. The header shouldn't do anything (fixing this was a matter of adding a scroll slop). Bug: 16462679 Change-Id: I272a838885ce9045d41aaef1168b0ee0a32ee31d --- .../dialer/widget/OverlappingPaneLayout.java | 226 +++++++++++++++++---- src/com/android/dialer/widget/ViewDragHelper.java | 87 ++++++-- 2 files changed, 262 insertions(+), 51 deletions(-) (limited to 'src/com/android/dialer/widget') diff --git a/src/com/android/dialer/widget/OverlappingPaneLayout.java b/src/com/android/dialer/widget/OverlappingPaneLayout.java index b6b9ec777..b81722942 100644 --- a/src/com/android/dialer/widget/OverlappingPaneLayout.java +++ b/src/com/android/dialer/widget/OverlappingPaneLayout.java @@ -33,6 +33,7 @@ import android.util.AttributeSet; import android.util.Log; import android.view.MotionEvent; import android.view.View; +import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewParent; import android.view.accessibility.AccessibilityEvent; @@ -116,18 +117,27 @@ public class OverlappingPaneLayout extends ViewGroup { /** * Indicates that the layout is currently in the process of a nested pre-scroll operation where - * the child scrolling view is being dragged downwards, and still has the ability to consume - * scroll events itself. If so, we should open the pane up to the maximum offset defined in - * {@link #mIntermediateOffset}, and no further, so that the child view can continue performing - * its own scroll. + * the child scrolling view is being dragged downwards. */ - private boolean mInNestedPreScrollDownwards = false; + private boolean mInNestedPreScrollDownwards; /** - * Indicates whether or not a nested scrolling child is able to scroll internally at this point - * in time. + * Indicates that the layout is currently in the process of a nested pre-scroll operation where + * the child scrolling view is being dragged upwards. */ - private boolean mChildCannotConsumeScroll; + private boolean mInNestedPreScrollUpwards; + + /** + * Indicates that the layout is currently in the process of a fling initiated by a pre-fling + * from the child scrolling view. + */ + private boolean mIsInNestedFling; + + /** + * Indicates the direction of the pre fling. We need to store this information since + * OverScoller doesn't expose the direction of its velocity. + */ + private boolean mInUpwardsPreFling; /** * Stores an offset used to represent a point somewhere in between the panel's fully closed @@ -139,7 +149,7 @@ public class OverlappingPaneLayout extends ViewGroup { private float mInitialMotionX; private float mInitialMotionY; - private PanelSlideListener mPanelSlideListener; + private PanelSlideCallbacks mPanelSlideCallbacks; private final ViewDragHelper mDragHelper; @@ -154,9 +164,18 @@ public class OverlappingPaneLayout extends ViewGroup { private final Rect mTmpRect = new Rect(); /** - * Listener for monitoring events about sliding panes. + * How many dips we need to scroll past a position before we can snap to the next position + * on release. Using this prevents accidentally snapping to positions. + * + * This is needed since vertical nested scrolling can be passed to this class even if the + * vertical scroll is less than the the nested list's touch slop. */ - public interface PanelSlideListener { + private final int mReleaseScrollSlop; + + /** + * Callbacks for interacting with sliding panes. + */ + public interface PanelSlideCallbacks { /** * Called when a sliding pane's position changes. * @param panel The child view that was moved @@ -176,6 +195,22 @@ public class OverlappingPaneLayout extends ViewGroup { * @param panel The child view that was slid to a closed position */ public void onPanelClosed(View panel); + + /** + * Called when a sliding pane is flung as far open/closed as it can be. + * @param velocityY Velocity of the panel once its fling goes as far as it can. + */ + public void onPanelFlingReachesEdge(int velocityY); + + /** + * Returns true if the second panel's contents haven't been scrolled at all. This value is + * used to determine whether or not we can fully expand the header on downwards scrolls. + * + * Instead of using this callback, it would be preferable to instead fully expand the header + * on a View#onNestedFlingOver() callback. The behavior would be nicer. Unfortunately, + * no such callback exists yet (b/17547693). + */ + public boolean isScrollableChildUnscrolled(); } public OverlappingPaneLayout(Context context) { @@ -199,6 +234,8 @@ public class OverlappingPaneLayout extends ViewGroup { mDragHelper = ViewDragHelper.create(this, 0.5f, new DragHelperCallback()); mDragHelper.setMinVelocity(MIN_FLING_VELOCITY * density); + + mReleaseScrollSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); } /** @@ -218,27 +255,21 @@ public class OverlappingPaneLayout extends ViewGroup { mCapturableView = capturableView; } - public void setPanelSlideListener(PanelSlideListener listener) { - mPanelSlideListener = listener; + public void setPanelSlideCallbacks(PanelSlideCallbacks listener) { + mPanelSlideCallbacks = listener; } void dispatchOnPanelSlide(View panel) { - if (mPanelSlideListener != null) { - mPanelSlideListener.onPanelSlide(panel, mSlideOffset); - } + mPanelSlideCallbacks.onPanelSlide(panel, mSlideOffset); } void dispatchOnPanelOpened(View panel) { - if (mPanelSlideListener != null) { - mPanelSlideListener.onPanelOpened(panel); - } + mPanelSlideCallbacks.onPanelOpened(panel); sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); } void dispatchOnPanelClosed(View panel) { - if (mPanelSlideListener != null) { - mPanelSlideListener.onPanelClosed(panel); - } + mPanelSlideCallbacks.onPanelClosed(panel); sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED); } @@ -820,7 +851,7 @@ public class OverlappingPaneLayout extends ViewGroup { @Override public void computeScroll() { - if (mDragHelper.continueSettling(true)) { + if (mDragHelper.continueSettling(/* deferCallbacks = */ false)) { if (!mCanSlide) { mDragHelper.abort(); return; @@ -897,7 +928,6 @@ public class OverlappingPaneLayout extends ViewGroup { final boolean startNestedScroll = (nestedScrollAxes & SCROLL_AXIS_VERTICAL) != 0; if (startNestedScroll) { mIsInNestedScroll = true; - mChildCannotConsumeScroll = true; mDragHelper.startNestedScroll(mSlideableView); } if (DEBUG) { @@ -915,19 +945,41 @@ public class OverlappingPaneLayout extends ViewGroup { if (DEBUG) { Log.d(TAG, "onNestedPreScroll: " + dy); } - mInNestedPreScrollDownwards = - mChildCannotConsumeScroll && dy < 0 && mSlideOffsetPx <= mIntermediateOffset; + + mInNestedPreScrollDownwards = dy < 0; + mInNestedPreScrollUpwards = dy > 0; + mIsInNestedFling = false; mDragHelper.processNestedScroll(mSlideableView, 0, -dy, consumed); } + @Override + public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (!(velocityY > 0 && mSlideOffsetPx != 0 + || velocityY < 0 && mSlideOffsetPx < mIntermediateOffset + || velocityY < 0 && mSlideOffsetPx < mSlideRange + && mPanelSlideCallbacks.isScrollableChildUnscrolled())) { + // No need to consume the fling if the fling won't collapse or expand the header. + // How far we are willing to expand the header depends on isScrollableChildUnscrolled(). + return false; + } + + if (DEBUG) { + Log.d(TAG, "onNestedPreFling: " + velocityY); + } + mInUpwardsPreFling = velocityY > 0; + mIsInNestedFling = true; + mIsInNestedScroll = false; + mDragHelper.processNestedFling(mSlideableView, (int) -velocityY); + return true; + } + @Override public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed) { if (DEBUG) { Log.d(TAG, "onNestedScroll: " + dyUnconsumed); } - mChildCannotConsumeScroll = false; - mInNestedPreScrollDownwards = false; + mIsInNestedFling = false; mDragHelper.processNestedScroll(mSlideableView, 0, -dyUnconsumed, null); } @@ -938,8 +990,10 @@ public class OverlappingPaneLayout extends ViewGroup { } if (mIsInNestedScroll) { mDragHelper.stopNestedScroll(mSlideableView); + mInNestedPreScrollDownwards = false; + mInNestedPreScrollUpwards = false; + mIsInNestedScroll = false; } - mIsInNestedScroll = false; } private class DragHelperCallback extends ViewDragHelper.Callback { @@ -955,6 +1009,10 @@ public class OverlappingPaneLayout extends ViewGroup { @Override public void onViewDragStateChanged(int state) { + if (DEBUG) { + Log.d(TAG, "onViewDragStateChanged: " + state); + } + if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE) { if (mSlideOffset == 0) { updateObscuredViewsVisibility(mSlideableView); @@ -965,6 +1023,16 @@ public class OverlappingPaneLayout extends ViewGroup { mPreservedOpenState = true; } } + + if (mDragHelper.getVelocityMagnitude() > 0 + && (mDragHelper.getCurrentScrollY() == 0 + || mDragHelper.getCurrentScrollY() == mIntermediateOffset) + && mIsInNestedFling) { + mIsInNestedFling = false; + final int flingVelocity = !mInUpwardsPreFling ? + -mDragHelper.getVelocityMagnitude() : mDragHelper.getVelocityMagnitude(); + mPanelSlideCallbacks.onPanelFlingReachesEdge(flingVelocity); + } } @Override @@ -979,21 +1047,97 @@ public class OverlappingPaneLayout extends ViewGroup { invalidate(); } + @Override + public void onViewFling(View releasedChild, float xVelocity, float yVelocity) { + if (releasedChild == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "onViewFling: " + yVelocity); + } + + // Flings won't always fully expand or collapse the header. Instead of performing the + // fling and then waiting for the fling to end before snapping into place, we + // immediately snap into place if we predict the fling won't fully expand or collapse + // the header. + int yOffsetPx = mDragHelper.predictFlingYOffset((int) yVelocity); + if (yVelocity < 0) { + // Only perform a fling if we know the fling will fully compress the header. + if (-yOffsetPx > mSlideOffsetPx) { + mDragHelper.flingCapturedView(releasedChild.getLeft(), /* minTop = */ 0, + mSlideRange, Integer.MAX_VALUE, (int) yVelocity); + } else { + mIsInNestedFling = false; + onViewReleased(releasedChild, xVelocity, yVelocity); + } + } else { + // Only perform a fling if we know the fling will expand the header as far + // as it can possible be expanded, given the isScrollableChildUnscrolled() value. + if (yOffsetPx + mSlideOffsetPx >= mSlideRange + && mPanelSlideCallbacks.isScrollableChildUnscrolled()) { + mDragHelper.flingCapturedView(releasedChild.getLeft(), /* minTop = */ 0, + Integer.MAX_VALUE, mSlideRange, (int) yVelocity); + } else if (yOffsetPx + mSlideOffsetPx >= mIntermediateOffset + && mSlideOffsetPx <= mIntermediateOffset + && !mPanelSlideCallbacks.isScrollableChildUnscrolled()) { + mDragHelper.flingCapturedView(releasedChild.getLeft(), /* minTop = */ 0, + Integer.MAX_VALUE, mIntermediateOffset, (int) yVelocity); + } else { + mIsInNestedFling = false; + onViewReleased(releasedChild, xVelocity, yVelocity); + } + } + + mInNestedPreScrollDownwards = false; + mInNestedPreScrollUpwards = false; + + // Without this invalidate, some calls to flingCapturedView can have no affect. + invalidate(); + } + @Override public void onViewReleased(View releasedChild, float xvel, float yvel) { + if (DEBUG) { + Log.d(TAG, "onViewReleased: " + + " unscrolled=" + mPanelSlideCallbacks.isScrollableChildUnscrolled() + + ", mInNestedPreScrollDownwards = " + mInNestedPreScrollDownwards + + ", mInNestedPreScrollUpwards = " + mInNestedPreScrollUpwards + + ", yvel=" + yvel); + } if (releasedChild == null) { return; } - final LayoutParams lp = (LayoutParams) releasedChild.getLayoutParams(); + final LayoutParams lp = (LayoutParams) releasedChild.getLayoutParams(); int top = getPaddingTop() + lp.topMargin; - if (mInNestedPreScrollDownwards) { - // Snap to the closest pinnable position based on the current slide offset - // (in pixels) [0 - mIntermediateoffset - mSlideRange] - if (yvel > 0) { + // Decide where to snap to according to the current direction of motion and the current + // position. The velocity's magnitude has no bearing on this. + if (mInNestedPreScrollDownwards || yvel > 0) { + // Scrolling downwards + if (mSlideOffsetPx > mIntermediateOffset + mReleaseScrollSlop) { + top += mSlideRange; + } else if (mSlideOffsetPx > mReleaseScrollSlop) { + top += mIntermediateOffset; + } else { + // Offset is very close to 0 + } + } else if (mInNestedPreScrollUpwards || yvel < 0) { + // Scrolling upwards + if (mSlideOffsetPx > mSlideRange - mReleaseScrollSlop) { + // Offset is very close to mSlideRange top += mSlideRange; - } else if (0 <= mSlideOffsetPx && mSlideOffsetPx <= mIntermediateOffset / 2) { + } else if (mSlideOffsetPx > mIntermediateOffset - mReleaseScrollSlop) { + // Offset is between mIntermediateOffset and mSlideRange. + top += mIntermediateOffset; + } else { + // Offset is between 0 and mIntermediateOffset. + } + } else { + // Not moving upwards or downwards. This case can only be triggered when directly + // dragging the tabs. We don't bother to remember previous scroll direction + // when directly dragging the tabs. + if (0 <= mSlideOffsetPx && mSlideOffsetPx <= mIntermediateOffset / 2) { // Offset is between 0 and mIntermediateOffset, but closer to 0 // Leave top unchanged } else if (mIntermediateOffset / 2 <= mSlideOffsetPx @@ -1005,8 +1149,6 @@ public class OverlappingPaneLayout extends ViewGroup { // mSlideRange top += mSlideRange; } - } else if (yvel > 0 || (yvel == 0 && mSlideOffset > 0.5f)) { - top += mSlideRange; } mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top); @@ -1029,9 +1171,15 @@ public class OverlappingPaneLayout extends ViewGroup { final LayoutParams lp = (LayoutParams) mSlideableView.getLayoutParams(); final int newTop; + int previousTop = top - dy; int topBound = getPaddingTop() + lp.topMargin; - int bottomBound = topBound - + (mInNestedPreScrollDownwards ? mIntermediateOffset : mSlideRange); + int bottomBound = topBound + (mPanelSlideCallbacks.isScrollableChildUnscrolled() + || !mIsInNestedScroll ? mSlideRange : mIntermediateOffset); + if (previousTop > bottomBound) { + // We were previously below the bottomBound, so loosen the bottomBound so that this + // makes sense. This can occur after the view was directly dragged by the tabs. + bottomBound = Math.max(bottomBound, mSlideRange); + } newTop = Math.min(Math.max(top, topBound), bottomBound); return newTop; diff --git a/src/com/android/dialer/widget/ViewDragHelper.java b/src/com/android/dialer/widget/ViewDragHelper.java index 91016d15b..e4fe12be2 100644 --- a/src/com/android/dialer/widget/ViewDragHelper.java +++ b/src/com/android/dialer/widget/ViewDragHelper.java @@ -27,7 +27,6 @@ import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; -import android.view.animation.Interpolator; import java.util.Arrays; @@ -201,6 +200,18 @@ public class ViewDragHelper { */ public void onViewReleased(View releasedChild, float xvel, float yvel) {} + /** + * Called when the child view has been released with a fling. + * + *

Calling code may decide to fling or otherwise release the view to let it + * settle into place.

+ * + * @param releasedChild The captured child view now being released + * @param xvel X velocity of the fling. + * @param yvel Y velocity of the fling. + */ + public void onViewFling(View releasedChild, float xvel, float yvel) {} + /** * Called when one of the subscribed edges in the parent view has been touched * by the user while no child view is currently captured. @@ -321,16 +332,6 @@ public class ViewDragHelper { } } - /** - * Interpolator defining the animation curve for mScroller - */ - private static final Interpolator sInterpolator = new Interpolator() { - public float getInterpolation(float t) { - t -= 1.0f; - return t * t * t * t * t + 1.0f; - } - }; - private final Runnable mSetIdleRunnable = new Runnable() { public void run() { setDragState(STATE_IDLE); @@ -389,7 +390,7 @@ public class ViewDragHelper { mTouchSlop = vc.getScaledTouchSlop(); mMaxVelocity = vc.getScaledMaximumFlingVelocity(); mMinVelocity = vc.getScaledMinimumFlingVelocity(); - mScroller = ScrollerCompat.create(context, sInterpolator); + mScroller = ScrollerCompat.create(context); } /** @@ -701,6 +702,46 @@ public class ViewDragHelper { setDragState(STATE_SETTLING); } + /** + * Settle the captured view based on standard free-moving fling behavior. + * The caller should invoke {@link #continueSettling(boolean)} on each subsequent frame + * to continue the motion until it returns false. + * + * @param minLeft Minimum X position for the view's left edge + * @param minTop Minimum Y position for the view's top edge + * @param maxLeft Maximum X position for the view's left edge + * @param maxTop Maximum Y position for the view's top edge + * @param yvel the Y velocity to fling with + */ + public void flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop, int yvel) { + if (!mReleaseInProgress) { + throw new IllegalStateException("Cannot flingCapturedView outside of a call to " + + "Callback#onViewReleased"); + } + mScroller.abortAnimation(); + mScroller.fling(mCapturedView.getLeft(), mCapturedView.getTop(), 0, yvel, minLeft, maxLeft, + minTop, maxTop); + + setDragState(STATE_SETTLING); + } + + /** + * Predict how far a fling with {@param yvel} will cause the view to travel from stand still. + * @return predicted y offset + */ + public int predictFlingYOffset(int yvel) { + mScroller.abortAnimation(); + mScroller.fling(0, 0, 0, yvel, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, + Integer.MAX_VALUE); + final int finalY = mScroller.getFinalY(); + mScroller.abortAnimation(); + return finalY; + } + + public int getCurrentScrollY() { + return mScroller.getCurrY(); + } + /** * Move the captured settling view by the appropriate amount for the current time. * If continueSettling returns true, the caller should call it again @@ -750,6 +791,28 @@ public class ViewDragHelper { return mDragState == STATE_SETTLING; } + public void processNestedFling(View target, int yvel) { + mCapturedView = target; + dispatchViewFling(0, yvel); + } + + public int getVelocityMagnitude() { + // Use Math.abs() to ensure this always returns an absolute value, even if the + // ScrollerCompat implementation changes. + return (int) Math.abs(mScroller.getCurrVelocity()); + } + + private void dispatchViewFling(float xvel, float yvel) { + mReleaseInProgress = true; + mCallback.onViewFling(mCapturedView, xvel, yvel); + mReleaseInProgress = false; + + if (mDragState == STATE_DRAGGING) { + // onViewReleased didn't call a method that would have changed this. Go idle. + setDragState(STATE_IDLE); + } + } + /** * Like all callback events this must happen on the UI thread, but release * involves some extra semantics. During a release (mReleaseInProgress) -- cgit v1.2.3