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
diff --git a/src/com/android/dialer/list/ListsFragment.java b/src/com/android/dialer/list/ListsFragment.java
index 7cc519f..2aa78a2 100644
--- a/src/com/android/dialer/list/ListsFragment.java
+++ b/src/com/android/dialer/list/ListsFragment.java
@@ -4,15 +4,10 @@
 import android.app.ActionBar;
 import android.app.Fragment;
 import android.app.FragmentManager;
-import android.app.LoaderManager;
 import android.content.Context;
-import android.content.CursorLoader;
-import android.content.Loader;
 import android.content.SharedPreferences;
 import android.database.Cursor;
-import android.net.Uri;
 import android.os.Bundle;
-import android.provider.CallLog;
 import android.support.v13.app.FragmentPagerAdapter;
 import android.support.v4.view.ViewPager;
 import android.support.v4.view.ViewPager.OnPageChangeListener;
@@ -20,6 +15,7 @@
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.AbsListView;
 import android.widget.ListView;
 
 import com.android.contacts.common.GeoUtil;
@@ -34,7 +30,7 @@
 import com.android.dialer.list.ShortcutCardsAdapter.SwipeableShortcutCard;
 import com.android.dialer.util.DialerUtils;
 import com.android.dialer.widget.OverlappingPaneLayout;
-import com.android.dialer.widget.OverlappingPaneLayout.PanelSlideListener;
+import com.android.dialer.widget.OverlappingPaneLayout.PanelSlideCallbacks;
 import com.android.dialerbind.analytics.AnalyticsFragment;
 import com.android.dialerbind.ObjectFactory;
 
@@ -108,7 +104,7 @@
      */
     private long mCurrentCallShortcutDate = 0;
 
-    private PanelSlideListener mPanelSlideListener = new PanelSlideListener() {
+    private PanelSlideCallbacks mPanelSlideCallbacks = new PanelSlideCallbacks() {
         @Override
         public void onPanelSlide(View panel, float slideOffset) {
             // For every 1 percent that the panel is slid upwards, clip 1 percent off the top
@@ -152,8 +148,35 @@
             }
             mIsPanelOpen = false;
         }
+
+        @Override
+        public void onPanelFlingReachesEdge(int velocityY) {
+            if (getCurrentListView() != null) {
+                getCurrentListView().fling(velocityY);
+            }
+        }
+
+        @Override
+        public boolean isScrollableChildUnscrolled() {
+            final AbsListView listView = getCurrentListView();
+            return listView != null && (listView.getChildCount() == 0
+                    || listView.getChildAt(0).getTop() == listView.getPaddingTop());
+        }
     };
 
+    private AbsListView getCurrentListView() {
+        final int position = mViewPager.getCurrentItem();
+        switch (getRtlPosition(position)) {
+            case TAB_INDEX_SPEED_DIAL:
+                return mSpeedDialFragment == null ? null : mSpeedDialFragment.getListView();
+            case TAB_INDEX_RECENTS:
+                return mRecentsFragment == null ? null : mRecentsFragment.getListView();
+            case TAB_INDEX_ALL_CONTACTS:
+                return mAllContactsFragment == null ? null : mAllContactsFragment.getListView();
+        }
+        throw new IllegalStateException("No fragment at position " + position);
+    }
+
     public class ViewPagerAdapter extends FragmentPagerAdapter {
         public ViewPagerAdapter(FragmentManager fm) {
             super(fm);
@@ -178,6 +201,26 @@
         }
 
         @Override
+        public Object instantiateItem(ViewGroup container, int position) {
+            // On rotation the FragmentManager handles rotation. Therefore getItem() isn't called.
+            // Copy the fragments that the FragmentManager finds so that we can store them in
+            // instance variables for later.
+            final Fragment fragment = (Fragment) super.instantiateItem(container, position);
+            switch (getRtlPosition(position)) {
+                case TAB_INDEX_SPEED_DIAL:
+                    mSpeedDialFragment = (SpeedDialFragment) fragment;
+                    return mSpeedDialFragment;
+                case TAB_INDEX_RECENTS:
+                    mRecentsFragment = (CallLogFragment) fragment;
+                    return mRecentsFragment;
+                case TAB_INDEX_ALL_CONTACTS:
+                    mAllContactsFragment = (AllContactsFragment) fragment;
+                    return mAllContactsFragment;
+            }
+            return super.instantiateItem(container, position);
+        }
+
+        @Override
         public int getCount() {
             return TAB_INDEX_COUNT;
         }
@@ -360,7 +403,7 @@
         // the framework better supports nested scrolling.
         paneLayout.setCapturableView(mViewPagerTabs);
         paneLayout.openPane();
-        paneLayout.setPanelSlideListener(mPanelSlideListener);
+        paneLayout.setPanelSlideCallbacks(mPanelSlideCallbacks);
         paneLayout.setIntermediatePinnedOffset(
                 ((HostInterface) getActivity()).getActionBarHeight());
 
diff --git a/src/com/android/dialer/list/SpeedDialFragment.java b/src/com/android/dialer/list/SpeedDialFragment.java
index 9732e19..c02c3d7 100644
--- a/src/com/android/dialer/list/SpeedDialFragment.java
+++ b/src/com/android/dialer/list/SpeedDialFragment.java
@@ -420,4 +420,8 @@
     public void cacheOffsetsForDatasetChange() {
         saveOffsets(0);
     }
+
+    public AbsListView getListView() {
+        return mListView;
+    }
 }
diff --git a/src/com/android/dialer/widget/OverlappingPaneLayout.java b/src/com/android/dialer/widget/OverlappingPaneLayout.java
index b6b9ec7..b817229 100644
--- a/src/com/android/dialer/widget/OverlappingPaneLayout.java
+++ b/src/com/android/dialer/widget/OverlappingPaneLayout.java
@@ -33,6 +33,7 @@
 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 @@
 
     /**
      * 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 @@
     private float mInitialMotionX;
     private float mInitialMotionY;
 
-    private PanelSlideListener mPanelSlideListener;
+    private PanelSlideCallbacks mPanelSlideCallbacks;
 
     private final ViewDragHelper mDragHelper;
 
@@ -154,9 +164,18 @@
     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 @@
          * @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 @@
 
         mDragHelper = ViewDragHelper.create(this, 0.5f, new DragHelperCallback());
         mDragHelper.setMinVelocity(MIN_FLING_VELOCITY * density);
+
+        mReleaseScrollSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop();
     }
 
     /**
@@ -218,27 +255,21 @@
         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 @@
 
     @Override
     public void computeScroll() {
-        if (mDragHelper.continueSettling(true)) {
+        if (mDragHelper.continueSettling(/* deferCallbacks = */ false)) {
             if (!mCanSlide) {
                 mDragHelper.abort();
                 return;
@@ -897,7 +928,6 @@
         final boolean startNestedScroll = (nestedScrollAxes & SCROLL_AXIS_VERTICAL) != 0;
         if (startNestedScroll) {
             mIsInNestedScroll = true;
-            mChildCannotConsumeScroll = true;
             mDragHelper.startNestedScroll(mSlideableView);
         }
         if (DEBUG) {
@@ -915,19 +945,41 @@
         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 @@
         }
         if (mIsInNestedScroll) {
             mDragHelper.stopNestedScroll(mSlideableView);
+            mInNestedPreScrollDownwards = false;
+            mInNestedPreScrollUpwards = false;
+            mIsInNestedScroll = false;
         }
-        mIsInNestedScroll = false;
     }
 
     private class DragHelperCallback extends ViewDragHelper.Callback {
@@ -955,6 +1009,10 @@
 
         @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 @@
                     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
@@ -980,20 +1048,96 @@
         }
 
         @Override
-        public void onViewReleased(View releasedChild, float xvel, float yvel) {
+        public void onViewFling(View releasedChild, float xVelocity, float yVelocity) {
             if (releasedChild == null) {
                 return;
             }
-            final LayoutParams lp = (LayoutParams) releasedChild.getLayoutParams();
+            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();
             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 (0 <= mSlideOffsetPx && mSlideOffsetPx <= mIntermediateOffset / 2) {
+                } 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 (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 @@
                     // mSlideRange
                     top += mSlideRange;
                 }
-            } else if (yvel > 0 || (yvel == 0 && mSlideOffset > 0.5f)) {
-                top += mSlideRange;
             }
 
             mDragHelper.settleCapturedViewAt(releasedChild.getLeft(), top);
@@ -1029,9 +1171,15 @@
             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 91016d1..e4fe12b 100644
--- a/src/com/android/dialer/widget/ViewDragHelper.java
+++ b/src/com/android/dialer/widget/ViewDragHelper.java
@@ -27,7 +27,6 @@
 import android.view.View;
 import android.view.ViewConfiguration;
 import android.view.ViewGroup;
-import android.view.animation.Interpolator;
 
 import java.util.Arrays;
 
@@ -202,6 +201,18 @@
         public void onViewReleased(View releasedChild, float xvel, float yvel) {}
 
         /**
+         * Called when the child view has been released with a fling.
+         *
+         * <p>Calling code may decide to fling or otherwise release the view to let it
+         * settle into place.</p>
+         *
+         * @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 @@
         }
     }
 
-    /**
-     * 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 @@
         mTouchSlop = vc.getScaledTouchSlop();
         mMaxVelocity = vc.getScaledMaximumFlingVelocity();
         mMinVelocity = vc.getScaledMinimumFlingVelocity();
-        mScroller = ScrollerCompat.create(context, sInterpolator);
+        mScroller = ScrollerCompat.create(context);
     }
 
     /**
@@ -702,6 +703,46 @@
     }
 
     /**
+     * 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 <code>continueSettling</code> returns true, the caller should call it again
      * on the next frame to continue.
@@ -750,6 +791,28 @@
         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)