Allow drag-n-drop to auto-scroll when near top/bottom of dirlist.
Couple of things this CL did: 1. Refactored ViewScroller to a separate file, renamed to "ViewAutoScroller" 2. New interfaces called UIDelegate and CalculationDelegate such that classes can provide necessary information for smooth scrolling 3. DirectoryDragListener implementation of #2's interface in order to have auto scrolling when user's cursor is near top/bottom of screen Currently since the status bar, action bar, and menu bar don't have a drag listener, we don't get a callback on them, and thus we lose the ability to get positions/scroll if user flings too quickly to those areas. There might be some way to circumvent this since hovering over these areas get a DRAG_EXIT event, but that should be 1) a separate discussion and 2) a separate CL in my opinion. Bug: 28696867 Change-Id: I61eba93aa60efd1638b3c0cd79db3f0d083b5e6d (cherry picked from commit ad3a323585e43d06f632d85759beaf670f541997)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -21,7 +21,6 @@ import android.app.AlertDialog;
|
||||
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.text.format.Time;
|
||||
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;
|
||||
|
||||
@@ -19,6 +19,7 @@ package com.android.documentsui.dirlist;
|
||||
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.InputEvent;
|
||||
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 @@ import java.util.Set;
|
||||
*/
|
||||
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 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
@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 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
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 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isActive() {
|
||||
return mModel != null;
|
||||
}
|
||||
|
||||
void bindSelection(Selection selection) {
|
||||
mSelection = selection;
|
||||
}
|
||||
@@ -212,10 +234,6 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
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 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
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 @@ public class BandController extends RecyclerView.OnScrollListener {
|
||||
* 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);
|
||||
|
||||
@@ -45,4 +45,4 @@ class DirectoryDragListener extends ItemDragListener<DirectoryFragment> {
|
||||
public boolean handleDropEventChecked(View v, DragEvent event) {
|
||||
return mDragHost.handleDropEvent(v, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,6 @@ import android.widget.Toolbar;
|
||||
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.Shared;
|
||||
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 @@ public class DirectoryFragment extends Fragment
|
||||
private @Nullable BandController mBandController;
|
||||
private @Nullable ActionMode mActionMode;
|
||||
|
||||
private DirectoryDragListener mOnDragListener;
|
||||
private DragScrollListener mOnDragListener;
|
||||
private MenuManager mMenuManager;
|
||||
|
||||
@Override
|
||||
@@ -210,7 +210,8 @@ public class DirectoryFragment extends Fragment
|
||||
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user