Update DirectoryFragment to use RecyclerView.

Add MultiSelectMaanger class to manager selection on a RecyclerView instance.
There are several outstanding issues that still need to be addressed
surrounding Grid mode as the GridLayout manager doesn't support
automatic column count calculation.
Also, we're missing the puddle effect on touch...
And probably other stuff. But it all *mostly* works.
Oh, also. Footers are currently commented out.
Add traditional unit tests for MultiSelectManager.

BUG: 22225617
Change-Id: I3cd26a10683f42053556d463a5d2f0d2a0bbde84
This commit is contained in:
Steve McKay
2015-06-11 10:10:49 -07:00
parent 4dc07b4d89
commit 4b3a13c1d0
8 changed files with 1248 additions and 314 deletions

View File

@@ -185,6 +185,11 @@ public class SparseBooleanArray implements Cloneable {
mValues[index] = value;
}
/** @hide */
public void setKeyAt(int index, int key) {
mKeys[index] = key;
}
/**
* Returns the index for which {@link #keyAt} would return the
* specified key, or a negative number if the specified

View File

@@ -5,7 +5,9 @@ LOCAL_MODULE_TAGS := optional
LOCAL_SRC_FILES := $(call all-java-files-under, src)
LOCAL_STATIC_JAVA_LIBRARIES := android-support-v4 guava
LOCAL_STATIC_JAVA_LIBRARIES := android-support-v4 \
android-support-v7-recyclerview \
guava
LOCAL_PACKAGE_NAME := DocumentsUI
LOCAL_CERTIFICATE := platform

View File

@@ -28,24 +28,23 @@
android:visibility="gone"
style="@android:style/TextAppearance.Material.Subhead" />
<!-- The 'list' view is still used for RecentsCreateFragment -->
<ListView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<GridView
android:id="@+id/grid"
<android.support.v7.widget.RecyclerView
android:id="@+id/recyclerView"
android:scrollbars="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="@dimen/grid_padding_horiz"
android:paddingEnd="@dimen/grid_padding_horiz"
android:paddingTop="@dimen/grid_padding_vert"
android:paddingBottom="@dimen/grid_padding_vert"
android:horizontalSpacing="@dimen/grid_item_padding"
android:verticalSpacing="@dimen/grid_item_padding"
android:clipToPadding="false"
android:scrollbarStyle="outsideOverlay"
android:drawSelectorOnTop="true"
android:visibility="gone" />
android:drawSelectorOnTop="true" />
</com.android.documentsui.DirectoryView>

View File

@@ -17,7 +17,8 @@
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="@dimen/grid_item_height"
android:background="@color/item_doc_grid_background">
android:background="@color/item_doc_grid_background"
android:padding="@dimen/grid_item_padding">
<ImageView
android:id="@+id/icon_thumb"

View File

@@ -0,0 +1,548 @@
/*
* 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.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.Adapter;
import android.support.v7.widget.RecyclerView.AdapterDataObserver;
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.GestureDetector;
import android.view.GestureDetector.OnGestureListener;
import android.view.MotionEvent;
import android.view.View;
import com.google.common.annotations.VisibleForTesting;
import java.util.ArrayList;
import java.util.List;
/**
* MultiSelectManager adds traditional multi-item selection support to RecyclerView.
*/
public final class MultiSelectManager {
private static final String TAG = "MultiSelectManager";
private static final boolean DEBUG = false;
private final Selection mSelection = new Selection();
// Only created when selection is cleared.
private Selection mIntermediateSelection;
private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
private Adapter<?> mAdapter;
private RecyclerViewHelper mHelper;
/**
* @param recyclerView
* @param gestureDelegate Option delage gesture listener.
*/
public MultiSelectManager(final RecyclerView recyclerView, OnGestureListener gestureDelegate) {
this(
recyclerView.getAdapter(),
new RecyclerViewHelper() {
@Override
public int findEventPosition(MotionEvent e) {
View view = recyclerView.findChildViewUnder(e.getX(), e.getY());
return view != null
? recyclerView.getChildAdapterPosition(view)
: RecyclerView.NO_POSITION;
}
});
GestureDetector.SimpleOnGestureListener listener =
new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onSingleTapUp(MotionEvent e) {
return MultiSelectManager.this.onSingleTapUp(e);
}
@Override
public void onLongPress(MotionEvent e) {
MultiSelectManager.this.onLongPress(e);
}
};
final GestureDetector detector = new GestureDetector(
recyclerView.getContext(),
gestureDelegate == null
? listener
: new CompositeOnGestureListener(listener, gestureDelegate));
recyclerView.addOnItemTouchListener(
new RecyclerView.OnItemTouchListener() {
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
detector.onTouchEvent(e);
return false;
}
public void onTouchEvent(RecyclerView rv, MotionEvent e) {}
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
});
}
MultiSelectManager(Adapter<?> adapter, RecyclerViewHelper helper) {
if (adapter == null) {
throw new IllegalArgumentException("Adapter cannot be null.");
}
if (helper == null) {
throw new IllegalArgumentException("Helper cannot be null.");
}
mHelper = helper;
mAdapter = adapter;
mAdapter.registerAdapterDataObserver(
new AdapterDataObserver() {
@Override
public void onChanged() {
mSelection.clear();
}
@Override
public void onItemRangeChanged(
int positionStart, int itemCount, Object payload) {
// No change in position. Ignoring.
}
@Override
public void onItemRangeInserted(int positionStart, int itemCount) {
mSelection.expand(positionStart, itemCount);
}
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
mSelection.collapse(positionStart, itemCount);
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
throw new UnsupportedOperationException();
}
});
}
public void addCallback(MultiSelectManager.Callback callback) {
mCallbacks.add(callback);
}
/**
* Returns a Selection object that provides a live view
* on the current selection. Callers wishing to get
*
* @see #getSelectionSnapshot() on how to get a snapshot
* of the selection that will not reflect future changes
* to selection.
*
* @return The current seleciton.
*/
public Selection getSelection() {
return mSelection;
}
/**
* Updates {@code dest} to reflect the current selection.
* @param dest
*
* @return The Selection instance passed in, for convenience.
*/
public Selection getSelection(Selection dest) {
dest.copyFrom(mSelection);
return dest;
}
public void selectItem(int position) {
selectItems(position, 1);
}
public void selectItems(int position, int length) {
for (int i = position; i < position + length; i++) {
mSelection.add(i);
}
}
public void clearSelection() {
if (DEBUG) Log.d(TAG, "Clearing selection");
if (mIntermediateSelection == null) {
mIntermediateSelection = new Selection();
}
getSelection(mIntermediateSelection);
mSelection.clear();
for (int i = 0; i < mIntermediateSelection.size(); i++) {
int position = mIntermediateSelection.get(i);
mAdapter.notifyItemChanged(position);
notifyItemStateChanged(position, false);
}
}
public boolean onSingleTapUp(MotionEvent e) {
if (DEBUG) Log.d(TAG, "Handling tap event.");
if (mSelection.size() == 0) {
return false;
}
return onSingleTapUp(mHelper.findEventPosition(e));
}
/**
* @param position
* @hide
*/
@VisibleForTesting
boolean onSingleTapUp(int position) {
if (mSelection.size() == 0) {
return false;
}
if (position == RecyclerView.NO_POSITION) {
if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
return false;
}
toggleSelection(position);
return true;
}
public void onLongPress(MotionEvent e) {
if (DEBUG) Log.d(TAG, "Handling long press event.");
int position = mHelper.findEventPosition(e);
if (position == RecyclerView.NO_POSITION) {
if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
}
toggleSelection(position);
}
/**
* @param position
* @hide
*/
@VisibleForTesting
void onLongPress(int position) {
if (position == RecyclerView.NO_POSITION) {
if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
}
toggleSelection(position);
}
private void toggleSelection(int position) {
// Position may be special "no position" during certain
// transitional phases. If so, skip handling of the event.
if (position == RecyclerView.NO_POSITION) {
if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
return;
}
if (DEBUG) Log.d(TAG, "Handling long press on view: " + position);
boolean nextState = !mSelection.contains(position);
if (notifyBeforeItemStateChange(position, nextState)) {
boolean selected = mSelection.flip(position);
notifyItemStateChanged(position, selected);
mAdapter.notifyItemChanged(position);
if (DEBUG) Log.d(TAG, "Selection after long press: " + mSelection);
} else {
Log.i(TAG, "Selection change cancelled by listener.");
}
}
private boolean notifyBeforeItemStateChange(int position, boolean nextState) {
int lastListener = mCallbacks.size() - 1;
for (int i = lastListener; i > -1; i--) {
if (!mCallbacks.get(i).onBeforeItemStateChange(position, nextState)) {
return false;
}
}
return true;
}
/**
* Notifies registered listeners when a selection changes.
*
* @param position
* @param selected
*/
private void notifyItemStateChanged(int position, boolean selected) {
int lastListener = mCallbacks.size() - 1;
for (int i = lastListener; i > -1; i--) {
mCallbacks.get(i).onItemStateChanged(position, selected);
}
}
/**
* Object representing the current selection.
*/
// NOTE: Much of the code in this class was copious swiped from
// ArrayUtils, GrowingArrayUtils, and SparseBooleanArray.
public static final class Selection {
private SparseBooleanArray mSelection;
public Selection() {
mSelection = new SparseBooleanArray();
}
/**
* @param position
* @return true if the position is currently selected.
*/
public boolean contains(int position) {
return mSelection.get(position);
}
/**
* Useful for iterating over selection. Please note that
* iteration should be done over a copy of the selection,
* not the live selection.
*
* @see #copyTo(MultiSelectManager.Selection)
*
* @param index
* @return the position value stored at specified index.
*/
public int get(int index) {
return mSelection.keyAt(index);
}
/**
* @return size of the selection.
*/
public int size() {
return mSelection.size();
}
private boolean flip(int position) {
if (contains(position)) {
remove(position);
return false;
} else {
add(position);
return true;
}
}
/** @hide */
@VisibleForTesting
void add(int position) {
mSelection.put(position, true);
}
/** @hide */
@VisibleForTesting
void remove(int position) {
mSelection.delete(position);
}
/**
* Adjusts the selection range to reflect the existence of newly inserted values at
* the specified positions. This has the effect of adjusting all existing selected
* positions within the specified range accordingly.
*
* @param startPosition
* @param count
* @hide
*/
@VisibleForTesting
void expand(int startPosition, int count) {
if (startPosition < 0) {
throw new IllegalArgumentException("startPosition must be non-negative");
}
if (count < 1) {
throw new IllegalArgumentException("countMust be greater than 0");
}
for (int i = 0; i < mSelection.size(); i++) {
int itemPosition = mSelection.keyAt(i);
if (itemPosition >= startPosition) {
mSelection.setKeyAt(i, itemPosition + count);
}
}
}
/**
* Adjusts the selection range to reflect the removal specified positions. This has
* the effect of adjusting all existing selected positions within the specified range
* accordingly.
*
* @param startPosition
* @param count The length of the range to collapse. Must be greater than 0.
* @hide
*/
@VisibleForTesting
void collapse(int startPosition, int count) {
if (startPosition < 0) {
throw new IllegalArgumentException("startPosition must be non-negative");
}
if (count < 1) {
throw new IllegalArgumentException("countMust be greater than 0");
}
int endPosition = startPosition + count - 1;
SparseBooleanArray newSelection = new SparseBooleanArray();
for (int i = 0; i < mSelection.size(); i++) {
int itemPosition = mSelection.keyAt(i);
if (itemPosition < startPosition) {
newSelection.append(itemPosition, true);
} else if (itemPosition > endPosition) {
newSelection.append(itemPosition - count, true);
}
}
mSelection = newSelection;
}
/** @hide */
@VisibleForTesting
void clear() {
mSelection.clear();
}
/** @hide */
@VisibleForTesting
void copyFrom(Selection source) {
mSelection = source.mSelection.clone();
}
@Override
public String toString() {
if (size() <= 0) {
return "size=0, items=[]";
}
StringBuilder buffer = new StringBuilder(mSelection.size() * 28);
buffer.append(String.format("{size=%d, ", mSelection.size()));
buffer.append("items=[");
for (int i=0; i < mSelection.size(); i++) {
if (i > 0) {
buffer.append(", ");
}
buffer.append(mSelection.keyAt(i));
}
buffer.append("]}");
return buffer.toString();
}
@Override
public boolean equals(Object that) {
if (this == that) {
return true;
}
if (that instanceof Selection) {
Selection other = (Selection) that;
for (int i = 0; i < mSelection.size(); i++) {
if (mSelection.keyAt(i) != other.mSelection.keyAt(i)) {
return false;
}
}
return true;
}
return false;
}
}
interface RecyclerViewHelper {
int findEventPosition(MotionEvent e);
}
public interface Callback {
/**
* Called when an item is selected or unselected while in selection mode.
*
* @param position Adapter position of the item that was checked or unchecked
* @param selected <code>true</code> if the item is now selected, <code>false</code>
* if the item is now unselected.
*/
public void onItemStateChanged(int position, boolean selected);
/**
* @param position
* @param selected
* @return false to cancel the change.
*/
public boolean onBeforeItemStateChange(int position, boolean selected);
}
/**
* A composite {@code OnGestureDetector} that allows us to delegate unhandled
* events to other interested parties.
*/
private static final class CompositeOnGestureListener implements OnGestureListener {
private OnGestureListener[] mListeners;
public CompositeOnGestureListener(OnGestureListener... listeners) {
mListeners = listeners;
}
@Override
public boolean onDown(MotionEvent e) {
for (int i = 0; i < mListeners.length; i++) {
if (mListeners[i].onDown(e)) {
return true;
}
}
return false;
}
@Override
public void onShowPress(MotionEvent e) {
for (int i = 0; i < mListeners.length; i++) {
mListeners[i].onShowPress(e);
}
}
@Override
public boolean onSingleTapUp(MotionEvent e) {
for (int i = 0; i < mListeners.length; i++) {
if (mListeners[i].onSingleTapUp(e)) {
return true;
}
}
return false;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
for (int i = 0; i < mListeners.length; i++) {
if (mListeners[i].onScroll(e1, e2, distanceX, distanceY)) {
return true;
}
}
return false;
}
@Override
public void onLongPress(MotionEvent e) {
for (int i = 0; i < mListeners.length; i++) {
mListeners[i].onLongPress(e);
}
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
for (int i = 0; i < mListeners.length; i++) {
if (mListeners[i].onFling(e1, e2, velocityX, velocityY)) {
return true;
}
}
return false;
}
}
}

View File

@@ -0,0 +1,176 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.documentsui;
import static org.junit.Assert.*;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import com.android.documentsui.MultiSelectManager.RecyclerViewHelper;
import com.android.documentsui.MultiSelectManager.Selection;
import org.junit.Before;
import org.junit.Test;
import org.mockito.Mockito;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class MultiSelectManagerTest {
private static final List<String> items;
static {
items = new ArrayList<String>();
items.add("aaa");
items.add("bbb");
items.add("ccc");
items.add("111");
items.add("222");
items.add("333");
}
private MultiSelectManager mManager;
private TestAdapter mAdapter;
private TestCallback mCallback;
private EventHelper mEventHelper;
@Before
public void setUp() throws Exception {
mAdapter = new TestAdapter(items);
mCallback = new TestCallback();
mEventHelper = new EventHelper();
mManager = new MultiSelectManager(mAdapter, mEventHelper);
mManager.addCallback(mCallback);
}
@Test
public void singleTapDoesNotSelectBeforeLongPress() {
mManager.onSingleTapUp(99);
assertSelection();
}
@Test
public void longPressStartsSelectionMode() {
mManager.onLongPress(7);
assertSelection(7);
}
@Test
public void secondLongPressExtendsSelection() {
mManager.onLongPress(7);
mManager.onLongPress(99);
assertSelection(7, 99);
}
@Test
public void singleTapUnselectedLastItem() {
mManager.onLongPress(7);
mManager.onSingleTapUp(7);
assertSelection();
}
@Test
public void singleTapUpExtendsSelection() {
mManager.onLongPress(99);
mManager.onSingleTapUp(7);
mManager.onSingleTapUp(13);
mManager.onSingleTapUp(129899);
assertSelection(7, 99, 13, 129899);
}
private void assertSelected(int... expected) {
for (int i = 0; i < expected.length; i++) {
Selection selection = mManager.getSelection();
String err = String.format(
"Selection %s does not contain %d", selection, expected[i]);
assertTrue(err, selection.contains(expected[i]));
}
}
private void assertSelection(int... expected) {
assertSelectionSize(expected.length);
assertSelected(expected);
}
private void assertSelectionSize(int expected) {
Selection selection = mManager.getSelection();
assertEquals(expected, selection.size());
}
private static final class EventHelper implements RecyclerViewHelper {
@Override
public int findEventPosition(MotionEvent e) {
throw new UnsupportedOperationException();
}
}
private static final class TestCallback implements MultiSelectManager.Callback {
Set<Integer> ignored = new HashSet<>();
private int mLastChangedPosition;
private boolean mLastChangedSelected;
@Override
public void onItemStateChanged(int position, boolean selected) {
this.mLastChangedPosition = position;
this.mLastChangedSelected = selected;
}
@Override
public boolean onBeforeItemStateChange(int position, boolean selected) {
return !ignored.contains(position);
}
}
private static final class TestHolder extends RecyclerView.ViewHolder {
// each data item is just a string in this case
public View view;
public String string;
public TestHolder(View view) {
super(view);
this.view = view;
}
}
private static final class TestAdapter extends RecyclerView.Adapter<TestHolder> {
private List<String> mItems;
public TestAdapter(List<String> items) {
mItems = items;
}
@Override
public TestHolder onCreateViewHolder(ViewGroup parent, int viewType) {
return new TestHolder(Mockito.mock(ViewGroup.class));
}
@Override
public void onBindViewHolder(TestHolder holder, int position) {}
@Override
public int getItemCount() {
return mItems.size();
}
}
}

View File

@@ -0,0 +1,149 @@
/*
* Copyright (C) 2015 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.documentsui;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import com.android.documentsui.MultiSelectManager.Selection;
import org.junit.Before;
import org.junit.Test;
public class MultiSelectManager_SelectionTest {
private Selection selection;
@Before
public void setUp() throws Exception {
selection = new Selection();
selection.add(3);
selection.add(5);
selection.add(9);
}
@Test
public void add() {
// We added in setUp.
assertEquals(3, selection.size());
assertContains(3);
assertContains(5);
assertContains(9);
}
@Test
public void remove() {
selection.remove(3);
selection.remove(5);
assertEquals(1, selection.size());
assertContains(9);
}
@Test
public void clear() {
selection.clear();
assertEquals(0, selection.size());
}
@Test
public void sizeAndGet() {
Selection other = new Selection();
for (int i = 0; i < selection.size(); i++) {
other.add(selection.get(i));
}
assertEquals(selection.size(), other.size());
}
@Test
public void equalsSelf() {
assertEquals(selection, selection);
}
@Test
public void equalsOther() {
Selection other = new Selection();
other.add(3);
other.add(5);
other.add(9);
assertEquals(selection, other);
}
@Test
public void expandBefore() {
selection.expand(2, 10);
assertEquals(3, selection.size());
assertContains(13);
assertContains(15);
assertContains(19);
}
@Test
public void expandAfter() {
selection.expand(10, 10);
assertEquals(3, selection.size());
assertContains(3);
assertContains(5);
assertContains(9);
}
@Test
public void expandSplit() {
selection.expand(5, 10);
assertEquals(3, selection.size());
assertContains(3);
assertContains(15);
assertContains(19);
}
@Test
public void expandEncompased() {
selection.expand(2, 10);
assertEquals(3, selection.size());
assertContains(13);
assertContains(15);
assertContains(19);
}
@Test
public void collapseBefore() {
selection.collapse(0, 2);
assertEquals(3, selection.size());
assertContains(1);
assertContains(3);
assertContains(7);
}
@Test
public void collapseAfter() {
selection.collapse(10, 10);
assertEquals(3, selection.size());
assertContains(3);
assertContains(5);
assertContains(9);
}
@Test
public void collapseAcross() {
selection.collapse(0, 10);
assertEquals(0, selection.size());
}
private void assertContains(int i) {
String err = String.format("Selection %s does not contain %d", selection, i);
assertTrue(err, selection.contains(i));
}
}