From 716912e719e8899e4c072006248cdd4669a55461 Mon Sep 17 00:00:00 2001 From: Steve McKay Date: Thu, 26 May 2016 12:04:43 -0700 Subject: [PATCH] Move BandController out of MultiSelectManager. Improved dependency injection (elimination of propagating dependencies). This change cleans up MultiSelectManager ahead of other improvements. Also, cancel band select in response to touch events....else weird things happen. Change-Id: I261d928ff7eb5d8a50791d5dd9d7202b324efc54 --- .../documentsui/dirlist/BandController.java | 1255 +++++++++++++++++ .../dirlist/DirectoryFragment.java | 10 +- .../dirlist/MultiSelectManager.java | 1178 +--------------- ...java => BandController_GridModelTest.java} | 8 +- .../dirlist/MultiSelectManagerTest.java | 11 +- .../dirlist/TestSelectionEnvironment.java | 2 +- 6 files changed, 1283 insertions(+), 1181 deletions(-) create mode 100644 packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java rename packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/{MultiSelectManager_GridModelTest.java => BandController_GridModelTest.java} (97%) diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java new file mode 100644 index 0000000000000..f3dc6864fef02 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/BandController.java @@ -0,0 +1,1255 @@ +/* + * 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.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 android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseBooleanArray; +import android.util.SparseIntArray; +import android.view.MotionEvent; +import android.view.View; + +import com.android.documentsui.Events; +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 java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView} + * and {@link MultiSelectManager}. This class is responsible for rendering the band select + * overlay and selecting overlaid items via MultiSelectManager. + */ +public class BandController extends RecyclerView.OnScrollListener { + + private static final int NOT_SET = -1; + + private static final String TAG = "BandController"; + + private final Runnable mModelBuilder; + private final SelectionEnvironment mEnvironment; + private final DocumentsAdapter mAdapter; + private final MultiSelectManager mSelectionManager; + private final Runnable mViewScroller = new ViewScroller(); + private final GridModel.OnSelectionChangedListener mGridListener; + + @Nullable private Rect mBounds; + @Nullable private Point mCurrentPosition; + @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( + final RecyclerView view, + DocumentsAdapter adapter, + MultiSelectManager selectionManager) { + this(new RuntimeSelectionEnvironment(view), adapter, selectionManager); + + view.addOnItemTouchListener( + new RecyclerView.OnItemTouchListener() { + @Override + public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { + return handleEvent(new MotionInputEvent(e, view)); + } + @Override + public void onTouchEvent(RecyclerView rv, MotionEvent e) { + if (Events.isMouseEvent(e)) { + processInputEvent(new MotionInputEvent(e, view)); + } + } + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} + }); + } + + private BandController( + SelectionEnvironment env, + DocumentsAdapter adapter, + MultiSelectManager selectionManager) { + + selectionManager.bindContoller(this); + + mEnvironment = env; + mAdapter = adapter; + mSelectionManager = selectionManager; + + mEnvironment.addOnScrollListener(this); + + mAdapter.registerAdapterDataObserver( + new RecyclerView.AdapterDataObserver() { + @Override + public void onChanged() { + if (isActive()) { + endBandSelect(); + } + } + + @Override + public void onItemRangeChanged( + int startPosition, int itemCount, Object payload) { + // No change in position. Ignoring. + } + + @Override + public void onItemRangeInserted(int startPosition, int itemCount) { + if (isActive()) { + endBandSelect(); + } + } + + @Override + public void onItemRangeRemoved(int startPosition, int itemCount) { + assert(startPosition >= 0); + assert(itemCount > 0); + + // TODO: Should update grid model. + } + + @Override + public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { + throw new UnsupportedOperationException(); + } + }); + + mGridListener = new GridModel.OnSelectionChangedListener() { + + @Override + public void onSelectionChanged(Set updatedSelection) { + BandController.this.onSelectionChanged(updatedSelection); + } + + @Override + public boolean onBeforeItemStateChange(String id, boolean nextState) { + return BandController.this.onBeforeItemStateChange(id, nextState); + } + }; + + mModelBuilder = new Runnable() { + @Override + public void run() { + mModel = new GridModel(mEnvironment, mAdapter); + mModel.addOnSelectionChangedListener(mGridListener); + } + }; + } + + void bindSelection(Selection selection) { + mSelection = selection; + } + + private boolean handleEvent(MotionInputEvent e) { + if (!e.isMouseEvent() && isActive()) { + // Weird things happen if we keep up band select + // when touch events happen. + endBandSelect(); + return false; + } + + // b/23793622 notes the fact that we *never* receive ACTION_DOWN + // events in onTouchEvent. Where it not for this issue, we'd + // push start handling down into handleInputEvent. + if (shouldStart(e)) { + // endBandSelect is handled in handleInputEvent. + startBandSelect(e.getOrigin()); + } else if (isActive() && e.isActionUp()) { + // Same issue here w b/23793622. The ACTION_UP event + // is only evert dispatched to onTouchEvent when + // there is some associated motion. If a user taps + // mouse, but doesn't move, then band select gets + // started BUT not ended. Causing phantom + // bands to appear when the user later clicks to start + // band select. + if (e.isMouseEvent()) { + processInputEvent(e); + } + } + + 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. + */ + public void handleLayoutChanged() { + if (mModel != null) { + mModel.removeOnSelectionChangedListener(mGridListener); + mModel.stopListening(); + + // build a new model, all fresh and happy. + mModelBuilder.run(); + } + } + + boolean shouldStart(MotionInputEvent e) { + return !isActive() + && e.isActionDown() // the initial button press + && mAdapter.getItemCount() > 0 + && e.getItemPosition() == RecyclerView.NO_ID; // in empty space + } + + boolean shouldStop(InputEvent input) { + return isActive() + && input.isMouseEvent() + && input.isActionUp(); + } + + /** + * Processes a MotionEvent by starting, ending, or resizing the band select overlay. + * @param input + */ + private void processInputEvent(InputEvent input) { + assert(input.isMouseEvent()); + + if (shouldStop(input)) { + endBandSelect(); + return; + } + + // We shouldn't get any events in this method when band select is not active, + // but it turns some guests show up late to the party. + if (!isActive()) { + return; + } + + mCurrentPosition = input.getOrigin(); + mModel.resizeSelection(input.getOrigin()); + scrollViewIfNecessary(); + resizeBandSelectRectangle(); + } + + /** + * Starts band select by adding the drawable to the RecyclerView's overlay. + */ + private void startBandSelect(Point origin) { + if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); + + mOrigin = origin; + mModelBuilder.run(); // Creates a new selection model. + mModel.startSelection(mOrigin); + } + + /** + * Scrolls the view if necessary. + */ + private void scrollViewIfNecessary() { + mEnvironment.removeCallback(mViewScroller); + mViewScroller.run(); + mEnvironment.invalidateView(); + } + + /** + * Resizes the band select rectangle by using the origin and the current pointer position as + * two opposite corners of the selection. + */ + private void resizeBandSelectRectangle() { + mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), + Math.min(mOrigin.y, mCurrentPosition.y), + Math.max(mOrigin.x, mCurrentPosition.x), + Math.max(mOrigin.y, mCurrentPosition.y)); + mEnvironment.showBand(mBounds); + } + + /** + * Ends band select by removing the overlay. + */ + private void endBandSelect() { + if (DEBUG) Log.d(TAG, "Ending band select."); + + mEnvironment.hideBand(); + mSelection.applyProvisionalSelection(); + mModel.endSelection(); + int firstSelected = mModel.getPositionNearestOrigin(); + if (firstSelected != NOT_SET) { + if (mSelection.contains(mAdapter.getModelId(firstSelected))) { + // TODO: firstSelected should really be lastSelected, we want to anchor the item + // where the mouse-up occurred. + mSelectionManager.setSelectionRangeBegin(firstSelected); + } else { + // TODO: Check if this is really happening. + Log.w(TAG, "First selected by band is NOT in selection!"); + } + } + + mModel = null; + mOrigin = null; + } + + private void onSelectionChanged(Set updatedSelection) { + Map delta = mSelection.setProvisionalSelection(updatedSelection); + for (Map.Entry entry: delta.entrySet()) { + mSelectionManager.notifyItemStateChanged(entry.getKey(), entry.getValue()); + } + mSelectionManager.notifySelectionChanged(); + } + + private boolean onBeforeItemStateChange(String id, boolean nextState) { + 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()) { + return; + } + + // 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 -= dy; + resizeBandSelectRectangle(); + } + + /** + * Provides a band selection item model for views within a RecyclerView. This class queries the + * RecyclerView to determine where its items are placed; then, once band selection is underway, + * it alerts listeners of which items are covered by the selections. + */ + @VisibleForTesting + static final class GridModel extends RecyclerView.OnScrollListener { + + public static final int NOT_SET = -1; + + // Enum values used to determine the corner at which the origin is located within the + private static final int UPPER = 0x00; + private static final int LOWER = 0x01; + private static final int LEFT = 0x00; + private static final int RIGHT = 0x02; + private static final int UPPER_LEFT = UPPER | LEFT; + private static final int UPPER_RIGHT = UPPER | RIGHT; + private static final int LOWER_LEFT = LOWER | LEFT; + private static final int LOWER_RIGHT = LOWER | RIGHT; + + private final SelectionEnvironment mHelper; + private final DocumentsAdapter mAdapter; + + private final List mOnSelectionChangedListeners = + new ArrayList<>(); + + // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed + // by their y-offset. For example, if the first column of the view starts at an x-value of 5, + // mColumns.get(5) would return an array of positions in that column. Within that array, the + // value for key y is the adapter position for the item whose y-offset is y. + private final SparseArray mColumns = new SparseArray<>(); + + // List of limits along the x-axis (columns). + // This list is sorted from furthest left to furthest right. + private final List mColumnBounds = new ArrayList<>(); + + // List of limits along the y-axis (rows). Note that this list only contains items which + // have been in the viewport. + private final List mRowBounds = new ArrayList<>(); + + // The adapter positions which have been recorded so far. + private final SparseBooleanArray mKnownPositions = new SparseBooleanArray(); + + // Array passed to registered OnSelectionChangedListeners. One array is created and reused + // throughout the lifetime of the object. + private final Set mSelection = new HashSet<>(); + + // The current pointer (in absolute positioning from the top of the view). + private Point mPointer = null; + + // The bounds of the band selection. + private RelativePoint mRelativeOrigin; + private RelativePoint mRelativePointer; + + private boolean mIsActive; + + // Tracks where the band select originated from. This is used to determine where selections + // should expand from when Shift+click is used. + private int mPositionNearestOrigin = NOT_SET; + + GridModel(SelectionEnvironment helper, DocumentsAdapter adapter) { + mHelper = helper; + mAdapter = adapter; + mHelper.addOnScrollListener(this); + } + + /** + * Stops listening to the view's scrolls. Call this function before discarding a + * BandSelecModel object to prevent memory leaks. + */ + void stopListening() { + mHelper.removeOnScrollListener(this); + } + + /** + * Start a band select operation at the given point. + * @param relativeOrigin The origin of the band select operation, relative to the viewport. + * For example, if the view is scrolled to the bottom, the top-left of the viewport + * would have a relative origin of (0, 0), even though its absolute point has a higher + * y-value. + */ + void startSelection(Point relativeOrigin) { + recordVisibleChildren(); + if (isEmpty()) { + // The selection band logic works only if there is at least one visible child. + return; + } + + mIsActive = true; + mPointer = mHelper.createAbsolutePoint(relativeOrigin); + mRelativeOrigin = new RelativePoint(mPointer); + mRelativePointer = new RelativePoint(mPointer); + computeCurrentSelection(); + notifyListeners(); + } + + /** + * Resizes the selection by adjusting the pointer (i.e., the corner of the selection + * opposite the origin. + * @param relativePointer The pointer (opposite of the origin) of the band select operation, + * relative to the viewport. For example, if the view is scrolled to the bottom, the + * top-left of the viewport would have a relative origin of (0, 0), even though its + * absolute point has a higher y-value. + */ + @VisibleForTesting + void resizeSelection(Point relativePointer) { + mPointer = mHelper.createAbsolutePoint(relativePointer); + updateModel(); + } + + /** + * Ends the band selection. + */ + void endSelection() { + mIsActive = false; + } + + /** + * @return The adapter position for the item nearest the origin corresponding to the latest + * band select operation, or NOT_SET if the selection did not cover any items. + */ + int getPositionNearestOrigin() { + return mPositionNearestOrigin; + } + + @Override + public void onScrolled(RecyclerView recyclerView, int dx, int dy) { + if (!mIsActive) { + return; + } + + mPointer.x += dx; + mPointer.y += dy; + recordVisibleChildren(); + updateModel(); + } + + /** + * Queries the view for all children and records their location metadata. + */ + private void recordVisibleChildren() { + for (int i = 0; i < mHelper.getVisibleChildCount(); i++) { + int adapterPosition = mHelper.getAdapterPositionAt(i); + // Sometimes the view is not attached, as we notify the multi selection manager + // synchronously, while views are attached asynchronously. As a result items which + // are in the adapter may not actually have a corresponding view (yet). + if (mHelper.hasView(adapterPosition) && + !mHelper.isLayoutItem(adapterPosition) && + !mKnownPositions.get(adapterPosition)) { + mKnownPositions.put(adapterPosition, true); + recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition); + } + } + } + + /** + * Checks if there are any recorded children. + */ + private boolean isEmpty() { + return mColumnBounds.size() == 0 || mRowBounds.size() == 0; + } + + /** + * Updates the limits lists and column map with the given item metadata. + * @param absoluteChildRect The absolute rectangle for the child view being processed. + * @param adapterPosition The position of the child view being processed. + */ + private void recordItemData(Rect absoluteChildRect, int adapterPosition) { + if (mColumnBounds.size() != mHelper.getColumnCount()) { + // If not all x-limits have been recorded, record this one. + recordLimits( + mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right)); + } + + recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom)); + + SparseIntArray columnList = mColumns.get(absoluteChildRect.left); + if (columnList == null) { + columnList = new SparseIntArray(); + mColumns.put(absoluteChildRect.left, columnList); + } + columnList.put(absoluteChildRect.top, adapterPosition); + } + + /** + * Ensures limits exists within the sorted list limitsList, and adds it to the list if it + * does not exist. + */ + private void recordLimits(List limitsList, GridModel.Limits limits) { + int index = Collections.binarySearch(limitsList, limits); + if (index < 0) { + limitsList.add(~index, limits); + } + } + + /** + * Handles a moved pointer; this function determines whether the pointer movement resulted + * in a selection change and, if it has, notifies listeners of this change. + */ + private void updateModel() { + RelativePoint old = mRelativePointer; + mRelativePointer = new RelativePoint(mPointer); + if (old != null && mRelativePointer.equals(old)) { + return; + } + + computeCurrentSelection(); + notifyListeners(); + } + + /** + * Computes the currently-selected items. + */ + private void computeCurrentSelection() { + if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) { + updateSelection(computeBounds()); + } else { + mSelection.clear(); + mPositionNearestOrigin = NOT_SET; + } + } + + /** + * Notifies all listeners of a selection change. Note that this function simply passes + * mSelection, so computeCurrentSelection() should be called before this + * function. + */ + private void notifyListeners() { + for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) { + listener.onSelectionChanged(mSelection); + } + } + + /** + * @param rect Rectangle including all covered items. + */ + private void updateSelection(Rect rect) { + int columnStart = + Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left)); + assert(columnStart >= 0); + int columnEnd = columnStart; + + for (int i = columnStart; i < mColumnBounds.size() + && mColumnBounds.get(i).lowerLimit <= rect.right; i++) { + columnEnd = i; + } + + int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top)); + if (rowStart < 0) { + mPositionNearestOrigin = NOT_SET; + return; + } + + int rowEnd = rowStart; + for (int i = rowStart; i < mRowBounds.size() + && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) { + rowEnd = i; + } + + updateSelection(columnStart, columnEnd, rowStart, rowEnd); + } + + /** + * Computes the selection given the previously-computed start- and end-indices for each + * row and column. + */ + private void updateSelection( + int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) { + if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d", + columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex)); + + mSelection.clear(); + for (int column = columnStartIndex; column <= columnEndIndex; column++) { + SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit); + for (int row = rowStartIndex; row <= rowEndIndex; row++) { + // The default return value for SparseIntArray.get is 0, which is a valid + // position. Use a sentry value to prevent erroneously selecting item 0. + final int rowKey = mRowBounds.get(row).lowerLimit; + int position = items.get(rowKey, NOT_SET); + if (position != NOT_SET) { + String id = mAdapter.getModelId(position); + if (id != null) { + // The adapter inserts items for UI layout purposes that aren't associated + // with files. Those will have a null model ID. Don't select them. + if (canSelect(id)) { + mSelection.add(id); + } + } + if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex, + row, rowStartIndex, rowEndIndex)) { + // If this is the position nearest the origin, record it now so that it + // can be returned by endSelection() later. + mPositionNearestOrigin = position; + } + } + } + } + } + + /** + * @return True if the item is selectable. + */ + private boolean canSelect(String id) { + // TODO: Simplify the logic, so the check whether we can select is done in one place. + // Consider injecting FragmentTuner, or move the checks from MultiSelectManager to + // Selection. + for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) { + if (!listener.onBeforeItemStateChange(id, true)) { + return false; + } + } + return true; + } + + /** + * @return Returns true if the position is the nearest to the origin, or, in the case of the + * lower-right corner, whether it is possible that the position is the nearest to the + * origin. See comment below for reasoning for this special case. + */ + private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, + int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) { + int corner = computeCornerNearestOrigin(); + switch (corner) { + case UPPER_LEFT: + return columnIndex == columnStartIndex && rowIndex == rowStartIndex; + case UPPER_RIGHT: + return columnIndex == columnEndIndex && rowIndex == rowStartIndex; + case LOWER_LEFT: + return columnIndex == columnStartIndex && rowIndex == rowEndIndex; + case LOWER_RIGHT: + // Note that in some cases, the last row will not have as many items as there + // are columns (e.g., if there are 4 items and 3 columns, the second row will + // only have one item in the first column). This function is invoked for each + // position from left to right, so return true for any position in the bottom + // row and only the right-most position in the bottom row will be recorded. + return rowIndex == rowEndIndex; + default: + throw new RuntimeException("Invalid corner type."); + } + } + + /** + * Listener for changes in which items have been band selected. + */ + static interface OnSelectionChangedListener { + public void onSelectionChanged(Set updatedSelection); + public boolean onBeforeItemStateChange(String id, boolean nextState); + } + + void addOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) { + mOnSelectionChangedListeners.add(listener); + } + + void removeOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) { + mOnSelectionChangedListeners.remove(listener); + } + + /** + * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side + * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides + * of item columns and the top- and bottom sides of item rows so that it can be determined + * whether the pointer is located within the bounds of an item. + */ + private static class Limits implements Comparable { + int lowerLimit; + int upperLimit; + + Limits(int lowerLimit, int upperLimit) { + this.lowerLimit = lowerLimit; + this.upperLimit = upperLimit; + } + + @Override + public int compareTo(GridModel.Limits other) { + return lowerLimit - other.lowerLimit; + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GridModel.Limits)) { + return false; + } + + return ((GridModel.Limits) other).lowerLimit == lowerLimit && + ((GridModel.Limits) other).upperLimit == upperLimit; + } + + @Override + public String toString() { + return "(" + lowerLimit + ", " + upperLimit + ")"; + } + } + + /** + * The location of a coordinate relative to items. This class represents a general area of the + * view as it relates to band selection rather than an explicit point. For example, two + * different points within an item are considered to have the same "location" because band + * selection originating within the item would select the same items no matter which point + * was used. Same goes for points between items as well as those at the very beginning or end + * of the view. + * + * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the + * advantage of tying the value to the Limits of items along that axis. This allows easy + * selection of items within those Limits as opposed to a search through every item to see if a + * given coordinate value falls within those Limits. + */ + private static class RelativeCoordinate + implements Comparable { + /** + * Location describing points after the last known item. + */ + static final int AFTER_LAST_ITEM = 0; + + /** + * Location describing points before the first known item. + */ + static final int BEFORE_FIRST_ITEM = 1; + + /** + * Location describing points between two items. + */ + static final int BETWEEN_TWO_ITEMS = 2; + + /** + * Location describing points within the limits of one item. + */ + static final int WITHIN_LIMITS = 3; + + /** + * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM, + * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS. + */ + final int type; + + /** + * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type == + * BETWEEN_TWO_ITEMS. + */ + GridModel.Limits limitsBeforeCoordinate; + + /** + * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS. + */ + GridModel.Limits limitsAfterCoordinate; + + // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM. + GridModel.Limits mFirstKnownItem; + // Limits of the last known item; only populated when type == AFTER_LAST_ITEM. + GridModel.Limits mLastKnownItem; + + /** + * @param limitsList The sorted limits list for the coordinate type. If this + * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise, + * mYLimitsList should be pased. + * @param value The coordinate value. + */ + RelativeCoordinate(List limitsList, int value) { + int index = Collections.binarySearch(limitsList, new Limits(value, value)); + + if (index >= 0) { + this.type = WITHIN_LIMITS; + this.limitsBeforeCoordinate = limitsList.get(index); + } else if (~index == 0) { + this.type = BEFORE_FIRST_ITEM; + this.mFirstKnownItem = limitsList.get(0); + } else if (~index == limitsList.size()) { + GridModel.Limits lastLimits = limitsList.get(limitsList.size() - 1); + if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) { + this.type = WITHIN_LIMITS; + this.limitsBeforeCoordinate = lastLimits; + } else { + this.type = AFTER_LAST_ITEM; + this.mLastKnownItem = lastLimits; + } + } else { + GridModel.Limits limitsBeforeIndex = limitsList.get(~index - 1); + if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) { + this.type = WITHIN_LIMITS; + this.limitsBeforeCoordinate = limitsList.get(~index - 1); + } else { + this.type = BETWEEN_TWO_ITEMS; + this.limitsBeforeCoordinate = limitsList.get(~index - 1); + this.limitsAfterCoordinate = limitsList.get(~index); + } + } + } + + int toComparisonValue() { + if (type == BEFORE_FIRST_ITEM) { + return mFirstKnownItem.lowerLimit - 1; + } else if (type == AFTER_LAST_ITEM) { + return mLastKnownItem.upperLimit + 1; + } else if (type == BETWEEN_TWO_ITEMS) { + return limitsBeforeCoordinate.upperLimit + 1; + } else { + return limitsBeforeCoordinate.lowerLimit; + } + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof GridModel.RelativeCoordinate)) { + return false; + } + + GridModel.RelativeCoordinate otherCoordinate = (GridModel.RelativeCoordinate) other; + return toComparisonValue() == otherCoordinate.toComparisonValue(); + } + + @Override + public int compareTo(GridModel.RelativeCoordinate other) { + return toComparisonValue() - other.toComparisonValue(); + } + } + + /** + * The location of a point relative to the Limits of nearby items; consists of both an x- and + * y-RelativeCoordinateLocation. + */ + private class RelativePoint { + final GridModel.RelativeCoordinate xLocation; + final GridModel.RelativeCoordinate yLocation; + + RelativePoint(Point point) { + this.xLocation = new RelativeCoordinate(mColumnBounds, point.x); + this.yLocation = new RelativeCoordinate(mRowBounds, point.y); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof RelativePoint)) { + return false; + } + + RelativePoint otherPoint = (RelativePoint) other; + return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation); + } + } + + /** + * Generates a rectangle which contains the items selected by the pointer and origin. + * @return The rectangle, or null if no items were selected. + */ + private Rect computeBounds() { + Rect rect = new Rect(); + rect.left = getCoordinateValue( + min(mRelativeOrigin.xLocation, mRelativePointer.xLocation), + mColumnBounds, + true); + rect.right = getCoordinateValue( + max(mRelativeOrigin.xLocation, mRelativePointer.xLocation), + mColumnBounds, + false); + rect.top = getCoordinateValue( + min(mRelativeOrigin.yLocation, mRelativePointer.yLocation), + mRowBounds, + true); + rect.bottom = getCoordinateValue( + max(mRelativeOrigin.yLocation, mRelativePointer.yLocation), + mRowBounds, + false); + return rect; + } + + /** + * Computes the corner of the selection nearest the origin. + * @return + */ + private int computeCornerNearestOrigin() { + int cornerValue = 0; + + if (mRelativeOrigin.yLocation == + min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) { + cornerValue |= UPPER; + } else { + cornerValue |= LOWER; + } + + if (mRelativeOrigin.xLocation == + min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) { + cornerValue |= LEFT; + } else { + cornerValue |= RIGHT; + } + + return cornerValue; + } + + private GridModel.RelativeCoordinate min(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) { + return first.compareTo(second) < 0 ? first : second; + } + + private GridModel.RelativeCoordinate max(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) { + return first.compareTo(second) > 0 ? first : second; + } + + /** + * @return The absolute coordinate (i.e., the x- or y-value) of the given relative + * coordinate. + */ + private int getCoordinateValue(GridModel.RelativeCoordinate coordinate, + List limitsList, boolean isStartOfRange) { + switch (coordinate.type) { + case RelativeCoordinate.BEFORE_FIRST_ITEM: + return limitsList.get(0).lowerLimit; + case RelativeCoordinate.AFTER_LAST_ITEM: + return limitsList.get(limitsList.size() - 1).upperLimit; + case RelativeCoordinate.BETWEEN_TWO_ITEMS: + if (isStartOfRange) { + return coordinate.limitsAfterCoordinate.lowerLimit; + } else { + return coordinate.limitsBeforeCoordinate.upperLimit; + } + case RelativeCoordinate.WITHIN_LIMITS: + return coordinate.limitsBeforeCoordinate.lowerLimit; + } + + throw new RuntimeException("Invalid coordinate value."); + } + + private boolean areItemsCoveredByBand( + RelativePoint first, RelativePoint second) { + return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) && + doesCoordinateLocationCoverItems(first.yLocation, second.yLocation); + } + + private boolean doesCoordinateLocationCoverItems( + GridModel.RelativeCoordinate pointerCoordinate, + GridModel.RelativeCoordinate originCoordinate) { + if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM && + originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) { + return false; + } + + if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM && + originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) { + return false; + } + + if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && + originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && + pointerCoordinate.limitsBeforeCoordinate.equals( + originCoordinate.limitsBeforeCoordinate) && + pointerCoordinate.limitsAfterCoordinate.equals( + originCoordinate.limitsAfterCoordinate)) { + return false; + } + + return true; + } + } + + /** + * Provides functionality for BandController. Exists primarily to tests that are + * fully isolated from RecyclerView. + */ + interface SelectionEnvironment { + 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); + int getColumnCount(); + int getChildCount(); + int getVisibleChildCount(); + /** + * Layout items are excluded from the GridModel. + */ + boolean isLayoutItem(int adapterPosition); + /** + * Items may be in the adapter, but without an attached view. + */ + boolean hasView(int adapterPosition); + } + + /** Recycler view facade implementation backed by good ol' RecyclerView. */ + private static final class RuntimeSelectionEnvironment implements SelectionEnvironment { + + private final RecyclerView mView; + private final Drawable mBand; + + private boolean mIsOverlayShown = false; + + RuntimeSelectionEnvironment(RecyclerView view) { + mView = view; + mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay); + } + + @Override + public int getAdapterPositionAt(int index) { + return mView.getChildAdapterPosition(mView.getChildAt(index)); + } + + @Override + public void addOnScrollListener(RecyclerView.OnScrollListener listener) { + mView.addOnScrollListener(listener); + } + + @Override + public void removeOnScrollListener(RecyclerView.OnScrollListener listener) { + mView.removeOnScrollListener(listener); + } + + @Override + public Point createAbsolutePoint(Point relativePoint) { + return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(), + relativePoint.y + mView.computeVerticalScrollOffset()); + } + + @Override + public Rect getAbsoluteRectForChildViewAt(int index) { + final View child = mView.getChildAt(index); + final Rect childRect = new Rect(); + child.getHitRect(childRect); + childRect.left += mView.computeHorizontalScrollOffset(); + childRect.right += mView.computeHorizontalScrollOffset(); + childRect.top += mView.computeVerticalScrollOffset(); + childRect.bottom += mView.computeVerticalScrollOffset(); + return childRect; + } + + @Override + public int getChildCount() { + return mView.getAdapter().getItemCount(); + } + + @Override + public int getVisibleChildCount() { + return mView.getChildCount(); + } + + @Override + public int getColumnCount() { + RecyclerView.LayoutManager layoutManager = mView.getLayoutManager(); + if (layoutManager instanceof GridLayoutManager) { + return ((GridLayoutManager) layoutManager).getSpanCount(); + } + + // Otherwise, it is a list with 1 column. + return 1; + } + + @Override + public int getHeight() { + return mView.getHeight(); + } + + @Override + public void invalidateView() { + mView.invalidate(); + } + + @Override + public void runAtNextFrame(Runnable r) { + mView.postOnAnimation(r); + } + + @Override + public void removeCallback(Runnable r) { + mView.removeCallbacks(r); + } + + @Override + public void scrollBy(int dy) { + mView.scrollBy(0, dy); + } + + @Override + public void showBand(Rect rect) { + mBand.setBounds(rect); + + if (!mIsOverlayShown) { + mView.getOverlay().add(mBand); + } + } + + @Override + public void hideBand() { + mView.getOverlay().remove(mBand); + } + + @Override + public boolean isLayoutItem(int pos) { + // The band selection model only operates on documents and directories. Exclude other + // types of adapter items (e.g. whitespace items like dividers). + RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos); + switch (vh.getItemViewType()) { + case ITEM_TYPE_DOCUMENT: + case ITEM_TYPE_DIRECTORY: + return false; + default: + return true; + } + } + + @Override + public boolean hasView(int pos) { + return mView.findViewHolderForAdapterPosition(pos) != null; + } + } +} \ No newline at end of file diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java index 8cc7c4fb0ecda..1a1fe61fce68d 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -177,6 +177,8 @@ public class DirectoryFragment extends Fragment // Save selection found during creation so it can be restored during directory loading. private Selection mSelection = null; private boolean mSearchMode = false; + + private @Nullable BandController mBandController; private @Nullable ActionMode mActionMode; private DirectoryDragListener mOnDragListener; @@ -299,6 +301,10 @@ public class DirectoryFragment extends Fragment : MultiSelectManager.MODE_SINGLE, null); + if (state.allowMultiple) { + mBandController = new BandController(mRecView, mAdapter, mSelectionManager); + } + mSelectionManager.addCallback(new SelectionModeListener()); mModel = new Model(); @@ -451,7 +457,9 @@ public class DirectoryFragment extends Fragment int pad = getDirectoryPadding(mode); mRecView.setPadding(pad, pad, pad, pad); mRecView.requestLayout(); - mSelectionManager.handleLayoutChanged(); // RecyclerView doesn't do this for us + if (mBandController != null) { + mBandController.handleLayoutChanged(); + } mIconHelper.setViewMode(mode); } diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java index 8852985da5d5f..d570bf197d151 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/MultiSelectManager.java @@ -17,35 +17,21 @@ 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 android.annotation.IntDef; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; -import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; -import android.util.SparseArray; -import android.util.SparseBooleanArray; -import android.util.SparseIntArray; -import android.view.MotionEvent; -import android.view.View; import com.android.documentsui.Events.InputEvent; -import com.android.documentsui.Events.MotionInputEvent; -import com.android.documentsui.R; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.Collection; -import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -72,16 +58,12 @@ public final class MultiSelectManager { private final Selection mSelection = new Selection(); - private final SelectionEnvironment mEnvironment; private final DocumentsAdapter mAdapter; private final List mCallbacks = new ArrayList<>(1); private Range mRanger; private boolean mSingleSelect; - @Nullable private BandController mBandManager; - - /** * @param mode Selection single or multiple selection mode. * @param initialSelection selection state probably preserved in external state. @@ -92,31 +74,7 @@ public final class MultiSelectManager { @SelectionMode int mode, @Nullable Selection initialSelection) { - this(new RuntimeSelectionEnvironment(recyclerView), adapter, mode, initialSelection); - - if (mode == MODE_MULTIPLE) { - // TODO: Don't load this on low memory devices. - mBandManager = new BandController(); - } - - recyclerView.addOnItemTouchListener( - new RecyclerView.OnItemTouchListener() { - @Override - public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { - if (mBandManager != null) { - return mBandManager.handleEvent(new MotionInputEvent(e, recyclerView)); - } - return false; - } - - @Override - public void onTouchEvent(RecyclerView rv, MotionEvent e) { - mBandManager.processInputEvent( - new MotionInputEvent(e, recyclerView)); - } - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} - }); + this(adapter, mode, initialSelection); } /** @@ -126,15 +84,12 @@ public final class MultiSelectManager { */ @VisibleForTesting MultiSelectManager( - SelectionEnvironment environment, DocumentsAdapter adapter, @SelectionMode int mode, @Nullable Selection initialSelection) { - assert(environment != null); assert(adapter != null); - mEnvironment = environment; mAdapter = adapter; mSingleSelect = mode == MODE_SINGLE; @@ -154,10 +109,6 @@ public final class MultiSelectManager { // Update the selection to remove any disappeared IDs. mSelection.cancelProvisionalSelection(); mSelection.intersect(mModelIds); - - if (mBandManager != null && mBandManager.isActive()) { - mBandManager.endBandSelect(); - } } @Override @@ -188,6 +139,11 @@ public final class MultiSelectManager { }); } + void bindContoller(BandController controller) { + // Provides BandController with access to private mSelection state. + controller.bindSelection(mSelection); + } + /** * Adds {@code callback} such that it will be notified when {@code MultiSelectManager} * events occur. @@ -263,12 +219,6 @@ public final class MultiSelectManager { notifySelectionChanged(); } - public void handleLayoutChanged() { - if (mBandManager != null) { - mBandManager.handleLayoutChanged(); - } - } - /** * Clears the selection, without notifying selection listeners. UI elements still need to be * notified about state changes so that they can update their appearance. @@ -511,7 +461,7 @@ public final class MultiSelectManager { return true; } - private boolean notifyBeforeItemStateChange(String id, boolean nextState) { + boolean notifyBeforeItemStateChange(String id, boolean nextState) { int lastListener = mCallbacks.size() - 1; for (int i = lastListener; i > -1; i--) { if (!mCallbacks.get(i).onBeforeItemStateChange(id, nextState)) { @@ -525,7 +475,7 @@ public final class MultiSelectManager { * Notifies registered listeners when the selection status of a single item * (identified by {@code position}) changes. */ - private void notifyItemStateChanged(String id, boolean selected) { + void notifyItemStateChanged(String id, boolean selected) { assert(id != null); int lastListener = mCallbacks.size() - 1; for (int i = lastListener; i > -1; i--) { @@ -540,7 +490,7 @@ public final class MultiSelectManager { * is complete, e.g. clearingSelection, or updating the single * selection from one item to another. */ - private void notifySelectionChanged() { + void notifySelectionChanged() { int lastListener = mCallbacks.size() - 1; for (int i = lastListener; i > -1; i--) { mCallbacks.get(i).onSelectionChanged(); @@ -920,162 +870,6 @@ public final class MultiSelectManager { }; } - /** - * Provides functionality for BandController. Exists primarily to tests that are - * fully isolated from RecyclerView. - */ - interface SelectionEnvironment { - 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); - int getColumnCount(); - int getChildCount(); - int getVisibleChildCount(); - /** - * Layout items are excluded from the GridModel. - */ - boolean isLayoutItem(int adapterPosition); - /** - * Items may be in the adapter, but without an attached view. - */ - boolean hasView(int adapterPosition); - } - - /** Recycler view facade implementation backed by good ol' RecyclerView. */ - private static final class RuntimeSelectionEnvironment implements SelectionEnvironment { - - private final RecyclerView mView; - private final Drawable mBand; - - private boolean mIsOverlayShown = false; - - RuntimeSelectionEnvironment(RecyclerView view) { - mView = view; - mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay); - } - - @Override - public int getAdapterPositionAt(int index) { - return mView.getChildAdapterPosition(mView.getChildAt(index)); - } - - @Override - public void addOnScrollListener(RecyclerView.OnScrollListener listener) { - mView.addOnScrollListener(listener); - } - - @Override - public void removeOnScrollListener(RecyclerView.OnScrollListener listener) { - mView.removeOnScrollListener(listener); - } - - @Override - public Point createAbsolutePoint(Point relativePoint) { - return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(), - relativePoint.y + mView.computeVerticalScrollOffset()); - } - - @Override - public Rect getAbsoluteRectForChildViewAt(int index) { - final View child = mView.getChildAt(index); - final Rect childRect = new Rect(); - child.getHitRect(childRect); - childRect.left += mView.computeHorizontalScrollOffset(); - childRect.right += mView.computeHorizontalScrollOffset(); - childRect.top += mView.computeVerticalScrollOffset(); - childRect.bottom += mView.computeVerticalScrollOffset(); - return childRect; - } - - @Override - public int getChildCount() { - return mView.getAdapter().getItemCount(); - } - - @Override - public int getVisibleChildCount() { - return mView.getChildCount(); - } - - @Override - public int getColumnCount() { - RecyclerView.LayoutManager layoutManager = mView.getLayoutManager(); - if (layoutManager instanceof GridLayoutManager) { - return ((GridLayoutManager) layoutManager).getSpanCount(); - } - - // Otherwise, it is a list with 1 column. - return 1; - } - - @Override - public int getHeight() { - return mView.getHeight(); - } - - @Override - public void invalidateView() { - mView.invalidate(); - } - - @Override - public void runAtNextFrame(Runnable r) { - mView.postOnAnimation(r); - } - - @Override - public void removeCallback(Runnable r) { - mView.removeCallbacks(r); - } - - @Override - public void scrollBy(int dy) { - mView.scrollBy(0, dy); - } - - @Override - public void showBand(Rect rect) { - mBand.setBounds(rect); - - if (!mIsOverlayShown) { - mView.getOverlay().add(mBand); - } - } - - @Override - public void hideBand() { - mView.getOverlay().remove(mBand); - } - - @Override - public boolean isLayoutItem(int pos) { - // The band selection model only operates on documents and directories. Exclude other - // types of adapter items (e.g. whitespace items like dividers). - RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos); - switch (vh.getItemViewType()) { - case ITEM_TYPE_DOCUMENT: - case ITEM_TYPE_DIRECTORY: - return false; - default: - return true; - } - } - - @Override - public boolean hasView(int pos) { - return mView.findViewHolderForAdapterPosition(pos) != null; - } - } - public interface Callback { /** * Called when an item is selected or unselected while in selection mode. @@ -1101,958 +895,4 @@ public final class MultiSelectManager { */ public void onSelectionChanged(); } - - /** - * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView} - * and {@link MultiSelectManager}. This class is responsible for rendering the band select - * overlay and selecting overlaid items via MultiSelectManager. - */ - public class BandController extends RecyclerView.OnScrollListener - implements GridModel.OnSelectionChangedListener { - - private static final int NOT_SET = -1; - - private final Runnable mModelBuilder; - - @Nullable private Rect mBounds; - @Nullable private Point mCurrentPosition; - @Nullable private Point mOrigin; - @Nullable private 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 final Runnable mViewScroller = new ViewScroller(); - - public BandController() { - mEnvironment.addOnScrollListener(this); - - mModelBuilder = new Runnable() { - @Override - public void run() { - mModel = new GridModel(mEnvironment, mAdapter); - mModel.addOnSelectionChangedListener(BandController.this); - } - }; - } - - public boolean handleEvent(MotionInputEvent e) { - // b/23793622 notes the fact that we *never* receive ACTION_DOWN - // events in onTouchEvent. Where it not for this issue, we'd - // push start handling down into handleInputEvent. - if (mBandManager.shouldStart(e)) { - // endBandSelect is handled in handleInputEvent. - mBandManager.startBandSelect(e.getOrigin()); - } else if (mBandManager.isActive() - && e.isMouseEvent() - && e.isActionUp()) { - // Same issue here w b/23793622. The ACTION_UP event - // is only evert dispatched to onTouchEvent when - // there is some associated motion. If a user taps - // mouse, but doesn't move, then band select gets - // started BUT not ended. Causing phantom - // bands to appear when the user later clicks to start - // band select. - mBandManager.processInputEvent(e); - } - - 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. - */ - public void handleLayoutChanged() { - if (mModel != null) { - mModel.removeOnSelectionChangedListener(this); - mModel.stopListening(); - - // build a new model, all fresh and happy. - mModelBuilder.run(); - } - } - - boolean shouldStart(MotionInputEvent e) { - return !isActive() - && e.isMouseEvent() // a mouse - && e.isActionDown() // the initial button press - && mAdapter.getItemCount() > 0 - && e.getItemPosition() == RecyclerView.NO_ID; // in empty space - } - - boolean shouldStop(InputEvent input) { - return isActive() - && input.isMouseEvent() - && input.isActionUp(); - } - - /** - * Processes a MotionEvent by starting, ending, or resizing the band select overlay. - * @param input - */ - private void processInputEvent(InputEvent input) { - assert(input.isMouseEvent()); - - if (shouldStop(input)) { - endBandSelect(); - return; - } - - // We shouldn't get any events in this method when band select is not active, - // but it turns some guests show up late to the party. - if (!isActive()) { - return; - } - - mCurrentPosition = input.getOrigin(); - mModel.resizeSelection(input.getOrigin()); - scrollViewIfNecessary(); - resizeBandSelectRectangle(); - } - - /** - * Starts band select by adding the drawable to the RecyclerView's overlay. - */ - private void startBandSelect(Point origin) { - if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); - - mOrigin = origin; - mModelBuilder.run(); // Creates a new selection model. - mModel.startSelection(mOrigin); - } - - /** - * Scrolls the view if necessary. - */ - private void scrollViewIfNecessary() { - mEnvironment.removeCallback(mViewScroller); - mViewScroller.run(); - mEnvironment.invalidateView(); - } - - /** - * Resizes the band select rectangle by using the origin and the current pointer position as - * two opposite corners of the selection. - */ - private void resizeBandSelectRectangle() { - mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), - Math.min(mOrigin.y, mCurrentPosition.y), - Math.max(mOrigin.x, mCurrentPosition.x), - Math.max(mOrigin.y, mCurrentPosition.y)); - mEnvironment.showBand(mBounds); - } - - /** - * Ends band select by removing the overlay. - */ - private void endBandSelect() { - if (DEBUG) Log.d(TAG, "Ending band select."); - - mEnvironment.hideBand(); - mSelection.applyProvisionalSelection(); - mModel.endSelection(); - int firstSelected = mModel.getPositionNearestOrigin(); - if (firstSelected != NOT_SET) { - if (mSelection.contains(mAdapter.getModelId(firstSelected))) { - // TODO: firstSelected should really be lastSelected, we want to anchor the item - // where the mouse-up occurred. - setSelectionRangeBegin(firstSelected); - } else { - // TODO: Check if this is really happening. - Log.w(TAG, "First selected by band is NOT in selection!"); - } - } - - mModel = null; - mOrigin = null; - } - - @Override - public void onSelectionChanged(Set updatedSelection) { - Map delta = mSelection.setProvisionalSelection(updatedSelection); - for (Map.Entry entry: delta.entrySet()) { - notifyItemStateChanged(entry.getKey(), entry.getValue()); - } - notifySelectionChanged(); - } - - @Override - public boolean onBeforeItemStateChange(String id, boolean nextState) { - return 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()) { - return; - } - - // 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 -= dy; - resizeBandSelectRectangle(); - } - } - - /** - * Provides a band selection item model for views within a RecyclerView. This class queries the - * RecyclerView to determine where its items are placed; then, once band selection is underway, - * it alerts listeners of which items are covered by the selections. - */ - public static final class GridModel extends RecyclerView.OnScrollListener { - - public static final int NOT_SET = -1; - - // Enum values used to determine the corner at which the origin is located within the - private static final int UPPER = 0x00; - private static final int LOWER = 0x01; - private static final int LEFT = 0x00; - private static final int RIGHT = 0x02; - private static final int UPPER_LEFT = UPPER | LEFT; - private static final int UPPER_RIGHT = UPPER | RIGHT; - private static final int LOWER_LEFT = LOWER | LEFT; - private static final int LOWER_RIGHT = LOWER | RIGHT; - - private final SelectionEnvironment mHelper; - private final DocumentsAdapter mAdapter; - - private final List mOnSelectionChangedListeners = - new ArrayList<>(); - - // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed - // by their y-offset. For example, if the first column of the view starts at an x-value of 5, - // mColumns.get(5) would return an array of positions in that column. Within that array, the - // value for key y is the adapter position for the item whose y-offset is y. - private final SparseArray mColumns = new SparseArray<>(); - - // List of limits along the x-axis (columns). - // This list is sorted from furthest left to furthest right. - private final List mColumnBounds = new ArrayList<>(); - - // List of limits along the y-axis (rows). Note that this list only contains items which - // have been in the viewport. - private final List mRowBounds = new ArrayList<>(); - - // The adapter positions which have been recorded so far. - private final SparseBooleanArray mKnownPositions = new SparseBooleanArray(); - - // Array passed to registered OnSelectionChangedListeners. One array is created and reused - // throughout the lifetime of the object. - private final Set mSelection = new HashSet<>(); - - // The current pointer (in absolute positioning from the top of the view). - private Point mPointer = null; - - // The bounds of the band selection. - private RelativePoint mRelativeOrigin; - private RelativePoint mRelativePointer; - - private boolean mIsActive; - - // Tracks where the band select originated from. This is used to determine where selections - // should expand from when Shift+click is used. - private int mPositionNearestOrigin = NOT_SET; - - GridModel(SelectionEnvironment helper, DocumentsAdapter adapter) { - mHelper = helper; - mAdapter = adapter; - mHelper.addOnScrollListener(this); - } - - /** - * Stops listening to the view's scrolls. Call this function before discarding a - * BandSelecModel object to prevent memory leaks. - */ - void stopListening() { - mHelper.removeOnScrollListener(this); - } - - /** - * Start a band select operation at the given point. - * @param relativeOrigin The origin of the band select operation, relative to the viewport. - * For example, if the view is scrolled to the bottom, the top-left of the viewport - * would have a relative origin of (0, 0), even though its absolute point has a higher - * y-value. - */ - void startSelection(Point relativeOrigin) { - recordVisibleChildren(); - if (isEmpty()) { - // The selection band logic works only if there is at least one visible child. - return; - } - - mIsActive = true; - mPointer = mHelper.createAbsolutePoint(relativeOrigin); - mRelativeOrigin = new RelativePoint(mPointer); - mRelativePointer = new RelativePoint(mPointer); - computeCurrentSelection(); - notifyListeners(); - } - - /** - * Resizes the selection by adjusting the pointer (i.e., the corner of the selection - * opposite the origin. - * @param relativePointer The pointer (opposite of the origin) of the band select operation, - * relative to the viewport. For example, if the view is scrolled to the bottom, the - * top-left of the viewport would have a relative origin of (0, 0), even though its - * absolute point has a higher y-value. - */ - @VisibleForTesting - void resizeSelection(Point relativePointer) { - mPointer = mHelper.createAbsolutePoint(relativePointer); - updateModel(); - } - - /** - * Ends the band selection. - */ - void endSelection() { - mIsActive = false; - } - - /** - * @return The adapter position for the item nearest the origin corresponding to the latest - * band select operation, or NOT_SET if the selection did not cover any items. - */ - int getPositionNearestOrigin() { - return mPositionNearestOrigin; - } - - @Override - public void onScrolled(RecyclerView recyclerView, int dx, int dy) { - if (!mIsActive) { - return; - } - - mPointer.x += dx; - mPointer.y += dy; - recordVisibleChildren(); - updateModel(); - } - - /** - * Queries the view for all children and records their location metadata. - */ - private void recordVisibleChildren() { - for (int i = 0; i < mHelper.getVisibleChildCount(); i++) { - int adapterPosition = mHelper.getAdapterPositionAt(i); - // Sometimes the view is not attached, as we notify the multi selection manager - // synchronously, while views are attached asynchronously. As a result items which - // are in the adapter may not actually have a corresponding view (yet). - if (mHelper.hasView(adapterPosition) && - !mHelper.isLayoutItem(adapterPosition) && - !mKnownPositions.get(adapterPosition)) { - mKnownPositions.put(adapterPosition, true); - recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition); - } - } - } - - /** - * Checks if there are any recorded children. - */ - private boolean isEmpty() { - return mColumnBounds.size() == 0 || mRowBounds.size() == 0; - } - - /** - * Updates the limits lists and column map with the given item metadata. - * @param absoluteChildRect The absolute rectangle for the child view being processed. - * @param adapterPosition The position of the child view being processed. - */ - private void recordItemData(Rect absoluteChildRect, int adapterPosition) { - if (mColumnBounds.size() != mHelper.getColumnCount()) { - // If not all x-limits have been recorded, record this one. - recordLimits( - mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right)); - } - - recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom)); - - SparseIntArray columnList = mColumns.get(absoluteChildRect.left); - if (columnList == null) { - columnList = new SparseIntArray(); - mColumns.put(absoluteChildRect.left, columnList); - } - columnList.put(absoluteChildRect.top, adapterPosition); - } - - /** - * Ensures limits exists within the sorted list limitsList, and adds it to the list if it - * does not exist. - */ - private void recordLimits(List limitsList, Limits limits) { - int index = Collections.binarySearch(limitsList, limits); - if (index < 0) { - limitsList.add(~index, limits); - } - } - - /** - * Handles a moved pointer; this function determines whether the pointer movement resulted - * in a selection change and, if it has, notifies listeners of this change. - */ - private void updateModel() { - RelativePoint old = mRelativePointer; - mRelativePointer = new RelativePoint(mPointer); - if (old != null && mRelativePointer.equals(old)) { - return; - } - - computeCurrentSelection(); - notifyListeners(); - } - - /** - * Computes the currently-selected items. - */ - private void computeCurrentSelection() { - if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) { - updateSelection(computeBounds()); - } else { - mSelection.clear(); - mPositionNearestOrigin = NOT_SET; - } - } - - /** - * Notifies all listeners of a selection change. Note that this function simply passes - * mSelection, so computeCurrentSelection() should be called before this - * function. - */ - private void notifyListeners() { - for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) { - listener.onSelectionChanged(mSelection); - } - } - - /** - * @param rect Rectangle including all covered items. - */ - private void updateSelection(Rect rect) { - int columnStart = - Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left)); - assert(columnStart >= 0); - int columnEnd = columnStart; - - for (int i = columnStart; i < mColumnBounds.size() - && mColumnBounds.get(i).lowerLimit <= rect.right; i++) { - columnEnd = i; - } - - int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top)); - if (rowStart < 0) { - mPositionNearestOrigin = NOT_SET; - return; - } - - int rowEnd = rowStart; - for (int i = rowStart; i < mRowBounds.size() - && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) { - rowEnd = i; - } - - updateSelection(columnStart, columnEnd, rowStart, rowEnd); - } - - /** - * Computes the selection given the previously-computed start- and end-indices for each - * row and column. - */ - private void updateSelection( - int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) { - if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d", - columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex)); - - mSelection.clear(); - for (int column = columnStartIndex; column <= columnEndIndex; column++) { - SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit); - for (int row = rowStartIndex; row <= rowEndIndex; row++) { - // The default return value for SparseIntArray.get is 0, which is a valid - // position. Use a sentry value to prevent erroneously selecting item 0. - final int rowKey = mRowBounds.get(row).lowerLimit; - int position = items.get(rowKey, NOT_SET); - if (position != NOT_SET) { - String id = mAdapter.getModelId(position); - if (id != null) { - // The adapter inserts items for UI layout purposes that aren't associated - // with files. Those will have a null model ID. Don't select them. - if (canSelect(id)) { - mSelection.add(id); - } - } - if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex, - row, rowStartIndex, rowEndIndex)) { - // If this is the position nearest the origin, record it now so that it - // can be returned by endSelection() later. - mPositionNearestOrigin = position; - } - } - } - } - } - - /** - * @return True if the item is selectable. - */ - private boolean canSelect(String id) { - // TODO: Simplify the logic, so the check whether we can select is done in one place. - // Consider injecting FragmentTuner, or move the checks from MultiSelectManager to - // Selection. - for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) { - if (!listener.onBeforeItemStateChange(id, true)) { - return false; - } - } - return true; - } - - /** - * @return Returns true if the position is the nearest to the origin, or, in the case of the - * lower-right corner, whether it is possible that the position is the nearest to the - * origin. See comment below for reasoning for this special case. - */ - private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, - int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) { - int corner = computeCornerNearestOrigin(); - switch (corner) { - case UPPER_LEFT: - return columnIndex == columnStartIndex && rowIndex == rowStartIndex; - case UPPER_RIGHT: - return columnIndex == columnEndIndex && rowIndex == rowStartIndex; - case LOWER_LEFT: - return columnIndex == columnStartIndex && rowIndex == rowEndIndex; - case LOWER_RIGHT: - // Note that in some cases, the last row will not have as many items as there - // are columns (e.g., if there are 4 items and 3 columns, the second row will - // only have one item in the first column). This function is invoked for each - // position from left to right, so return true for any position in the bottom - // row and only the right-most position in the bottom row will be recorded. - return rowIndex == rowEndIndex; - default: - throw new RuntimeException("Invalid corner type."); - } - } - - /** - * Listener for changes in which items have been band selected. - */ - static interface OnSelectionChangedListener { - public void onSelectionChanged(Set updatedSelection); - public boolean onBeforeItemStateChange(String id, boolean nextState); - } - - void addOnSelectionChangedListener(OnSelectionChangedListener listener) { - mOnSelectionChangedListeners.add(listener); - } - - void removeOnSelectionChangedListener(OnSelectionChangedListener listener) { - mOnSelectionChangedListeners.remove(listener); - } - - /** - * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side - * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides - * of item columns and the top- and bottom sides of item rows so that it can be determined - * whether the pointer is located within the bounds of an item. - */ - private static class Limits implements Comparable { - int lowerLimit; - int upperLimit; - - Limits(int lowerLimit, int upperLimit) { - this.lowerLimit = lowerLimit; - this.upperLimit = upperLimit; - } - - @Override - public int compareTo(Limits other) { - return lowerLimit - other.lowerLimit; - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof Limits)) { - return false; - } - - return ((Limits) other).lowerLimit == lowerLimit && - ((Limits) other).upperLimit == upperLimit; - } - - @Override - public String toString() { - return "(" + lowerLimit + ", " + upperLimit + ")"; - } - } - - /** - * The location of a coordinate relative to items. This class represents a general area of the - * view as it relates to band selection rather than an explicit point. For example, two - * different points within an item are considered to have the same "location" because band - * selection originating within the item would select the same items no matter which point - * was used. Same goes for points between items as well as those at the very beginning or end - * of the view. - * - * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the - * advantage of tying the value to the Limits of items along that axis. This allows easy - * selection of items within those Limits as opposed to a search through every item to see if a - * given coordinate value falls within those Limits. - */ - private static class RelativeCoordinate - implements Comparable { - /** - * Location describing points after the last known item. - */ - static final int AFTER_LAST_ITEM = 0; - - /** - * Location describing points before the first known item. - */ - static final int BEFORE_FIRST_ITEM = 1; - - /** - * Location describing points between two items. - */ - static final int BETWEEN_TWO_ITEMS = 2; - - /** - * Location describing points within the limits of one item. - */ - static final int WITHIN_LIMITS = 3; - - /** - * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM, - * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS. - */ - final int type; - - /** - * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type == - * BETWEEN_TWO_ITEMS. - */ - Limits limitsBeforeCoordinate; - - /** - * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS. - */ - Limits limitsAfterCoordinate; - - // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM. - Limits mFirstKnownItem; - // Limits of the last known item; only populated when type == AFTER_LAST_ITEM. - Limits mLastKnownItem; - - /** - * @param limitsList The sorted limits list for the coordinate type. If this - * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise, - * mYLimitsList should be pased. - * @param value The coordinate value. - */ - RelativeCoordinate(List limitsList, int value) { - int index = Collections.binarySearch(limitsList, new Limits(value, value)); - - if (index >= 0) { - this.type = WITHIN_LIMITS; - this.limitsBeforeCoordinate = limitsList.get(index); - } else if (~index == 0) { - this.type = BEFORE_FIRST_ITEM; - this.mFirstKnownItem = limitsList.get(0); - } else if (~index == limitsList.size()) { - Limits lastLimits = limitsList.get(limitsList.size() - 1); - if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) { - this.type = WITHIN_LIMITS; - this.limitsBeforeCoordinate = lastLimits; - } else { - this.type = AFTER_LAST_ITEM; - this.mLastKnownItem = lastLimits; - } - } else { - Limits limitsBeforeIndex = limitsList.get(~index - 1); - if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) { - this.type = WITHIN_LIMITS; - this.limitsBeforeCoordinate = limitsList.get(~index - 1); - } else { - this.type = BETWEEN_TWO_ITEMS; - this.limitsBeforeCoordinate = limitsList.get(~index - 1); - this.limitsAfterCoordinate = limitsList.get(~index); - } - } - } - - int toComparisonValue() { - if (type == BEFORE_FIRST_ITEM) { - return mFirstKnownItem.lowerLimit - 1; - } else if (type == AFTER_LAST_ITEM) { - return mLastKnownItem.upperLimit + 1; - } else if (type == BETWEEN_TWO_ITEMS) { - return limitsBeforeCoordinate.upperLimit + 1; - } else { - return limitsBeforeCoordinate.lowerLimit; - } - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof RelativeCoordinate)) { - return false; - } - - RelativeCoordinate otherCoordinate = (RelativeCoordinate) other; - return toComparisonValue() == otherCoordinate.toComparisonValue(); - } - - @Override - public int compareTo(RelativeCoordinate other) { - return toComparisonValue() - other.toComparisonValue(); - } - } - - /** - * The location of a point relative to the Limits of nearby items; consists of both an x- and - * y-RelativeCoordinateLocation. - */ - private class RelativePoint { - final RelativeCoordinate xLocation; - final RelativeCoordinate yLocation; - - RelativePoint(Point point) { - this.xLocation = new RelativeCoordinate(mColumnBounds, point.x); - this.yLocation = new RelativeCoordinate(mRowBounds, point.y); - } - - @Override - public boolean equals(Object other) { - if (!(other instanceof RelativePoint)) { - return false; - } - - RelativePoint otherPoint = (RelativePoint) other; - return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation); - } - } - - /** - * Generates a rectangle which contains the items selected by the pointer and origin. - * @return The rectangle, or null if no items were selected. - */ - private Rect computeBounds() { - Rect rect = new Rect(); - rect.left = getCoordinateValue( - min(mRelativeOrigin.xLocation, mRelativePointer.xLocation), - mColumnBounds, - true); - rect.right = getCoordinateValue( - max(mRelativeOrigin.xLocation, mRelativePointer.xLocation), - mColumnBounds, - false); - rect.top = getCoordinateValue( - min(mRelativeOrigin.yLocation, mRelativePointer.yLocation), - mRowBounds, - true); - rect.bottom = getCoordinateValue( - max(mRelativeOrigin.yLocation, mRelativePointer.yLocation), - mRowBounds, - false); - return rect; - } - - /** - * Computes the corner of the selection nearest the origin. - * @return - */ - private int computeCornerNearestOrigin() { - int cornerValue = 0; - - if (mRelativeOrigin.yLocation == - min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) { - cornerValue |= UPPER; - } else { - cornerValue |= LOWER; - } - - if (mRelativeOrigin.xLocation == - min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) { - cornerValue |= LEFT; - } else { - cornerValue |= RIGHT; - } - - return cornerValue; - } - - private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) { - return first.compareTo(second) < 0 ? first : second; - } - - private RelativeCoordinate max(RelativeCoordinate first, RelativeCoordinate second) { - return first.compareTo(second) > 0 ? first : second; - } - - /** - * @return The absolute coordinate (i.e., the x- or y-value) of the given relative - * coordinate. - */ - private int getCoordinateValue(RelativeCoordinate coordinate, - List limitsList, boolean isStartOfRange) { - switch (coordinate.type) { - case RelativeCoordinate.BEFORE_FIRST_ITEM: - return limitsList.get(0).lowerLimit; - case RelativeCoordinate.AFTER_LAST_ITEM: - return limitsList.get(limitsList.size() - 1).upperLimit; - case RelativeCoordinate.BETWEEN_TWO_ITEMS: - if (isStartOfRange) { - return coordinate.limitsAfterCoordinate.lowerLimit; - } else { - return coordinate.limitsBeforeCoordinate.upperLimit; - } - case RelativeCoordinate.WITHIN_LIMITS: - return coordinate.limitsBeforeCoordinate.lowerLimit; - } - - throw new RuntimeException("Invalid coordinate value."); - } - - private boolean areItemsCoveredByBand( - RelativePoint first, RelativePoint second) { - return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) && - doesCoordinateLocationCoverItems(first.yLocation, second.yLocation); - } - - private boolean doesCoordinateLocationCoverItems( - RelativeCoordinate pointerCoordinate, - RelativeCoordinate originCoordinate) { - if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM && - originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) { - return false; - } - - if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM && - originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) { - return false; - } - - if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && - originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && - pointerCoordinate.limitsBeforeCoordinate.equals( - originCoordinate.limitsBeforeCoordinate) && - pointerCoordinate.limitsAfterCoordinate.equals( - originCoordinate.limitsAfterCoordinate)) { - return false; - } - - return true; - } - } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/BandController_GridModelTest.java similarity index 97% rename from packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java rename to packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/BandController_GridModelTest.java index e401de1607080..59547adc08e05 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManager_GridModelTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/BandController_GridModelTest.java @@ -16,7 +16,7 @@ package com.android.documentsui.dirlist; -import static com.android.documentsui.dirlist.MultiSelectManager.GridModel.NOT_SET; +import static com.android.documentsui.dirlist.BandController.GridModel.NOT_SET; import android.graphics.Point; import android.graphics.Rect; @@ -24,14 +24,14 @@ import android.support.v7.widget.RecyclerView.OnScrollListener; import android.test.AndroidTestCase; import android.test.suitebuilder.annotation.SmallTest; -import com.android.documentsui.dirlist.MultiSelectManager.GridModel; +import com.android.documentsui.dirlist.BandController.GridModel; import java.util.ArrayList; import java.util.List; import java.util.Set; @SmallTest -public class MultiSelectManager_GridModelTest extends AndroidTestCase { +public class BandController_GridModelTest extends AndroidTestCase { private static final int VIEW_PADDING_PX = 5; private static final int CHILD_VIEW_EDGE_PX = 100; @@ -279,7 +279,7 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase { model.onScrolled(null, 0, dy); } - private static final class TestEnvironment implements MultiSelectManager.SelectionEnvironment { + private static final class TestEnvironment implements BandController.SelectionEnvironment { private final int mNumColumns; private final int mNumRows; diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java index 9447d9c18a835..9401da8fb5daf 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/MultiSelectManagerTest.java @@ -23,6 +23,7 @@ import android.util.SparseBooleanArray; import com.android.documentsui.TestInputEvent; import com.android.documentsui.dirlist.MultiSelectManager.Selection; + import com.google.common.collect.Lists; import java.util.ArrayList; @@ -43,14 +44,12 @@ public class MultiSelectManagerTest extends AndroidTestCase { private MultiSelectManager mManager; private TestCallback mCallback; - private TestSelectionEnvironment mEnv; private TestDocumentsAdapter mAdapter; public void setUp() throws Exception { mCallback = new TestCallback(); - mEnv = new TestSelectionEnvironment(items); mAdapter = new TestDocumentsAdapter(items); - mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_MULTIPLE, null); + mManager = new MultiSelectManager(mAdapter, MultiSelectManager.MODE_MULTIPLE, null); mManager.addCallback(mCallback); } @@ -174,7 +173,7 @@ public class MultiSelectManagerTest extends AndroidTestCase { } public void testSingleSelectMode() { - mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE, null); + mManager = new MultiSelectManager(mAdapter, MultiSelectManager.MODE_SINGLE, null); mManager.addCallback(mCallback); longPress(20); tap(13); @@ -182,7 +181,7 @@ public class MultiSelectManagerTest extends AndroidTestCase { } public void testSingleSelectMode_ShiftTap() { - mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE, null); + mManager = new MultiSelectManager(mAdapter, MultiSelectManager.MODE_SINGLE, null); mManager.addCallback(mCallback); longPress(13); shiftTap(20); @@ -229,7 +228,7 @@ public class MultiSelectManagerTest extends AndroidTestCase { } public void testRangeSelection_singleSelect() { - mManager = new MultiSelectManager(mEnv, mAdapter, MultiSelectManager.MODE_SINGLE, null); + mManager = new MultiSelectManager(mAdapter, MultiSelectManager.MODE_SINGLE, null); mManager.addCallback(mCallback); mManager.startRangeSelection(11); mManager.snapRangeSelection(19); diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java index f56476978a4eb..b69787c697cea 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/dirlist/TestSelectionEnvironment.java @@ -20,7 +20,7 @@ import android.graphics.Point; import android.graphics.Rect; import android.support.v7.widget.RecyclerView.OnScrollListener; -import com.android.documentsui.dirlist.MultiSelectManager.SelectionEnvironment; +import com.android.documentsui.dirlist.BandController.SelectionEnvironment; import java.util.List;