Merge "Allow drag-n-drop to auto-scroll when near top/bottom of dirlist." into nyc-andromeda-dev
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index 5f1b349..7cda341 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -46,4 +46,6 @@
     <dimen name="drag_shadow_width">160dp</dimen>
     <dimen name="drag_shadow_height">48dp</dimen>
 
+    <dimen name="autoscroll_edge_height">32dp</dimen>
+
 </resources>
diff --git a/src/com/android/documentsui/Shared.java b/src/com/android/documentsui/Shared.java
index 0cd568a..c1db87d 100644
--- a/src/com/android/documentsui/Shared.java
+++ b/src/com/android/documentsui/Shared.java
@@ -21,7 +21,6 @@
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Configuration;
-import android.net.Uri;
 import android.os.Looper;
 import android.provider.DocumentsContract;
 import android.text.TextUtils;
@@ -30,10 +29,6 @@
 import android.util.Log;
 import android.view.WindowManager;
 
-import com.android.documentsui.model.DocumentInfo;
-import com.android.documentsui.model.RootInfo;
-
-import java.io.FileNotFoundException;
 import java.text.Collator;
 import java.util.ArrayList;
 import java.util.List;
diff --git a/src/com/android/documentsui/dirlist/BandController.java b/src/com/android/documentsui/dirlist/BandController.java
index eb53ec1..5ab85c1 100644
--- a/src/com/android/documentsui/dirlist/BandController.java
+++ b/src/com/android/documentsui/dirlist/BandController.java
@@ -19,6 +19,7 @@
 import static com.android.documentsui.Shared.DEBUG;
 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DIRECTORY;
 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DOCUMENT;
+import static com.android.documentsui.dirlist.ViewAutoScroller.NOT_SET;
 
 import android.graphics.Point;
 import android.graphics.Rect;
@@ -39,6 +40,8 @@
 import com.android.documentsui.Events.MotionInputEvent;
 import com.android.documentsui.R;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
+import com.android.documentsui.dirlist.ViewAutoScroller.ScrollActionDelegate;
+import com.android.documentsui.dirlist.ViewAutoScroller.ScrollDistanceDelegate;
 
 import java.util.ArrayList;
 import java.util.Collections;
@@ -54,15 +57,14 @@
  */
 public class BandController extends RecyclerView.OnScrollListener {
 
-    private static final int NOT_SET = -1;
-
     private static final String TAG = "BandController";
+    private static final int AUTOSCROLL_EDGE_HEIGHT = 1;
 
     private final Runnable mModelBuilder;
     private final SelectionEnvironment mEnvironment;
     private final DocumentsAdapter mAdapter;
     private final MultiSelectManager mSelectionManager;
-    private final Runnable mViewScroller = new ViewScroller();
+    private final Runnable mViewScroller;
     private final GridModel.OnSelectionChangedListener mGridListener;
 
     @Nullable private Rect mBounds;
@@ -70,9 +72,6 @@
     @Nullable private Point mOrigin;
     @Nullable private BandController.GridModel mModel;
 
-    // The time at which the current band selection-induced scroll began. If no scroll is in
-    // progress, the value is NOT_SET.
-    private long mScrollStartTime = NOT_SET;
     private Selection mSelection;
 
     public BandController(
@@ -114,6 +113,25 @@
         mSelectionManager = selectionManager;
 
         mEnvironment.addOnScrollListener(this);
+        mViewScroller = new ViewAutoScroller(
+                AUTOSCROLL_EDGE_HEIGHT,
+                new ScrollDistanceDelegate() {
+                    @Override
+                    public Point getCurrentPosition() {
+                        return mCurrentPosition;
+                    }
+
+                    @Override
+                    public int getViewHeight() {
+                        return mEnvironment.getHeight();
+                    }
+
+                    @Override
+                    public boolean isActive() {
+                        return BandController.this.isActive();
+                    }
+                },
+                env);
 
         mAdapter.registerAdapterDataObserver(
                 new RecyclerView.AdapterDataObserver() {
@@ -173,6 +191,10 @@
         };
     }
 
+    private boolean isActive() {
+        return mModel != null;
+    }
+
     void bindSelection(Selection selection) {
         mSelection = selection;
     }
@@ -212,10 +234,6 @@
         return isActive();
     }
 
-    private boolean isActive() {
-        return mModel != null;
-    }
-
     /**
      * Handle a change in layout by cleaning up and getting rid of the old model and creating
      * a new model which will track the new layout.
@@ -336,112 +354,6 @@
         return mSelectionManager.notifyBeforeItemStateChange(id, nextState);
     }
 
-    private class ViewScroller implements Runnable {
-        /**
-         * The number of milliseconds of scrolling at which scroll speed continues to increase.
-         * At first, the scroll starts slowly; then, the rate of scrolling increases until it
-         * reaches its maximum value at after this many milliseconds.
-         */
-        private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
-
-        @Override
-        public void run() {
-            // Compute the number of pixels the pointer's y-coordinate is past the view.
-            // Negative values mean the pointer is at or before the top of the view, and
-            // positive values mean that the pointer is at or after the bottom of the view. Note
-            // that one additional pixel is added here so that the view still scrolls when the
-            // pointer is exactly at the top or bottom.
-            int pixelsPastView = 0;
-            if (mCurrentPosition.y <= 0) {
-                pixelsPastView = mCurrentPosition.y - 1;
-            } else if (mCurrentPosition.y >= mEnvironment.getHeight() - 1) {
-                pixelsPastView = mCurrentPosition.y - mEnvironment.getHeight() + 1;
-            }
-
-            if (!isActive() || pixelsPastView == 0) {
-                // If band selection is inactive, or if it is active but not at the edge of the
-                // view, no scrolling is necessary.
-                mScrollStartTime = NOT_SET;
-                return;
-            }
-
-            if (mScrollStartTime == NOT_SET) {
-                // If the pointer was previously not at the edge of the view but now is, set the
-                // start time for the scroll.
-                mScrollStartTime = System.currentTimeMillis();
-            }
-
-            // Compute the number of pixels to scroll, and scroll that many pixels.
-            final int numPixels = computeScrollDistance(
-                    pixelsPastView, System.currentTimeMillis() - mScrollStartTime);
-            mEnvironment.scrollBy(numPixels);
-
-            mEnvironment.removeCallback(mViewScroller);
-            mEnvironment.runAtNextFrame(this);
-        }
-
-        /**
-         * Computes the number of pixels to scroll based on how far the pointer is past the end
-         * of the view and how long it has been there. Roughly based on ItemTouchHelper's
-         * algorithm for computing the number of pixels to scroll when an item is dragged to the
-         * end of a {@link RecyclerView}.
-         * @param pixelsPastView
-         * @param scrollDuration
-         * @return
-         */
-        private int computeScrollDistance(int pixelsPastView, long scrollDuration) {
-            final int maxScrollStep = mEnvironment.getHeight();
-            final int direction = (int) Math.signum(pixelsPastView);
-            final int absPastView = Math.abs(pixelsPastView);
-
-            // Calculate the ratio of how far out of the view the pointer currently resides to
-            // the entire height of the view.
-            final float outOfBoundsRatio = Math.min(
-                    1.0f, (float) absPastView / mEnvironment.getHeight());
-            // Interpolate this ratio and use it to compute the maximum scroll that should be
-            // possible for this step.
-            final float cappedScrollStep =
-                    direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio);
-
-            // Likewise, calculate the ratio of the time spent in the scroll to the limit.
-            final float timeRatio = Math.min(
-                    1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS);
-            // Interpolate this ratio and use it to compute the final number of pixels to
-            // scroll.
-            final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio));
-
-            // If the final number of pixels to scroll ends up being 0, the view should still
-            // scroll at least one pixel.
-            return numPixels != 0 ? numPixels : direction;
-        }
-
-        /**
-         * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
-         * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
-         * drags that are at the edge or barely past the edge of the view still cause sufficient
-         * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if
-         * needed.
-         * @param ratio A ratio which is in the range [0, 1].
-         * @return A "smoothed" value, also in the range [0, 1].
-         */
-        private float smoothOutOfBoundsRatio(float ratio) {
-            return (float) Math.pow(ratio - 1.0f, 5) + 1.0f;
-        }
-
-        /**
-         * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1)
-         * and stays close to 0 for most input values except those very close to 1. This ensures
-         * that scrolls start out very slowly but speed up drastically after the scroll has been
-         * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used,
-         * but this could also be tweaked if needed.
-         * @param ratio A ratio which is in the range [0, 1].
-         * @return A "smoothed" value, also in the range [0, 1].
-         */
-        private float smoothTimeRatio(float ratio) {
-            return (float) Math.pow(ratio, 5);
-        }
-    };
-
     @Override
     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
         if (!isActive()) {
@@ -1110,16 +1022,13 @@
      * Provides functionality for BandController. Exists primarily to tests that are
      * fully isolated from RecyclerView.
      */
-    interface SelectionEnvironment {
+    interface SelectionEnvironment extends ScrollActionDelegate {
         void showBand(Rect rect);
         void hideBand();
         void addOnScrollListener(RecyclerView.OnScrollListener listener);
         void removeOnScrollListener(RecyclerView.OnScrollListener listener);
-        void scrollBy(int dy);
         int getHeight();
         void invalidateView();
-        void runAtNextFrame(Runnable r);
-        void removeCallback(Runnable r);
         Point createAbsolutePoint(Point relativePoint);
         Rect getAbsoluteRectForChildViewAt(int index);
         int getAdapterPositionAt(int index);
diff --git a/src/com/android/documentsui/dirlist/DirectoryDragListener.java b/src/com/android/documentsui/dirlist/DirectoryDragListener.java
index 0860f4c..f0a7aae 100644
--- a/src/com/android/documentsui/dirlist/DirectoryDragListener.java
+++ b/src/com/android/documentsui/dirlist/DirectoryDragListener.java
@@ -45,4 +45,4 @@
     public boolean handleDropEventChecked(View v, DragEvent event) {
         return mDragHost.handleDropEvent(v, event);
     }
-}
+}
\ No newline at end of file
diff --git a/src/com/android/documentsui/dirlist/DirectoryFragment.java b/src/com/android/documentsui/dirlist/DirectoryFragment.java
index ca7b2ca..2e1b1d6 100644
--- a/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -74,7 +74,6 @@
 import com.android.documentsui.BaseActivity;
 import com.android.documentsui.DirectoryLoader;
 import com.android.documentsui.DirectoryResult;
-import com.android.documentsui.clipping.DocumentClipper;
 import com.android.documentsui.DocumentsActivity;
 import com.android.documentsui.DocumentsApplication;
 import com.android.documentsui.Events.InputEvent;
@@ -93,6 +92,7 @@
 import com.android.documentsui.Snackbars;
 import com.android.documentsui.State;
 import com.android.documentsui.State.ViewMode;
+import com.android.documentsui.clipping.DocumentClipper;
 import com.android.documentsui.clipping.UrisSupplier;
 import com.android.documentsui.dirlist.MultiSelectManager.Selection;
 import com.android.documentsui.dirlist.UserInputHandler.DocumentDetails;
@@ -183,7 +183,7 @@
     private @Nullable BandController mBandController;
     private @Nullable ActionMode mActionMode;
 
-    private DirectoryDragListener mOnDragListener;
+    private DragScrollListener mOnDragListener;
     private MenuManager mMenuManager;
 
     @Override
@@ -210,7 +210,8 @@
 
         mRecView.setItemAnimator(new DirectoryItemAnimator(getActivity()));
 
-        mOnDragListener = new DirectoryDragListener(this);
+        mOnDragListener = DragScrollListener.create(
+                getActivity(), new DirectoryDragListener(this), mRecView);
 
         // Make the recycler and the empty views responsive to drop events.
         mRecView.setOnDragListener(mOnDragListener);
diff --git a/src/com/android/documentsui/dirlist/DragScrollListener.java b/src/com/android/documentsui/dirlist/DragScrollListener.java
new file mode 100644
index 0000000..898a4a2
--- /dev/null
+++ b/src/com/android/documentsui/dirlist/DragScrollListener.java
@@ -0,0 +1,167 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.dirlist;
+
+import android.content.Context;
+import android.graphics.Point;
+import android.view.DragEvent;
+import android.view.View;
+import android.view.View.OnDragListener;
+
+import com.android.documentsui.ItemDragListener;
+import com.android.documentsui.ItemDragListener.DragHost;
+import com.android.documentsui.dirlist.ViewAutoScroller.ScrollActionDelegate;
+import com.android.documentsui.dirlist.ViewAutoScroller.ScrollDistanceDelegate;
+import com.android.documentsui.R;
+
+import java.util.function.BooleanSupplier;
+import java.util.function.IntSupplier;
+
+import javax.annotation.Nullable;
+
+/**
+ * This class acts as a middle-man handler for potential auto-scrolling before passing the dragEvent
+ * onto {@link DirectoryDragListener}.
+ */
+class DragScrollListener implements OnDragListener {
+
+    private final ItemDragListener<? extends DragHost> mDragHandler;
+    private final IntSupplier mHeight;
+    private final BooleanSupplier mCanScrollUp;
+    private final BooleanSupplier mCanScrollDown;
+    private final int mAutoScrollEdgeHeight;
+    private final Runnable mDragScroller;
+
+    private boolean mDragHappening;
+    private @Nullable Point mCurrentPosition;
+
+    private DragScrollListener(
+            Context context,
+            ItemDragListener<? extends DragHost> dragHandler,
+            IntSupplier heightSupplier,
+            BooleanSupplier scrollUpSupplier,
+            BooleanSupplier scrollDownSupplier,
+            ViewAutoScroller.ScrollActionDelegate actionDelegate) {
+        mDragHandler = dragHandler;
+        mAutoScrollEdgeHeight = (int) context.getResources()
+                .getDimension(R.dimen.autoscroll_edge_height);
+        mHeight = heightSupplier;
+        mCanScrollUp = scrollUpSupplier;
+        mCanScrollDown = scrollDownSupplier;
+
+        ScrollDistanceDelegate distanceDelegate = new ScrollDistanceDelegate() {
+            @Override
+            public Point getCurrentPosition() {
+                return mCurrentPosition;
+            }
+
+            @Override
+            public int getViewHeight() {
+                return mHeight.getAsInt();
+            }
+
+            @Override
+            public boolean isActive() {
+                return mDragHappening;
+            }
+        };
+
+        mDragScroller = new ViewAutoScroller(
+                mAutoScrollEdgeHeight, distanceDelegate, actionDelegate);
+    }
+
+    static DragScrollListener create(
+            Context context, ItemDragListener<? extends DragHost> dragHandler, View scrollView) {
+        ScrollActionDelegate actionDelegate = new ScrollActionDelegate() {
+            @Override
+            public void scrollBy(int dy) {
+                scrollView.scrollBy(0, dy);
+            }
+
+            @Override
+            public void runAtNextFrame(Runnable r) {
+                scrollView.postOnAnimation(r);
+
+            }
+
+            @Override
+            public void removeCallback(Runnable r) {
+                scrollView.removeCallbacks(r);
+            }
+        };
+        DragScrollListener listener = new DragScrollListener(
+                context,
+                dragHandler,
+                scrollView::getHeight,
+                () -> {
+                    return scrollView.canScrollVertically(-1);
+                },
+                () -> {
+                    return scrollView.canScrollVertically(1);
+                },
+                actionDelegate);
+        return listener;
+    }
+
+    @Override
+    public boolean onDrag(View v, DragEvent event) {
+        boolean handled = false;
+        switch (event.getAction()) {
+            case DragEvent.ACTION_DRAG_STARTED:
+                mDragHappening = true;
+                break;
+            case DragEvent.ACTION_DRAG_ENDED:
+                mDragHappening = false;
+                break;
+            case DragEvent.ACTION_DRAG_ENTERED:
+                handled = insideDragZone();
+                break;
+            case DragEvent.ACTION_DRAG_LOCATION:
+                handled = handleLocationEvent(v, event.getX(), event.getY());
+                break;
+            default:
+                break;
+        }
+
+        if (!handled) {
+            handled = mDragHandler.onDrag(v, event);
+        }
+
+        return handled;
+    }
+
+    private boolean handleLocationEvent(View v, float x, float y) {
+        mCurrentPosition = new Point(Math.round(v.getX() + x), Math.round(v.getY() + y));
+        if (insideDragZone()) {
+            mDragScroller.run();
+            return true;
+        }
+        return false;
+    }
+
+    private boolean insideDragZone() {
+        if (mCurrentPosition == null) {
+            return false;
+        }
+
+        boolean shouldScrollUp = mCurrentPosition.y < mAutoScrollEdgeHeight
+                && mCanScrollUp.getAsBoolean();
+        boolean shouldScrollDown = mCurrentPosition.y > mHeight.getAsInt() - mAutoScrollEdgeHeight
+                && mCanScrollDown.getAsBoolean();
+        return shouldScrollUp || shouldScrollDown;
+    }
+}
\ No newline at end of file
diff --git a/src/com/android/documentsui/dirlist/ViewAutoScroller.java b/src/com/android/documentsui/dirlist/ViewAutoScroller.java
new file mode 100644
index 0000000..5ef68ca
--- /dev/null
+++ b/src/com/android/documentsui/dirlist/ViewAutoScroller.java
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package com.android.documentsui.dirlist;
+
+import android.graphics.Point;
+import android.support.annotation.VisibleForTesting;
+
+import java.util.function.IntSupplier;
+import java.util.function.LongSupplier;
+
+/**
+ * Provides auto-scrolling upon request when user's interaction with the application
+ * introduces a natural intent to scroll. Used by {@link BandController} and
+ * {@link DragScrollListener} to allow auto scrolling when user either does band selection, or
+ * attempting to drag and drop files to somewhere off the current screen.
+ */
+public final class ViewAutoScroller implements Runnable {
+    public static final int NOT_SET = -1;
+    /**
+     * The number of milliseconds of scrolling at which scroll speed continues to increase.
+     * At first, the scroll starts slowly; then, the rate of scrolling increases until it
+     * reaches its maximum value at after this many milliseconds.
+     */
+    private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
+
+    // Top and bottom inner buffer such that user's cursor does not have to be exactly off screen
+    // for auto scrolling to begin
+    private final int mTopBottomThreshold;
+    private final ScrollDistanceDelegate mCalcDelegate;
+    private final ScrollActionDelegate mUiDelegate;
+    private final LongSupplier mCurrentTime;
+
+    private long mScrollStartTime = NOT_SET;
+
+    public ViewAutoScroller(
+            int topBottomThreshold,
+            ScrollDistanceDelegate calcDelegate,
+            ScrollActionDelegate uiDelegate) {
+        this(topBottomThreshold, calcDelegate, uiDelegate, System::currentTimeMillis);
+    }
+
+    @VisibleForTesting
+    ViewAutoScroller(
+            int topBottomThreshold,
+            ScrollDistanceDelegate calcDelegate,
+            ScrollActionDelegate uiDelegate,
+            LongSupplier clock) {
+        mTopBottomThreshold = topBottomThreshold;
+        mCalcDelegate = calcDelegate;
+        mUiDelegate = uiDelegate;
+        mCurrentTime = clock;
+    }
+
+    /**
+     * Attempts to smooth-scroll the view at the given UI frame. Application should be
+     * responsible to do any clean up (such as unsubscribing scrollListeners) after the run has
+     * finished, and re-run this method on the next UI frame if applicable.
+     */
+    @Override
+    public void run() {
+        // Compute the number of pixels the pointer's y-coordinate is past the view.
+        // Negative values mean the pointer is at or before the top of the view, and
+        // positive values mean that the pointer is at or after the bottom of the view. Note
+        // that top/bottom threshold is added here so that the view still scrolls when the
+        // pointer are in these buffer pixels.
+        int pixelsPastView = 0;
+
+        if (mCalcDelegate.getCurrentPosition().y <= mTopBottomThreshold) {
+            pixelsPastView = mCalcDelegate.getCurrentPosition().y - mTopBottomThreshold;
+        } else if (mCalcDelegate.getCurrentPosition().y >= mCalcDelegate.getViewHeight()
+                - mTopBottomThreshold) {
+            pixelsPastView = mCalcDelegate.getCurrentPosition().y - mCalcDelegate.getViewHeight()
+                    + mTopBottomThreshold;
+        }
+
+        if (!mCalcDelegate.isActive() || pixelsPastView == 0) {
+            // If the operation that started the scrolling is no longer inactive, or if it is active
+            // but not at the edge of the view, no scrolling is necessary.
+            mScrollStartTime = NOT_SET;
+            return;
+        }
+
+        if (mScrollStartTime == NOT_SET) {
+            // If the pointer was previously not at the edge of the view but now is, set the
+            // start time for the scroll.
+            mScrollStartTime = mCurrentTime.getAsLong();
+        }
+
+        // Compute the number of pixels to scroll, and scroll that many pixels.
+        final int numPixels = computeScrollDistance(
+                pixelsPastView, mCurrentTime.getAsLong() - mScrollStartTime);
+        mUiDelegate.scrollBy(numPixels);
+
+        // Remove callback to this, and then properly run at next frame again
+        mUiDelegate.removeCallback(this);
+        mUiDelegate.runAtNextFrame(this);
+    }
+
+    /**
+     * Computes the number of pixels to scroll based on how far the pointer is past the end
+     * of the view and how long it has been there. Roughly based on ItemTouchHelper's
+     * algorithm for computing the number of pixels to scroll when an item is dragged to the
+     * end of a view.
+     * @param pixelsPastView
+     * @param scrollDuration
+     * @return
+     */
+    public int computeScrollDistance(int pixelsPastView, long scrollDuration) {
+        final int maxScrollStep = mCalcDelegate.getViewHeight();
+        final int direction = (int) Math.signum(pixelsPastView);
+        final int absPastView = Math.abs(pixelsPastView);
+
+        // Calculate the ratio of how far out of the view the pointer currently resides to
+        // the entire height of the view.
+        final float outOfBoundsRatio = Math.min(
+                1.0f, (float) absPastView / mCalcDelegate.getViewHeight());
+        // Interpolate this ratio and use it to compute the maximum scroll that should be
+        // possible for this step.
+        final float cappedScrollStep =
+                direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio);
+
+        // Likewise, calculate the ratio of the time spent in the scroll to the limit.
+        final float timeRatio = Math.min(
+                1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS);
+        // Interpolate this ratio and use it to compute the final number of pixels to
+        // scroll.
+        final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio));
+
+        // If the final number of pixels to scroll ends up being 0, the view should still
+        // scroll at least one pixel.
+        return numPixels != 0 ? numPixels : direction;
+    }
+
+    /**
+     * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
+     * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
+     * drags that are at the edge or barely past the edge of the view still cause sufficient
+     * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if
+     * needed.
+     * @param ratio A ratio which is in the range [0, 1].
+     * @return A "smoothed" value, also in the range [0, 1].
+     */
+    private float smoothOutOfBoundsRatio(float ratio) {
+        return (float) Math.pow(ratio - 1.0f, 5) + 1.0f;
+    }
+
+    /**
+     * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1)
+     * and stays close to 0 for most input values except those very close to 1. This ensures
+     * that scrolls start out very slowly but speed up drastically after the scroll has been
+     * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used,
+     * but this could also be tweaked if needed.
+     * @param ratio A ratio which is in the range [0, 1].
+     * @return A "smoothed" value, also in the range [0, 1].
+     */
+    private float smoothTimeRatio(float ratio) {
+        return (float) Math.pow(ratio, 5);
+    }
+
+    /**
+     * Used by {@link run} to properly calculate the proper amount of pixels to scroll given time
+     * passed since scroll started, and to properly scroll / proper listener clean up if necessary.
+     */
+    interface ScrollDistanceDelegate {
+        public Point getCurrentPosition();
+        public int getViewHeight();
+        public boolean isActive();
+    }
+
+    /**
+     * Used by {@link run} to do UI tasks, such as scrolling and rerunning at next UI cycle.
+     */
+    interface ScrollActionDelegate {
+        public void scrollBy(int dy);
+        public void runAtNextFrame(Runnable r);
+        public void removeCallback(Runnable r);
+    }
+}
\ No newline at end of file
diff --git a/tests/src/com/android/documentsui/dirlist/ViewAutoScrollerTest.java b/tests/src/com/android/documentsui/dirlist/ViewAutoScrollerTest.java
new file mode 100644
index 0000000..e2aaa84
--- /dev/null
+++ b/tests/src/com/android/documentsui/dirlist/ViewAutoScrollerTest.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.documentsui.dirlist;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.graphics.Point;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.function.IntConsumer;
+
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public final class ViewAutoScrollerTest {
+
+    private static final int VIEW_HEIGHT = 100;
+    private static final int EDGE_HEIGHT = 10;
+
+    private ViewAutoScroller mAutoScroller;
+    private Point mPoint;
+    private boolean mActive;
+    private ViewAutoScroller.ScrollDistanceDelegate mDistanceDelegate;
+    private ViewAutoScroller.ScrollActionDelegate mActionDelegate;
+    private IntConsumer mScrollAssert;
+
+    @Before
+    public void setUp() {
+        mActive = false;
+        mPoint = new Point();
+        mDistanceDelegate = new ViewAutoScroller.ScrollDistanceDelegate() {
+            @Override
+            public boolean isActive() {
+                return mActive;
+            }
+
+            @Override
+            public int getViewHeight() {
+                return VIEW_HEIGHT;
+            }
+
+            @Override
+            public Point getCurrentPosition() {
+                return mPoint;
+            }
+        };
+        mActionDelegate = new ViewAutoScroller.ScrollActionDelegate() {
+            @Override
+            public void scrollBy(int dy) {
+                mScrollAssert.accept(dy);
+            }
+
+            @Override
+            public void runAtNextFrame(Runnable r) {
+            }
+
+            @Override
+            public void removeCallback(Runnable r) {
+            }
+        };
+        mAutoScroller = new ViewAutoScroller(
+                EDGE_HEIGHT, mDistanceDelegate, mActionDelegate, new TestClock()::getCurrentTime);
+    }
+
+    @Test
+    public void testCursorNotInScrollZone() {
+        mPoint = new Point(0, VIEW_HEIGHT/2);
+        mScrollAssert = (int dy) -> {
+            // Should not have called this method
+            fail("Received unexpected scroll event");
+            assertTrue(false);
+        };
+        mAutoScroller.run();
+    }
+
+    @Test
+    public void testCursorInScrollZone_notActive() {
+        mActive = false;
+        mPoint = new Point(0, EDGE_HEIGHT - 1);
+        mScrollAssert = (int dy) -> {
+            // Should not have called this method
+            fail("Received unexpected scroll event");
+            assertTrue(false);
+        };
+        mAutoScroller.run();
+    }
+
+    @Test
+    public void testCursorInScrollZone_top() {
+        mActive = true;
+        mPoint = new Point(0, EDGE_HEIGHT - 1);
+        int expectedScrollDistance = mAutoScroller.computeScrollDistance(-1, 1);
+        mScrollAssert = (int dy) -> {
+            assertTrue(dy == expectedScrollDistance);
+        };
+        mAutoScroller.run();
+    }
+
+    @Test
+    public void testCursorInScrollZone_bottom() {
+        mActive = true;
+        mPoint = new Point(0, VIEW_HEIGHT - EDGE_HEIGHT + 1);
+        int expectedScrollDistance = mAutoScroller.computeScrollDistance(1, 1);
+        mScrollAssert = (int dy) -> {
+            assertTrue(dy == expectedScrollDistance);
+        };
+        mAutoScroller.run();
+    }
+
+    class TestClock {
+        private int timesCalled = 0;
+
+        public long getCurrentTime() {
+            return ++timesCalled;
+        }
+    }
+}