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
diff --git a/packages/DocumentsUI/Android.mk b/packages/DocumentsUI/Android.mk
index 67d8ab6..3430bb47 100644
--- a/packages/DocumentsUI/Android.mk
+++ b/packages/DocumentsUI/Android.mk
@@ -5,7 +5,9 @@
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
diff --git a/packages/DocumentsUI/res/layout/fragment_directory.xml b/packages/DocumentsUI/res/layout/fragment_directory.xml
index 4717839..7ae7eea 100644
--- a/packages/DocumentsUI/res/layout/fragment_directory.xml
+++ b/packages/DocumentsUI/res/layout/fragment_directory.xml
@@ -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>
diff --git a/packages/DocumentsUI/res/layout/item_doc_grid.xml b/packages/DocumentsUI/res/layout/item_doc_grid.xml
index d62d050..5d8a834 100644
--- a/packages/DocumentsUI/res/layout/item_doc_grid.xml
+++ b/packages/DocumentsUI/res/layout/item_doc_grid.xml
@@ -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"
diff --git a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
index e260127..0554601 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/DirectoryFragment.java
@@ -28,6 +28,7 @@
import static com.android.documentsui.model.DocumentInfo.getCursorInt;
import static com.android.documentsui.model.DocumentInfo.getCursorLong;
import static com.android.documentsui.model.DocumentInfo.getCursorString;
+import static com.android.internal.util.Preconditions.checkNotNull;
import android.annotation.NonNull;
import android.app.Activity;
@@ -37,7 +38,6 @@
import android.app.FragmentTransaction;
import android.app.LoaderManager.LoaderCallbacks;
import android.content.ClipData;
-import android.content.ClipboardManager;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentValues;
@@ -50,7 +50,6 @@
import android.graphics.Canvas;
import android.graphics.Point;
import android.graphics.drawable.Drawable;
-import android.graphics.drawable.InsetDrawable;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
@@ -62,33 +61,36 @@
import android.os.SystemProperties;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
+import android.support.v7.widget.GridLayoutManager;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+import android.support.v7.widget.RecyclerView.LayoutManager;
+import android.support.v7.widget.RecyclerView.OnItemTouchListener;
+import android.support.v7.widget.RecyclerView.RecyclerListener;
+import android.support.v7.widget.RecyclerView.ViewHolder;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.format.Formatter;
import android.text.format.Time;
import android.util.Log;
import android.util.SparseArray;
-import android.util.SparseBooleanArray;
import android.view.ActionMode;
import android.view.DragEvent;
+import android.view.GestureDetector;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
+import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.AbsListView;
-import android.widget.AbsListView.MultiChoiceModeListener;
-import android.widget.AbsListView.RecyclerListener;
-import android.widget.AdapterView;
-import android.widget.AdapterView.OnItemClickListener;
-import android.widget.BaseAdapter;
-import android.widget.GridView;
+import android.view.ViewParent;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.android.documentsui.BaseActivity.State;
+import com.android.documentsui.MultiSelectManager.Selection;
import com.android.documentsui.ProviderExecutor.Preemptable;
import com.android.documentsui.RecentsProvider.StateColumns;
import com.android.documentsui.model.DocumentInfo;
@@ -98,8 +100,6 @@
import com.google.android.collect.Lists;
-import libcore.io.IoUtils;
-
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -109,12 +109,6 @@
*/
public class DirectoryFragment extends Fragment {
- private View mEmptyView;
- private ListView mListView;
- private GridView mGridView;
-
- private AbsListView mCurrentView;
-
public static final int TYPE_NORMAL = 1;
public static final int TYPE_SEARCH = 2;
public static final int TYPE_RECENT_OPEN = 3;
@@ -126,21 +120,8 @@
public static final int REQUEST_COPY_DESTINATION = 1;
- private int mType = TYPE_NORMAL;
- private String mStateKey;
-
- private int mLastMode = MODE_UNKNOWN;
- private int mLastSortOrder = SORT_ORDER_UNKNOWN;
- private boolean mLastShowSize = false;
-
- private boolean mHideGridTitles = false;
-
- private boolean mSvelteRecents;
- private Point mThumbSize;
-
- private DocumentsAdapter mAdapter;
- private LoaderCallbacks<DirectoryResult> mCallbacks;
-
+ private static final int LOADER_ID = 42;
+ private static final boolean DEBUG = false;
private static final boolean DEBUG_ENABLE_DND = false;
private static final String EXTRA_TYPE = "type";
@@ -149,11 +130,28 @@
private static final String EXTRA_QUERY = "query";
private static final String EXTRA_IGNORE_STATE = "ignoreState";
- private final int mLoaderId = 42;
+ private final Handler mHandler = new Handler(Looper.getMainLooper());
+ private View mEmptyView;
+ private RecyclerView mRecView;
+
+ private int mType = TYPE_NORMAL;
+ private String mStateKey;
+
+ private int mLastMode = MODE_UNKNOWN;
+ private int mLastSortOrder = SORT_ORDER_UNKNOWN;
+ private boolean mLastShowSize;
+ private boolean mHideGridTitles;
+ private boolean mSvelteRecents;
+ private Point mThumbSize;
+ private DocumentsAdapter mAdapter;
+ private LoaderCallbacks<DirectoryResult> mCallbacks;
private FragmentTuner mFragmentTuner;
private DocumentClipper mClipper;
- private final Handler mHandler = new Handler(Looper.getMainLooper());
+ private MultiSelectManager mSelectionManager;
+ // These are lazily initialized.
+ private LayoutManager mListLayout;
+ private LayoutManager mGridLayout;
public static void showNormal(FragmentManager fm, RootInfo root, DocumentInfo doc, int anim) {
show(fm, TYPE_NORMAL, root, doc, null, anim);
@@ -218,29 +216,18 @@
mEmptyView = view.findViewById(android.R.id.empty);
- mListView = (ListView) view.findViewById(R.id.list);
- mListView.setOnItemClickListener(mItemListener);
- mListView.setMultiChoiceModeListener(mMultiListener);
- mListView.setRecyclerListener(mRecycleListener);
-
- // Indent our list divider to align with text
- final Drawable divider = mListView.getDivider();
- final boolean insetLeft = res.getBoolean(R.bool.list_divider_inset_left);
- final int insetSize = res.getDimensionPixelSize(R.dimen.list_divider_inset);
- if (insetLeft) {
- mListView.setDivider(new InsetDrawable(divider, insetSize, 0, 0, 0));
- } else {
- mListView.setDivider(new InsetDrawable(divider, 0, 0, insetSize, 0));
- }
-
- mGridView = (GridView) view.findViewById(R.id.grid);
- mGridView.setOnItemClickListener(mItemListener);
- mGridView.setMultiChoiceModeListener(mMultiListener);
- mGridView.setRecyclerListener(mRecycleListener);
+ mRecView = (RecyclerView) view.findViewById(R.id.recyclerView);
+ mRecView.setRecyclerListener(
+ new RecyclerListener() {
+ @Override
+ public void onViewRecycled(ViewHolder holder) {
+ cancelThumbnailTask(holder.itemView);
+ }
+ });
+ // TODO: Add a divider between views (which might use RecyclerView.ItemDecoration).
if (DEBUG_ENABLE_DND) {
- setupDragAndDropOnDirectoryView(mListView);
- setupDragAndDropOnDirectoryView(mGridView);
+ setupDragAndDropOnDirectoryView(mRecView);
}
return view;
@@ -251,16 +238,14 @@
super.onDestroyView();
// Cancel any outstanding thumbnail requests
- final ViewGroup target = (mListView.getAdapter() != null) ? mListView : mGridView;
- final int count = target.getChildCount();
+ final int count = mRecView.getChildCount();
for (int i = 0; i < count; i++) {
- final View view = target.getChildAt(i);
- mRecycleListener.onMovedToScrapHeap(view);
+ final View view = mRecView.getChildAt(i);
+ cancelThumbnailTask(view);
}
- // Tear down any selection in progress
- mListView.setChoiceMode(AbsListView.CHOICE_MODE_NONE);
- mGridView.setChoiceMode(AbsListView.CHOICE_MODE_NONE);
+ // Clear any outstanding selection
+ mSelectionManager.clearSelection();
}
@Override
@@ -273,7 +258,20 @@
final RootInfo root = getArguments().getParcelable(EXTRA_ROOT);
final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC);
- mAdapter = new DocumentsAdapter();
+ mAdapter = new DocumentsAdapter(context);
+ mRecView.setAdapter(mAdapter);
+
+ GestureDetector.SimpleOnGestureListener listener =
+ new GestureDetector.SimpleOnGestureListener() {
+ @Override
+ public boolean onSingleTapUp(MotionEvent e) {
+ return DirectoryFragment.this.onSingleTapUp(e);
+ }
+ };
+
+ mSelectionManager = new MultiSelectManager(mRecView, listener);
+ mSelectionManager.addCallback(new SelectionModeListener());
+
mType = getArguments().getInt(EXTRA_TYPE);
mStateKey = buildStateKey(root, doc);
@@ -342,7 +340,7 @@
if (!isAdded()) return;
- mAdapter.swapResult(result);
+ mAdapter.replaceResult(result);
// Push latest state up to UI
// TODO: if mode change was racing with us, don't overwrite it
@@ -365,8 +363,7 @@
if (container != null && !getArguments().getBoolean(EXTRA_IGNORE_STATE, false)) {
getView().restoreHierarchyState(container);
} else if (mLastSortOrder != state.derivedSortOrder) {
- mListView.smoothScrollToPosition(0);
- mGridView.smoothScrollToPosition(0);
+ mRecView.smoothScrollToPosition(0);
}
mLastSortOrder = state.derivedSortOrder;
@@ -374,12 +371,12 @@
@Override
public void onLoaderReset(Loader<DirectoryResult> loader) {
- mAdapter.swapResult(null);
+ mAdapter.replaceResult(null);
}
};
// Kick off loader at least once
- getLoaderManager().restartLoader(mLoaderId, null, mCallbacks);
+ getLoaderManager().restartLoader(LOADER_ID, null, mCallbacks);
updateDisplayState();
}
@@ -402,6 +399,29 @@
data.getIntExtra(CopyService.EXTRA_TRANSFER_MODE, CopyService.TRANSFER_MODE_NONE));
}
+ private int getEventAdapterPosition(MotionEvent e) {
+ View view = mRecView.findChildViewUnder(e.getX(), e.getY());
+ return view != null ? mRecView.getChildAdapterPosition(view) : RecyclerView.NO_POSITION;
+ }
+
+ 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;
+ }
+ }
+
+ return false;
+ }
+
@Override
public void onStop() {
super.onStop();
@@ -426,7 +446,7 @@
public void onUserSortOrderChanged() {
// Sort order change always triggers reload; we'll trigger state change
// on the flip side.
- getLoaderManager().restartLoader(mLoaderId, null, mCallbacks);
+ getLoaderManager().restartLoader(LOADER_ID, null, mCallbacks);
}
public void onUserModeChanged() {
@@ -466,8 +486,7 @@
mLastMode = state.derivedMode;
mLastShowSize = state.showSize;
- mListView.setVisibility(state.derivedMode == MODE_LIST ? View.VISIBLE : View.GONE);
- mGridView.setVisibility(state.derivedMode == MODE_GRID ? View.VISIBLE : View.GONE);
+ updateLayout(state.derivedMode);
final int choiceMode;
if (state.allowMultiple) {
@@ -476,51 +495,104 @@
choiceMode = ListView.CHOICE_MODE_NONE;
}
+ final int thumbSize = getResources().getDimensionPixelSize(R.dimen.icon_size);
+ mThumbSize = new Point(thumbSize, thumbSize);
+ mRecView.setAdapter(mAdapter);
+ }
+
+ /**
+ * Returns a {@code LayoutManager} for {@code mode}, lazily initializing
+ * classes as needed.
+ */
+ private void updateLayout(int mode) {
final int thumbSize;
- if (state.derivedMode == MODE_GRID) {
- thumbSize = getResources().getDimensionPixelSize(R.dimen.grid_width);
- mListView.setAdapter(null);
- mListView.setChoiceMode(ListView.CHOICE_MODE_NONE);
- mGridView.setAdapter(mAdapter);
- mGridView.setColumnWidth(getResources().getDimensionPixelSize(R.dimen.grid_width));
- mGridView.setNumColumns(GridView.AUTO_FIT);
- mGridView.setChoiceMode(choiceMode);
- mCurrentView = mGridView;
- } else if (state.derivedMode == MODE_LIST) {
- thumbSize = getResources().getDimensionPixelSize(R.dimen.icon_size);
- mGridView.setAdapter(null);
- mGridView.setChoiceMode(ListView.CHOICE_MODE_NONE);
- mListView.setAdapter(mAdapter);
- mListView.setChoiceMode(choiceMode);
- mCurrentView = mListView;
- } else {
- throw new IllegalStateException("Unknown state " + state.derivedMode);
+
+ final LayoutManager layout;
+ switch (mode) {
+ case MODE_GRID:
+ if (mGridLayout == null) {
+ // TODO: Determine appropriate column count.
+ mGridLayout = new GridLayoutManager(getContext(), 4);
+ }
+ thumbSize = getResources().getDimensionPixelSize(R.dimen.grid_width);
+ layout = mGridLayout;
+ break;
+ case MODE_LIST:
+ if (mListLayout == null) {
+ mListLayout = new LinearLayoutManager(getContext());
+ }
+ thumbSize = getResources().getDimensionPixelSize(R.dimen.icon_size);
+ layout = mListLayout;
+ break;
+ case MODE_UNKNOWN:
+ default:
+ throw new IllegalArgumentException("Unsupported layout mode: " + mode);
}
+ mRecView.setLayoutManager(layout);
+ // setting layout manager automatically invalidates existing ViewHolders.
mThumbSize = new Point(thumbSize, thumbSize);
}
- private OnItemClickListener mItemListener = new OnItemClickListener() {
+ /**
+ * Manages the integration between our ActionMode and MultiSelectManager, initiating
+ * ActionMode when there is a selection, canceling it when there is no selection,
+ * and clearing selection when action mode is explicitly exited by the user.
+ */
+ private final class SelectionModeListener
+ implements MultiSelectManager.Callback, ActionMode.Callback {
+
+ private Selection mSelected = new Selection();
+ private ActionMode mActionMode;
+
@Override
- public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
- final Cursor cursor = mAdapter.getItem(position);
- if (cursor != null) {
+ public boolean onBeforeItemStateChange(int position, boolean selected) {
+ // Directories and footer items cannot be checked
+ if (selected) {
+ 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 isDocumentEnabled(docMimeType, docFlags);
+ }
+ return true;
+ }
+
+ @Override
+ public void onItemStateChanged(int position, boolean selected) {
+ mSelectionManager.getSelection(mSelected);
+ if (mSelected.size() == 0) {
+ if (DEBUG) Log.d(TAG, "Finishing action mode.");
+ if (mActionMode != null) {
+ mActionMode.finish();
+ }
+ } else {
+ if (DEBUG) Log.d(TAG, "Maybe starting action mode.");
+ if (mActionMode == null) {
+ if (DEBUG) Log.d(TAG, "Yeah. Starting action mode.");
+ mActionMode = getActivity().startActionMode(this);
}
}
- }
- };
- private MultiChoiceModeListener mMultiListener = new MultiChoiceModeListener() {
+ if (mActionMode != null) {
+ mActionMode.setTitle(TextUtils.formatSelectedCount(mSelected.size()));
+ }
+ }
+
+ // Called when the user exits the action mode
+ @Override
+ public void onDestroyActionMode(ActionMode mode) {
+ if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
+ mActionMode = null;
+ // clear selection
+ mSelectionManager.clearSelection();
+ }
+
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.mode_directory, menu);
- mode.setTitle(TextUtils.formatSelectedCount(mCurrentView.getCheckedItemCount()));
- return true;
+ mode.setTitle(TextUtils.formatSelectedCount(mSelectionManager.getSelection().size()));
+ return mSelectionManager.getSelection().size() > 0;
}
@Override
@@ -532,41 +604,39 @@
}
@Override
- public boolean onActionItemClicked(final ActionMode mode, MenuItem item) {
+ public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
- // ListView returns a reference to its internal selection container,
- // which will get cleared when we cancel action mode. So we
- // make a defensive clone here.
- final SparseBooleanArray selected = mCurrentView.getCheckedItemPositions().clone();
+ Selection selection = new Selection();
+ mSelectionManager.getSelection(selection);
final int id = item.getItemId();
if (id == R.id.menu_open) {
- openDocuments(selected);
+ openDocuments(selection);
mode.finish();
return true;
} else if (id == R.id.menu_share) {
- shareDocuments(selected);
+ shareDocuments(selection);
mode.finish();
return true;
} else if (id == R.id.menu_delete) {
- deleteDocuments(selected);
+ deleteDocuments(selection);
mode.finish();
return true;
} else if (id == R.id.menu_copy_to) {
- transferDocuments(selected, CopyService.TRANSFER_MODE_COPY);
+ transferDocuments(selection, CopyService.TRANSFER_MODE_COPY);
mode.finish();
return true;
} else if (id == R.id.menu_move_to) {
- transferDocuments(selected, CopyService.TRANSFER_MODE_MOVE);
+ transferDocuments(selection, CopyService.TRANSFER_MODE_MOVE);
mode.finish();
return true;
} else if (id == R.id.menu_copy_to_clipboard) {
- copySelectionToClipboard(selected);
+ copySelectionToClipboard(selection);
mode.finish();
return true;
@@ -578,50 +648,20 @@
return false;
}
}
+ }
- @Override
- public void onDestroyActionMode(ActionMode mode) {
- // ignored
- }
-
- @Override
- public void onItemCheckedStateChanged(
- ActionMode mode, int position, long id, boolean checked) {
- if (checked) {
- // Directories and footer items cannot be checked
- boolean valid = false;
-
- final Cursor cursor = mAdapter.getItem(position);
- if (cursor != null) {
- final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
- final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
- valid = isDocumentEnabled(docMimeType, docFlags);
- }
-
- if (!valid) {
- mCurrentView.setItemChecked(position, false);
- }
- }
-
- mode.setTitle(TextUtils.formatSelectedCount(mCurrentView.getCheckedItemCount()));
- }
- };
-
- private RecyclerListener mRecycleListener = new RecyclerListener() {
- @Override
- public void onMovedToScrapHeap(View view) {
- final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb);
- if (iconThumb != null) {
- final ThumbnailAsyncTask oldTask = (ThumbnailAsyncTask) iconThumb.getTag();
- if (oldTask != null) {
- oldTask.preempt();
- iconThumb.setTag(null);
- }
+ private static void cancelThumbnailTask(View view) {
+ final ImageView iconThumb = (ImageView) view.findViewById(R.id.icon_thumb);
+ if (iconThumb != null) {
+ final ThumbnailAsyncTask oldTask = (ThumbnailAsyncTask) iconThumb.getTag();
+ if (oldTask != null) {
+ oldTask.preempt();
+ iconThumb.setTag(null);
}
}
- };
+ }
- private void openDocuments(final SparseBooleanArray selected) {
+ private void openDocuments(final Selection selected) {
new GetDocumentsTask() {
@Override
void onDocumentsReady(List<DocumentInfo> docs) {
@@ -631,7 +671,7 @@
}.execute(selected);
}
- private void shareDocuments(final SparseBooleanArray selected) {
+ private void shareDocuments(final Selection selected) {
new GetDocumentsTask() {
@Override
void onDocumentsReady(List<DocumentInfo> docs) {
@@ -679,7 +719,7 @@
}.execute(selected);
}
- private void deleteDocuments(final SparseBooleanArray selected) {
+ private void deleteDocuments(final Selection selected) {
final Context context = getActivity();
final ContentResolver resolver = context.getContentResolver();
@@ -717,7 +757,7 @@
}.execute(selected);
}
- private void transferDocuments(final SparseBooleanArray selected, final int mode) {
+ private void transferDocuments(final Selection selected, final int mode) {
// Pop up a dialog to pick a destination. This is inadequate but works for now.
// TODO: Implement a picker that is to spec.
final Intent intent = new Intent(
@@ -822,13 +862,36 @@
}
}
- private class DocumentsAdapter extends BaseAdapter {
+ // Provide a reference to the views for each data item
+ // Complex data items may need more than one view per item, and
+ // you provide access to all the views for a data item in a view holder
+ private static final class DocumentHolder extends RecyclerView.ViewHolder {
+ // each data item is just a string in this case
+ public View view;
+ public String docId; // The stable document id.
+ public DocumentHolder(View view) {
+ super(view);
+ this.view = view;
+ }
+ }
+
+ private final class DocumentsAdapter extends RecyclerView.Adapter<DocumentHolder> {
+
+ private final Context mContext;
+ private final LayoutInflater mInflater;
+ // TODO: Bring back support for footers.
+ private final List<Footer> mFooters = Lists.newArrayList();
+
private Cursor mCursor;
private int mCursorCount;
- private List<Footer> mFooters = Lists.newArrayList();
+ public DocumentsAdapter(Context context) {
+ mContext = context;
+ mInflater = LayoutInflater.from(context);
+ }
- public void swapResult(DirectoryResult result) {
+ public void replaceResult(DirectoryResult result) {
+ if (DEBUG) Log.i(TAG, "Updating adapter with new result set.");
mCursor = result != null ? result.cursor : null;
mCursorCount = mCursor != null ? mCursor.getCount() : 0;
@@ -864,41 +927,32 @@
}
@Override
- public View getView(int position, View convertView, ViewGroup parent) {
- if (position < mCursorCount) {
- return getDocumentView(position, convertView, parent);
- } else {
- position -= mCursorCount;
- convertView = mFooters.get(position).getView(convertView, parent);
- // Only the view itself is disabled; contents inside shouldn't
- // be dimmed.
- convertView.setEnabled(false);
- return convertView;
+ public DocumentHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ final State state = getDisplayState(DirectoryFragment.this);
+ final LayoutInflater inflater = LayoutInflater.from(getContext());
+ switch (state.derivedMode) {
+ case MODE_GRID:
+ return new DocumentHolder(inflater.inflate(R.layout.item_doc_grid, parent, false));
+ case MODE_LIST:
+ return new DocumentHolder(inflater.inflate(R.layout.item_doc_list, parent, false));
+ case MODE_UNKNOWN:
+ default:
+ throw new IllegalStateException("Unsupported layout mode.");
}
}
- private View getDocumentView(int position, View convertView, ViewGroup parent) {
- final Context context = parent.getContext();
+ @Override
+ public void onBindViewHolder(DocumentHolder holder, int position) {
+
+ final Context context = getContext();
final State state = getDisplayState(DirectoryFragment.this);
-
final DocumentInfo doc = getArguments().getParcelable(EXTRA_DOC);
-
final RootsCache roots = DocumentsApplication.getRootsCache(context);
final ThumbnailCache thumbs = DocumentsApplication.getThumbnailsCache(
context, mThumbSize);
- if (convertView == null) {
- final LayoutInflater inflater = LayoutInflater.from(context);
- if (state.derivedMode == MODE_LIST) {
- convertView = inflater.inflate(R.layout.item_doc_list, parent, false);
- } else if (state.derivedMode == MODE_GRID) {
- convertView = inflater.inflate(R.layout.item_doc_grid, parent, false);
- } else {
- throw new IllegalStateException();
- }
- }
-
final Cursor cursor = getItem(position);
+ checkNotNull(cursor, "Cursor cannot be null.");
final String docAuthority = getCursorString(cursor, RootCursorWrapper.COLUMN_AUTHORITY);
final String docRootId = getCursorString(cursor, RootCursorWrapper.COLUMN_ROOT_ID);
@@ -911,17 +965,21 @@
final String docSummary = getCursorString(cursor, Document.COLUMN_SUMMARY);
final long docSize = getCursorLong(cursor, Document.COLUMN_SIZE);
- final View line1 = convertView.findViewById(R.id.line1);
- final View line2 = convertView.findViewById(R.id.line2);
+ holder.docId = docId;
+ final View itemView = holder.view;
+ itemView.setActivated(mSelectionManager.getSelection().contains(position));
- final ImageView iconMime = (ImageView) convertView.findViewById(R.id.icon_mime);
- final ImageView iconThumb = (ImageView) convertView.findViewById(R.id.icon_thumb);
- final TextView title = (TextView) convertView.findViewById(android.R.id.title);
- final ImageView icon1 = (ImageView) convertView.findViewById(android.R.id.icon1);
- final ImageView icon2 = (ImageView) convertView.findViewById(android.R.id.icon2);
- final TextView summary = (TextView) convertView.findViewById(android.R.id.summary);
- final TextView date = (TextView) convertView.findViewById(R.id.date);
- final TextView size = (TextView) convertView.findViewById(R.id.size);
+ final View line1 = itemView.findViewById(R.id.line1);
+ final View line2 = itemView.findViewById(R.id.line2);
+
+ final ImageView iconMime = (ImageView) itemView.findViewById(R.id.icon_mime);
+ final ImageView iconThumb = (ImageView) itemView.findViewById(R.id.icon_thumb);
+ final TextView title = (TextView) itemView.findViewById(android.R.id.title);
+ final ImageView icon1 = (ImageView) itemView.findViewById(android.R.id.icon1);
+ final ImageView icon2 = (ImageView) itemView.findViewById(android.R.id.icon2);
+ final TextView summary = (TextView) itemView.findViewById(android.R.id.summary);
+ final TextView date = (TextView) itemView.findViewById(R.id.date);
+ final TextView size = (TextView) itemView.findViewById(R.id.size);
final ThumbnailAsyncTask oldTask = (ThumbnailAsyncTask) iconThumb.getTag();
if (oldTask != null) {
@@ -949,6 +1007,7 @@
cacheHit = true;
} else {
iconThumb.setImageDrawable(null);
+ // TODO: Hang this off DocumentHolder?
final ThumbnailAsyncTask task = new ThumbnailAsyncTask(
uri, iconMime, iconThumb, mThumbSize, iconAlpha);
iconThumb.setTag(task);
@@ -967,7 +1026,7 @@
iconThumb.setAlpha(0f);
iconThumb.setImageDrawable(null);
iconMime.setImageDrawable(
- getDocumentIcon(context, docAuthority, docId, docMimeType, docIcon, state));
+ getDocumentIcon(mContext, docAuthority, docId, docMimeType, docIcon, state));
}
boolean hasLine1 = false;
@@ -985,9 +1044,9 @@
// be shown, so this will never block.
final RootInfo root = roots.getRootBlocking(docAuthority, docRootId);
if (state.derivedMode == MODE_GRID) {
- iconDrawable = root.loadGridIcon(context);
+ iconDrawable = root.loadGridIcon(mContext);
} else {
- iconDrawable = root.loadIcon(context);
+ iconDrawable = root.loadIcon(mContext);
}
if (summary != null) {
@@ -1014,7 +1073,7 @@
// hint to remind user they're a directory.
if (Document.MIME_TYPE_DIR.equals(docMimeType) && state.derivedMode == MODE_GRID
&& showThumbnail) {
- iconDrawable = IconUtils.applyTintAttr(context, R.drawable.ic_doc_folder,
+ iconDrawable = IconUtils.applyTintAttr(mContext, R.drawable.ic_doc_folder,
android.R.attr.textColorPrimaryInverse);
}
@@ -1045,7 +1104,7 @@
if (docLastModified == -1) {
date.setText(null);
} else {
- date.setText(formatTime(context, docLastModified));
+ date.setText(formatTime(mContext, docLastModified));
hasLine2 = true;
}
@@ -1054,7 +1113,7 @@
if (Document.MIME_TYPE_DIR.equals(docMimeType) || docSize == -1) {
size.setText(null);
} else {
- size.setText(Formatter.formatFileSize(context, docSize));
+ size.setText(Formatter.formatFileSize(mContext, docSize));
hasLine2 = true;
}
} else {
@@ -1068,7 +1127,7 @@
line2.setVisibility(hasLine2 ? View.VISIBLE : View.GONE);
}
- setEnabledRecursive(convertView, enabled);
+ setEnabledRecursive(itemView, enabled);
iconMime.setAlpha(iconAlpha);
iconThumb.setAlpha(iconAlpha);
@@ -1076,35 +1135,26 @@
if (icon2 != null) icon2.setAlpha(iconAlpha);
if (DEBUG_ENABLE_DND) {
- setupDragAndDropOnDocumentView(convertView, cursor);
+ setupDragAndDropOnDocumentView(itemView, cursor);
}
-
- return convertView;
}
- @Override
- public int getCount() {
- return mCursorCount + mFooters.size();
- }
-
- @Override
- public Cursor getItem(int position) {
+ private Cursor getItem(int position) {
if (position < mCursorCount) {
mCursor.moveToPosition(position);
return mCursor;
- } else {
- return null;
}
+
+ Log.w(TAG, "Returning null cursor for position: " + position);
+ if (DEBUG) Log.d(TAG, "...Adapter size: " + mCursorCount);
+ if (DEBUG) Log.d(TAG, "...Footer size: " + mFooters.size());
+ return null;
}
@Override
- public long getItemId(int position) {
- return position;
- }
-
- @Override
- public int getViewTypeCount() {
- return 4;
+ public int getItemCount() {
+ return mCursorCount;
+ // return mCursorCount + mFooters.size();
}
@Override
@@ -1116,72 +1166,9 @@
return mFooters.get(position).getItemViewType();
}
}
- }
- private static class ThumbnailAsyncTask extends AsyncTask<Uri, Void, Bitmap>
- implements Preemptable {
- private final Uri mUri;
- private final ImageView mIconMime;
- private final ImageView mIconThumb;
- private final Point mThumbSize;
- private final float mTargetAlpha;
- private final CancellationSignal mSignal;
-
- public ThumbnailAsyncTask(Uri uri, ImageView iconMime, ImageView iconThumb, Point thumbSize,
- float targetAlpha) {
- mUri = uri;
- mIconMime = iconMime;
- mIconThumb = iconThumb;
- mThumbSize = thumbSize;
- mTargetAlpha = targetAlpha;
- mSignal = new CancellationSignal();
- }
-
- @Override
- public void preempt() {
- cancel(false);
- mSignal.cancel();
- }
-
- @Override
- protected Bitmap doInBackground(Uri... params) {
- if (isCancelled()) return null;
-
- final Context context = mIconThumb.getContext();
- final ContentResolver resolver = context.getContentResolver();
-
- ContentProviderClient client = null;
- Bitmap result = null;
- try {
- client = DocumentsApplication.acquireUnstableProviderOrThrow(
- resolver, mUri.getAuthority());
- result = DocumentsContract.getDocumentThumbnail(client, mUri, mThumbSize, mSignal);
- if (result != null) {
- final ThumbnailCache thumbs = DocumentsApplication.getThumbnailsCache(
- context, mThumbSize);
- thumbs.put(mUri, result);
- }
- } catch (Exception e) {
- if (!(e instanceof OperationCanceledException)) {
- Log.w(TAG, "Failed to load thumbnail for " + mUri + ": " + e);
- }
- } finally {
- ContentProviderClient.releaseQuietly(client);
- }
- return result;
- }
-
- @Override
- protected void onPostExecute(Bitmap result) {
- if (mIconThumb.getTag() == this && result != null) {
- mIconThumb.setTag(null);
- mIconThumb.setImageBitmap(result);
-
- mIconMime.setAlpha(mTargetAlpha);
- mIconMime.animate().alpha(0f).start();
- mIconThumb.setAlpha(0f);
- mIconThumb.animate().alpha(mTargetAlpha).start();
- }
+ private boolean isEmpty() {
+ return getItemCount() > 0;
}
}
@@ -1260,10 +1247,11 @@
}
private @NonNull List<DocumentInfo> getSelectedDocuments() {
- return getItemsAsDocuments(mCurrentView.getCheckedItemPositions().clone());
+ Selection sel = mSelectionManager.getSelection(new Selection());
+ return getItemsAsDocuments(sel);
}
- private List<DocumentInfo> getItemsAsDocuments(SparseBooleanArray items) {
+ private List<DocumentInfo> getItemsAsDocuments(Selection items) {
if (items == null || items.size() == 0) {
return new ArrayList<>(0);
}
@@ -1271,11 +1259,10 @@
final List<DocumentInfo> docs = new ArrayList<>(items.size());
final int size = items.size();
for (int i = 0; i < size; i++) {
- if (items.valueAt(i)) {
- final Cursor cursor = mAdapter.getItem(items.keyAt(i));
- final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
- docs.add(doc);
- }
+ final Cursor cursor = mAdapter.getItem(items.get(i));
+ checkNotNull(cursor, "Cursor cannot be null.");
+ final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
+ docs.add(doc);
}
return docs;
}
@@ -1298,7 +1285,7 @@
}
private void copyFromClipData(final ClipData clipData, final DocumentInfo destination) {
- Preconditions.checkNotNull(clipData);
+ checkNotNull(clipData);
new AsyncTask<Void, Void, List<DocumentInfo>>() {
@Override
@@ -1357,10 +1344,11 @@
}
void copySelectedToClipboard() {
- copySelectionToClipboard(mCurrentView.getCheckedItemPositions().clone());
+ Selection sel = mSelectionManager.getSelection(new Selection());
+ copySelectionToClipboard(sel);
}
- void copySelectionToClipboard(SparseBooleanArray selected) {
+ void copySelectionToClipboard(Selection items) {
new GetDocumentsTask() {
@Override
void onDocumentsReady(List<DocumentInfo> docs) {
@@ -1371,7 +1359,7 @@
R.plurals.clipboard_files_clipped, docs.size(), docs.size()),
Toast.LENGTH_SHORT).show();
}
- }.execute(selected);
+ }.execute(items);
}
void pasteFromClipboard() {
@@ -1405,14 +1393,11 @@
}
void selectAllFiles() {
- int count = mCurrentView.getCount();
- for (int i = 0; i < count; i++) {
- mCurrentView.setItemChecked(i, true);
- }
+ mSelectionManager.selectItems(0, mAdapter.getItemCount());
updateDisplayState();
}
- private void setupDragAndDropOnDirectoryView(AbsListView view) {
+ private void setupDragAndDropOnDirectoryView(View view) {
// Listen for drops on non-directory items and empty space.
view.setOnDragListener(mOnDragListener);
}
@@ -1449,10 +1434,11 @@
return true;
case DragEvent.ACTION_DROP:
- int dstPosition = mCurrentView.getPositionForView(v);
+ int dstPosition = mRecView.getChildAdapterPosition(v);
DocumentInfo dstDir = null;
if (dstPosition != android.widget.AdapterView.INVALID_POSITION) {
Cursor dstCursor = mAdapter.getItem(dstPosition);
+ checkNotNull(dstCursor, "Cursor cannot be null.");
dstDir = DocumentInfo.fromDirectoryCursor(dstCursor);
// TODO: Do not drop into the directory where the documents came from.
}
@@ -1481,14 +1467,14 @@
};
private List<DocumentInfo> getDraggableDocuments(View currentItemView) {
- final int position = mCurrentView.getPositionForView(currentItemView);
+ int position = mRecView.getChildAdapterPosition(currentItemView);
if (position == android.widget.AdapterView.INVALID_POSITION) {
return Collections.EMPTY_LIST;
}
final List<DocumentInfo> selectedDocs = getSelectedDocuments();
if (!selectedDocs.isEmpty()) {
- if (!mCurrentView.isItemChecked(position)) {
+ if (!mSelectionManager.getSelection().contains(position)) {
// There is a selection that does not include the current item, drag nothing.
return Collections.EMPTY_LIST;
}
@@ -1496,6 +1482,7 @@
}
final Cursor cursor = mAdapter.getItem(position);
+ checkNotNull(cursor, "Cursor cannot be null.");
final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
return Lists.newArrayList(doc);
}
@@ -1519,6 +1506,73 @@
}
}
+ private static class ThumbnailAsyncTask extends AsyncTask<Uri, Void, Bitmap>
+ implements Preemptable {
+ private final Uri mUri;
+ private final ImageView mIconMime;
+ private final ImageView mIconThumb;
+ private final Point mThumbSize;
+ private final float mTargetAlpha;
+ private final CancellationSignal mSignal;
+
+ public ThumbnailAsyncTask(Uri uri, ImageView iconMime, ImageView iconThumb, Point thumbSize,
+ float targetAlpha) {
+ mUri = uri;
+ mIconMime = iconMime;
+ mIconThumb = iconThumb;
+ mThumbSize = thumbSize;
+ mTargetAlpha = targetAlpha;
+ mSignal = new CancellationSignal();
+ }
+
+ @Override
+ public void preempt() {
+ cancel(false);
+ mSignal.cancel();
+ }
+
+ @Override
+ protected Bitmap doInBackground(Uri... params) {
+ if (isCancelled()) return null;
+
+ final Context context = mIconThumb.getContext();
+ final ContentResolver resolver = context.getContentResolver();
+
+ ContentProviderClient client = null;
+ Bitmap result = null;
+ try {
+ client = DocumentsApplication.acquireUnstableProviderOrThrow(
+ resolver, mUri.getAuthority());
+ result = DocumentsContract.getDocumentThumbnail(client, mUri, mThumbSize, mSignal);
+ if (result != null) {
+ final ThumbnailCache thumbs = DocumentsApplication.getThumbnailsCache(
+ context, mThumbSize);
+ thumbs.put(mUri, result);
+ }
+ } catch (Exception e) {
+ if (!(e instanceof OperationCanceledException)) {
+ Log.w(TAG, "Failed to load thumbnail for " + mUri + ": " + e);
+ }
+ } finally {
+ ContentProviderClient.releaseQuietly(client);
+ }
+ return result;
+ }
+
+ @Override
+ protected void onPostExecute(Bitmap result) {
+ if (mIconThumb.getTag() == this && result != null) {
+ mIconThumb.setTag(null);
+ mIconThumb.setImageBitmap(result);
+
+ mIconMime.setAlpha(mTargetAlpha);
+ mIconMime.animate().alpha(0f).start();
+ mIconThumb.setAlpha(0f);
+ mIconThumb.animate().alpha(mTargetAlpha).start();
+ }
+ }
+ }
+
private class DrawableShadowBuilder extends View.DragShadowBuilder {
private final Drawable mShadow;
@@ -1563,9 +1617,9 @@
* of documents (especially large lists) can be pretty expensive.
*/
private abstract class GetDocumentsTask
- extends AsyncTask<SparseBooleanArray, Void, List<DocumentInfo>> {
+ extends AsyncTask<Selection, Void, List<DocumentInfo>> {
@Override
- protected final List<DocumentInfo> doInBackground(SparseBooleanArray... selected) {
+ protected final List<DocumentInfo> doInBackground(Selection... selected) {
return getItemsAsDocuments(selected[0]);
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
new file mode 100644
index 0000000..9e02901
--- /dev/null
+++ b/packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java
@@ -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;
+ }
+ }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
new file mode 100644
index 0000000..57677d3
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java
@@ -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();
+ }
+ }
+}
diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManager_SelectionTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManager_SelectionTest.java
new file mode 100644
index 0000000..125236d
--- /dev/null
+++ b/packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManager_SelectionTest.java
@@ -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));
+ }
+}