From 4b3a13c1d05115a197ccddcee7da1f48dcd460f3 Mon Sep 17 00:00:00 2001 From: Steve McKay Date: Thu, 11 Jun 2015 10:10:49 -0700 Subject: [PATCH] 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 --- .../java/android/util/SparseBooleanArray.java | 5 + packages/DocumentsUI/Android.mk | 4 +- .../res/layout/fragment_directory.xml | 11 +- .../DocumentsUI/res/layout/item_doc_grid.xml | 3 +- .../documentsui/DirectoryFragment.java | 666 ++++++++++-------- .../documentsui/MultiSelectManager.java | 548 ++++++++++++++ .../documentsui/MultiSelectManagerTest.java | 176 +++++ .../MultiSelectManager_SelectionTest.java | 149 ++++ 8 files changed, 1248 insertions(+), 314 deletions(-) create mode 100644 packages/DocumentsUI/src/com/android/documentsui/MultiSelectManager.java create mode 100644 packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManagerTest.java create mode 100644 packages/DocumentsUI/tests/src/com/android/documentsui/MultiSelectManager_SelectionTest.java diff --git a/core/java/android/util/SparseBooleanArray.java b/core/java/android/util/SparseBooleanArray.java index e293b1f652cd1..8fd8226da6ac3 100644 --- a/core/java/android/util/SparseBooleanArray.java +++ b/core/java/android/util/SparseBooleanArray.java @@ -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 diff --git a/packages/DocumentsUI/Android.mk b/packages/DocumentsUI/Android.mk index 67d8ab6d8ce25..3430bb4797172 100644 --- a/packages/DocumentsUI/Android.mk +++ b/packages/DocumentsUI/Android.mk @@ -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 diff --git a/packages/DocumentsUI/res/layout/fragment_directory.xml b/packages/DocumentsUI/res/layout/fragment_directory.xml index 471783928f9a6..7ae7eeacf764b 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" /> + - + android:drawSelectorOnTop="true" /> diff --git a/packages/DocumentsUI/res/layout/item_doc_grid.xml b/packages/DocumentsUI/res/layout/item_doc_grid.xml index d62d050b91457..5d8a834ad310f 100644 --- a/packages/DocumentsUI/res/layout/item_doc_grid.xml +++ b/packages/DocumentsUI/res/layout/item_doc_grid.xml @@ -17,7 +17,8 @@ + android:background="@color/item_doc_grid_background" + android:padding="@dimen/grid_item_padding"> 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 @@ public class DirectoryFragment extends Fragment { 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 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { @Override public void onLoaderReset(Loader 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 @@ public class DirectoryFragment extends Fragment { 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 class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { 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 @@ public class DirectoryFragment extends Fragment { } @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 @@ public class DirectoryFragment extends Fragment { 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 docs) { @@ -631,7 +671,7 @@ public class DirectoryFragment extends Fragment { }.execute(selected); } - private void shareDocuments(final SparseBooleanArray selected) { + private void shareDocuments(final Selection selected) { new GetDocumentsTask() { @Override void onDocumentsReady(List docs) { @@ -679,7 +719,7 @@ public class DirectoryFragment extends Fragment { }.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 @@ public class DirectoryFragment extends Fragment { }.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 @@ public class DirectoryFragment extends Fragment { } } - 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 { + + private final Context mContext; + private final LayoutInflater mInflater; + // TODO: Bring back support for footers. + private final List