blob: 74170f583f7c036ec14e4fca2f49f5a5f0f9930f [file] [log] [blame]
/*
* Copyright (C) 2015 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;
import static com.android.documentsui.Events.isMouseEvent;
import static com.android.internal.util.Preconditions.checkState;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.MotionEvent;
import android.view.View;
/**
* Provides mouse driven band-select support when used in conjuction with {@link RecyclerView} and
* {@link MultiSelectManager}. This class is responsible for rendering the band select overlay and
* selecting overlaid items via MultiSelectManager.
*/
public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
private static final int NOT_SELECTED = -1;
private static final int NOT_SET = -1;
// For debugging purposes.
private static final String TAG = "BandSelectManager";
private static final boolean DEBUG = false;
private final RecyclerView mRecyclerView;
private final MultiSelectManager mSelectManager;
private final Drawable mRegionSelectorDrawable;
private final SparseBooleanArray mSelectedByBand = new SparseBooleanArray();
private boolean mIsBandSelectActive = false;
private Point mOrigin;
private Point mPointer;
private Rect mBounds;
// Maintain the last selection made by band, so if bounds shrink back, we can deselect
// the respective items.
private int mCursorDeltaY = 0;
private int mFirstSelected = NOT_SELECTED;
// 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 final Runnable mScrollRunnable = new 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 (mPointer.y <= 0) {
pixelsPastView = mPointer.y - 1;
} else if (mPointer.y >= mRecyclerView.getHeight() - 1) {
pixelsPastView = mPointer.y - mRecyclerView.getHeight() + 1;
}
if (!mIsBandSelectActive || 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 = computeNumPixelsToScroll(
pixelsPastView, System.currentTimeMillis() - mScrollStartTime);
mRecyclerView.scrollBy(0, numPixels);
// Adjust the y-coordinate of the origin the opposite number of pixels so that the
// origin remains in the same place relative to the view's items.
mOrigin.y -= numPixels;
resizeBandSelectRectangle();
mRecyclerView.removeCallbacks(mScrollRunnable);
mRecyclerView.postOnAnimation(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 computeNumPixelsToScroll(int pixelsPastView, long scrollDuration) {
final int maxScrollStep = computeMaxScrollStep(mRecyclerView);
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 / mRecyclerView.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;
}
/**
* Computes the maximum scroll allowed for a given animation frame. Currently, this
* defaults to the height of the view, but this could be tweaked if this results in scrolls
* that are too fast or too slow.
* @param rv
* @return
*/
private int computeMaxScrollStep(RecyclerView rv) {
return rv.getHeight();
}
/**
* 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);
}
};
/**
* @param recyclerView
* @param multiSelectManager
*/
public BandSelectManager(RecyclerView recyclerView, MultiSelectManager multiSelectManager) {
mRecyclerView = recyclerView;
mSelectManager = multiSelectManager;
mRegionSelectorDrawable =
mRecyclerView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
mRecyclerView.addOnItemTouchListener(this);
}
@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
// Only intercept the event if it was triggered by a mouse. If band select is inactive,
// do not intercept ACTION_UP events as they will not be processed.
return isMouseEvent(e) &&
(mIsBandSelectActive || e.getActionMasked() != MotionEvent.ACTION_UP);
}
@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
checkState(isMouseEvent(e));
processMotionEvent(e);
}
/**
* Processes a MotionEvent by starting, ending, or resizing the band select overlay.
* @param e
*/
private void processMotionEvent(MotionEvent e) {
if (mIsBandSelectActive && e.getActionMasked() == MotionEvent.ACTION_UP) {
endBandSelect();
return;
}
mPointer = new Point((int) e.getX(), (int) e.getY());
if (!mIsBandSelectActive) {
startBandSelect();
}
scrollViewIfNecessary();
resizeBandSelectRectangle();
selectChildrenCoveredBySelection();
}
/**
* Starts band select by adding the drawable to the RecyclerView's overlay.
*/
private void startBandSelect() {
if (DEBUG) Log.d(TAG, "Starting band select from (" + mPointer.x + "," + mPointer.y + ").");
mIsBandSelectActive = true;
mOrigin = mPointer;
mRecyclerView.getOverlay().add(mRegionSelectorDrawable);
}
/**
* Scrolls the view if necessary.
*/
private void scrollViewIfNecessary() {
mRecyclerView.removeCallbacks(mScrollRunnable);
mScrollRunnable.run();
mRecyclerView.invalidate();
}
/**
* Resizes the band select rectangle by using the origin and the current pointer positoin as
* two opposite corners of the selection.
*/
private void resizeBandSelectRectangle() {
if (mBounds != null) {
mCursorDeltaY = mPointer.y - mBounds.bottom;
}
mBounds = new Rect(Math.min(mOrigin.x, mPointer.x),
Math.min(mOrigin.y, mPointer.y),
Math.max(mOrigin.x, mPointer.x),
Math.max(mOrigin.y, mPointer.y));
mRegionSelectorDrawable.setBounds(mBounds);
}
/**
* Selects the children covered by the band select overlay by delegating to MultiSelectManager.
* TODO: Provide a finished implementation. This is down and dirty, proof of concept code.
* Final optimized implementation, with support for managing offscreen selection to come.
*/
private void selectChildrenCoveredBySelection() {
// track top and bottom selections. Details on why this is useful below.
int first = NOT_SELECTED;
int last = NOT_SELECTED;
for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
View child = mRecyclerView.getChildAt(i);
ViewHolder holder = mRecyclerView.getChildViewHolder(child);
Rect childRect = new Rect();
child.getHitRect(childRect);
boolean shouldSelect = Rect.intersects(childRect, mBounds);
int position = holder.getAdapterPosition();
// This also allows us to clear the selection of elements
// that only temporarily entered the bounds of the band.
if (mSelectedByBand.get(position) && !shouldSelect) {
mSelectManager.setItemSelected(position, false);
mSelectedByBand.delete(position);
}
// We need to keep track of the first and last items selected.
// We'll use this information along with cursor direction
// to determine the starting point of the selection.
// We provide this information to selection manager
// to enable more natural user interaction when working
// with Shift+Click and multiple contiguous selection ranges.
if (shouldSelect) {
if (first == NOT_SELECTED) {
first = position;
} else {
last = position;
}
mSelectManager.setItemSelected(position, true);
mSelectedByBand.put(position, true);
}
}
// Remember which is the last selected item, so we can
// share that with selection manager when band select ends.
// It'll use that as it's begin selection point when
// user SHIFT+Clicks.
if (mCursorDeltaY < 0 && last != NOT_SELECTED) {
mFirstSelected = last;
} else if (mCursorDeltaY > 0 && first != NOT_SELECTED) {
mFirstSelected = first;
}
}
/**
* Ends band select by removing the overlay.
*/
private void endBandSelect() {
if (DEBUG) Log.d(TAG, "Ending band select.");
mIsBandSelectActive = false;
mSelectedByBand.clear();
mRecyclerView.getOverlay().remove(mRegionSelectorDrawable);
if (mFirstSelected != NOT_SELECTED) {
mSelectManager.setSelectionFocusBegin(mFirstSelected);
}
}
}