Start selection mode with single mouse click...
Double clicking an item opens it.
BandSelectManager tells MultiSelectManager where its selection begins
so Shift+Click behavior can be complimentary.
BandSelectManager more actively manages selection...so it doesn't
clear existing selection on mouse down.
Change-Id: Ibe65e793e84463d333a19f363dfb0d42c37480e3
diff --git a/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
index aa63837..f2bde0e 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/BandSelectManager.java
@@ -16,7 +16,9 @@
package com.android.documentsui;
+import static com.android.documentsui.Events.isMouseEvent;
import static com.android.internal.util.Preconditions.checkState;
+import static java.lang.String.format;
import android.graphics.Point;
import android.graphics.Rect;
@@ -24,6 +26,7 @@
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;
@@ -34,6 +37,8 @@
*/
public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
+ private static final int NOT_SELECTED = -1;
+
// For debugging purposes.
private static final String TAG = "BandSelectManager";
private static final boolean DEBUG = false;
@@ -41,10 +46,17 @@
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 Rect mRegionBounds;
+ private Rect mBounds;
+ // Maintain the last selection made by band, so if bounds shink back, we can unselect
+ // the respective items.
+
+ // Track information
+ private int mCursorDeltaY = 0;
+ private int mFirstSelected = NOT_SELECTED;
/**
* @param recyclerView
@@ -109,11 +121,17 @@
* @param pointerPosition
*/
private void resizeBandSelectRectangle(Point pointerPosition) {
- mRegionBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),
+
+ if (mBounds != null) {
+ mCursorDeltaY = pointerPosition.y - mBounds.bottom;
+ }
+
+ mBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),
Math.min(mOrigin.y, pointerPosition.y),
Math.max(mOrigin.x, pointerPosition.x),
Math.max(mOrigin.y, pointerPosition.y));
- mRegionSelectorDrawable.setBounds(mRegionBounds);
+
+ mRegionSelectorDrawable.setBounds(mBounds);
}
/**
@@ -122,14 +140,53 @@
* 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 doRectsOverlap = Rect.intersects(childRect, mRegionBounds);
- mSelectManager.setItemSelected(holder.getAdapterPosition(), doRectsOverlap);
+ 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;
}
}
@@ -139,16 +196,10 @@
private void endBandSelect() {
if (DEBUG) Log.d(TAG, "Ending band select.");
mIsBandSelectActive = false;
+ mSelectedByBand.clear();
mRecyclerView.getOverlay().remove(mRegionSelectorDrawable);
- }
-
- /**
- * Determines whether the provided event was triggered by a mouse (as opposed to a finger or
- * stylus).
- * @param e
- * @return
- */
- private static boolean isMouseEvent(MotionEvent e) {
- return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
+ if (mFirstSelected != NOT_SELECTED) {
+ mSelectManager.setSelectionFocusBegin(mFirstSelected);
+ }
}
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
index 308375e7..9468cfd 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
@@ -286,6 +286,11 @@
public boolean onSingleTapUp(MotionEvent e) {
return DirectoryFragment.this.onSingleTapUp(e);
}
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ Log.d(TAG, "Handling double tap.");
+ return DirectoryFragment.this.onDoubleTap(e);
+ }
};
mSelectionManager = new MultiSelectManager(mRecView, listener);
@@ -425,20 +430,37 @@
}
private boolean onSingleTapUp(MotionEvent e) {
- int position = getEventAdapterPosition(e);
-
- if (position != RecyclerView.NO_POSITION) {
- final Cursor cursor = mAdapter.getItem(position);
- checkNotNull(cursor, "Cursor cannot be null.");
- final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
- final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
- if (isDocumentEnabled(docMimeType, docFlags)) {
- final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
- ((BaseActivity) getActivity()).onDocumentPicked(doc);
- return true;
+ if (!Events.isMouseEvent(e)) {
+ int position = getEventAdapterPosition(e);
+ if (position != RecyclerView.NO_POSITION) {
+ return handleViewItem(position);
}
}
+ return false;
+ }
+ protected boolean onDoubleTap(MotionEvent e) {
+ if (Events.isMouseEvent(e)) {
+ Log.d(TAG, "Handling double tap from mouse.");
+ int position = getEventAdapterPosition(e);
+ if (position != RecyclerView.NO_POSITION) {
+ return handleViewItem(position);
+ }
+ }
+ return false;
+ }
+
+ private boolean handleViewItem(int position) {
+ final Cursor cursor = mAdapter.getItem(position);
+ checkNotNull(cursor, "Cursor cannot be null.");
+ final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
+ final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
+ if (isDocumentEnabled(docMimeType, docFlags)) {
+ final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
+ ((BaseActivity) getActivity()).onDocumentPicked(doc);
+ mSelectionManager.clearSelection();
+ return true;
+ }
return false;
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/Events.java b/packages/DocumentsUI/src/com/android/documentsui/Events.java
new file mode 100644
index 0000000..2e06903
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/Events.java
@@ -0,0 +1,54 @@
+/*
+ * 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 android.view.KeyEvent;
+import android.view.MotionEvent;
+
+/**
+ * Utility code for dealing with MotionEvents.
+ */
+final class Events {
+
+ /**
+ * Returns true if event was triggered by a mouse.
+ */
+ static boolean isMouseEvent(MotionEvent e) {
+ return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
+ }
+
+ /**
+ * Returns true if event was triggered by a mouse.
+ */
+ static boolean isMouseType(int toolType) {
+ return toolType == MotionEvent.TOOL_TYPE_MOUSE;
+ }
+
+ /**
+ * Returns true if the shift is pressed.
+ */
+ boolean isShiftPressed(MotionEvent e) {
+ return hasShiftBit(e.getMetaState());
+ }
+
+ /**
+ * Returns true if the "SHIFT" bit is set.
+ */
+ static boolean hasShiftBit(int metaState) {
+ return (metaState & KeyEvent.META_SHIFT_ON) != 0;
+ }
+}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
index a962abd..91b4456 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
@@ -26,13 +26,11 @@
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.GestureDetector;
+import android.view.GestureDetector.OnDoubleTapListener;
import android.view.GestureDetector.OnGestureListener;
-import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
-import com.android.internal.util.Preconditions;
-
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
@@ -51,7 +49,7 @@
// Only created when selection is cleared.
private Selection mIntermediateSelection;
- private Ranger mRanger;
+ private Range mRanger;
private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
private Adapter<?> mAdapter;
@@ -60,8 +58,11 @@
/**
* @param recyclerView
* @param gestureDelegate Option delage gesture listener.
+ * @template A gestureDelegate that implements both {@link OnGestureListener}
+ * and {@link OnDoubleTapListener}
*/
- public MultiSelectManager(final RecyclerView recyclerView, OnGestureListener gestureDelegate) {
+ public <L extends OnGestureListener & OnDoubleTapListener> MultiSelectManager(
+ final RecyclerView recyclerView, L gestureDelegate) {
this(
recyclerView.getAdapter(),
new RecyclerViewHelper() {
@@ -86,11 +87,15 @@
}
};
+ CompositeOnGestureListener<? extends Object> compositeListener =
+ new CompositeOnGestureListener<>(listener, gestureDelegate);
final GestureDetector detector = new GestureDetector(
recyclerView.getContext(),
gestureDelegate == null
? listener
- : new CompositeOnGestureListener(listener, gestureDelegate));
+ : compositeListener);
+
+ detector.setOnDoubleTapListener(compositeListener);
recyclerView.addOnItemTouchListener(
new RecyclerView.OnItemTouchListener() {
@@ -236,52 +241,6 @@
}
}
- /**
- * @param e
- * @return true if the event was consumed.
- */
- private boolean onSingleTapUp(MotionEvent e) {
- if (DEBUG) Log.d(TAG, "Handling tap event.");
- if (mSelection.isEmpty()) {
- return false;
- }
-
- return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState());
- }
-
- /**
- * TODO: Roll this into {@link #onSingleTapUp(MotionEvent)} once MotionEvent
- * can be mocked.
- *
- * @param position
- * @param metaState as returned from {@link MotionEvent#getMetaState()}.
- * @return true if the event was consumed.
- * @hide
- */
- @VisibleForTesting
- boolean onSingleTapUp(int position, int metaState) {
- if (mSelection.isEmpty()) {
- return false;
- }
-
- if (position == RecyclerView.NO_POSITION) {
- if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
- clearSelection();
- return true;
- }
-
- if (isShiftPressed(metaState) && mRanger != null) {
- mRanger.snapSelection(position);
- } else {
- toggleSelection(position);
- }
- return true;
- }
-
- private static boolean isShiftPressed(int metaState) {
- return (metaState & KeyEvent.META_SHIFT_ON) != 0;
- }
-
private void onLongPress(MotionEvent e) {
if (DEBUG) Log.d(TAG, "Handling long press event.");
@@ -310,6 +269,50 @@
}
/**
+ * @param e
+ * @return true if the event was consumed.
+ */
+ private boolean onSingleTapUp(MotionEvent e) {
+ if (DEBUG) Log.d(TAG, "Handling tap event.");
+ return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState(), e.getToolType(0));
+ }
+
+ /**
+ * TODO: Roll this into {@link #onSingleTapUp(MotionEvent)} once MotionEvent
+ * can be mocked.
+ *
+ * @param position
+ * @param metaState as returned from {@link MotionEvent#getMetaState()}.
+ * @param toolType
+ * @return true if the event was consumed.
+ * @hide
+ */
+ @VisibleForTesting
+ boolean onSingleTapUp(int position, int metaState, int toolType) {
+ if (mSelection.isEmpty()) {
+ // if this is a mouse click on an item, start selection mode.
+ if (position != RecyclerView.NO_POSITION && Events.isMouseType(toolType)) {
+ toggleSelection(position);
+ }
+ return false;
+ }
+
+ if (position == RecyclerView.NO_POSITION) {
+ if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
+ clearSelection();
+ return false;
+ }
+
+ if (Events.hasShiftBit(metaState) && mRanger != null) {
+ mRanger.snapSelection(position);
+ } else {
+ toggleSelection(position);
+ }
+
+ return false;
+ }
+
+ /**
* Toggles the selection state at position. If an item does end up selected
* a new Ranger (range selection manager) at that point is created.
*
@@ -334,13 +337,26 @@
// By recreating Ranger at this point, we allow the user to create
// multiple separate contiguous ranges with SHIFT+Click & Click.
if (selected) {
- mRanger = new Ranger(position);
+ setSelectionFocusBegin(position);
}
return selected;
}
}
/**
+ * Sets the magic location at which a selection range begins. This
+ * value is consulted when determining how to extend, and modify
+ * selection ranges.
+ *
+ * @throws IllegalStateException if {@code position} is not already be selected
+ * @param position
+ */
+ void setSelectionFocusBegin(int position) {
+ checkState(mSelection.contains(position));
+ mRanger = new Range(position);
+ }
+
+ /**
* Try to select all elements in range. Not that callbacks can cancel selection
* of specific items, so some or even all items may not reflect the desired
* state after the update is complete.
@@ -420,18 +436,18 @@
/**
* Class providing support for managing range selections.
*/
- private final class Ranger {
+ private final class Range {
private static final int UNDEFINED = -1;
final int mBegin;
int mEnd = UNDEFINED;
- public Ranger(int begin) {
+ public Range(int begin) {
if (DEBUG) Log.d(TAG, String.format("New Ranger(%d) created.", begin));
mBegin = begin;
}
- void snapSelection(int position) {
+ private void snapSelection(int position) {
checkState(mRanger != null);
checkArgument(position != RecyclerView.NO_POSITION);
@@ -720,12 +736,17 @@
/**
* A composite {@code OnGestureDetector} that allows us to delegate unhandled
* events to an outside party (presumably DirectoryFragment).
+ * @template A gestureDelegate that implements both {@link OnGestureListener}
+ * and {@link OnDoubleTapListener}
*/
- private static final class CompositeOnGestureListener implements OnGestureListener {
+ private static final class
+ CompositeOnGestureListener<L extends OnGestureListener & OnDoubleTapListener>
+ implements OnGestureListener, OnDoubleTapListener {
- private OnGestureListener[] mListeners;
+ private L[] mListeners;
- public CompositeOnGestureListener(OnGestureListener... listeners) {
+ @SafeVarargs
+ public CompositeOnGestureListener(L... listeners) {
mListeners = listeners;
}
@@ -782,5 +803,35 @@
}
return false;
}
+
+ @Override
+ public boolean onSingleTapConfirmed(MotionEvent e) {
+ for (int i = 0; i < mListeners.length; i++) {
+ if (mListeners[i].onSingleTapConfirmed(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ for (int i = 0; i < mListeners.length; i++) {
+ if (mListeners[i].onDoubleTap(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public boolean onDoubleTapEvent(MotionEvent e) {
+ for (int i = 0; i < mListeners.length; i++) {
+ if (mListeners[i].onDoubleTapEvent(e)) {
+ return true;
+ }
+ }
+ return false;
+ }
}
}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
index aabec9a..d9f2261 100644
--- a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
@@ -65,6 +65,35 @@
}
@Test
+ public void mouseClick_StartsSelectionMode() {
+ click(7);
+ assertSelection(7);
+ }
+
+ @Test
+ public void mouseClick_ShiftClickExtendsSelection() {
+ click(7);
+ shiftClick(11);
+ assertRangeSelection(7, 11);
+ }
+
+ @Test
+ public void mouseClick_NoPosition_ClearsSelection() {
+ mManager.onLongPress(7);
+ click(11);
+ click(RecyclerView.NO_POSITION);
+ assertSelection();
+ }
+
+ @Test
+ public void setSelectionFocusBegin() {
+ mManager.setItemSelected(7, true);
+ mManager.setSelectionFocusBegin(7);
+ shiftClick(11);
+ assertRangeSelection(7, 11);
+ }
+
+ @Test
public void longPress_StartsSelectionMode() {
mManager.onLongPress(7);
assertSelection(7);
@@ -78,63 +107,57 @@
}
@Test
- public void singleTapUp_DoesNotSelectBeforeLongPress() {
- mManager.onSingleTapUp(99, 0);
- assertSelection();
- }
-
- @Test
public void singleTapUp_UnselectsSelectedItem() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(7, 0);
+ tap(7);
assertSelection();
}
@Test
- public void singleTapUp_NoPositionClearsSelection() {
+ public void singleTapUp_NoPosition_ClearsSelection() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(11, 0);
- mManager.onSingleTapUp(RecyclerView.NO_POSITION, 0);
+ tap(11);
+ tap(RecyclerView.NO_POSITION);
assertSelection();
}
@Test
public void singleTapUp_ExtendsSelection() {
mManager.onLongPress(99);
- mManager.onSingleTapUp(7, 0);
- mManager.onSingleTapUp(13, 0);
- mManager.onSingleTapUp(129899, 0);
+ tap(7);
+ tap(13);
+ tap(129899);
assertSelection(7, 99, 13, 129899);
}
@Test
public void singleTapUp_ShiftCreatesRangeSelection() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+ shiftTap(17);
assertRangeSelection(7, 17);
}
@Test
public void singleTapUp_ShiftCreatesRangeSeletion_Backwards() {
mManager.onLongPress(17);
- mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
+ shiftTap(7);
assertRangeSelection(7, 17);
}
@Test
public void singleTapUp_SecondShiftClickExtendsSelection() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
- mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
+ shiftTap(11);
+ shiftTap(17);
assertRangeSelection(7, 17);
}
@Test
public void singleTapUp_MultipleContiguousRangesSelected() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
- mManager.onSingleTapUp(20, 0);
- mManager.onSingleTapUp(25, KeyEvent.META_SHIFT_ON);
+ shiftTap(11);
+ tap(20);
+ shiftTap(25);
assertRangeSelected(7, 11);
assertRangeSelected(20, 25);
assertSelectionSize(11);
@@ -143,16 +166,16 @@
@Test
public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
- mManager.onSingleTapUp(10, KeyEvent.META_SHIFT_ON);
+ shiftTap(17);
+ shiftTap(10);
assertRangeSelection(7, 10);
}
@Test
public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick_Backwards() {
mManager.onLongPress(17);
- mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
- mManager.onSingleTapUp(14, KeyEvent.META_SHIFT_ON);
+ shiftTap(7);
+ shiftTap(14);
assertRangeSelection(14, 17);
}
@@ -160,11 +183,27 @@
@Test
public void singleTapUp_ShiftReversesSelectionDirection() {
mManager.onLongPress(7);
- mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
- mManager.onSingleTapUp(0, KeyEvent.META_SHIFT_ON);
+ shiftTap(17);
+ shiftTap(0);
assertRangeSelection(0, 7);
}
+ private void tap(int position) {
+ mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
+ }
+
+ private void shiftTap(int position) {
+ mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_FINGER);
+ }
+
+ private void click(int position) {
+ mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
+ }
+
+ private void shiftClick(int position) {
+ mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_MOUSE);
+ }
+
private void assertSelected(int... expected) {
for (int i = 0; i < expected.length; i++) {
Selection selection = mManager.getSelection();