Merge "Add shift-selection to DocumentsUI."

This commit is contained in:
Ben Kwa
2015-10-16 22:44:04 +00:00
committed by Android (Google) Code Review
5 changed files with 175 additions and 147 deletions

View File

@@ -466,25 +466,6 @@ abstract class BaseActivity extends Activity {
}
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (DEBUG) Log.d(mTag, "onKeyUp: keycode = " + keyCode);
// TODO: Support for RecentsCreateFragment.
DirectoryFragment fragment = DirectoryFragment.get(getFragmentManager());
if (fragment != null) {
switch (keyCode) {
case KeyEvent.KEYCODE_MOVE_HOME:
fragment.focusFirstFile();
return true;
case KeyEvent.KEYCODE_MOVE_END:
fragment.focusLastFile();
return true;
}
}
return super.onKeyUp(keyCode, event);
}
public void onStackPicked(DocumentStack stack) {
try {
// Update the restored stack to ensure we have freshest data

View File

@@ -17,7 +17,6 @@
package com.android.documentsui;
import static com.android.documentsui.Shared.DEBUG;
import static com.android.documentsui.Shared.TAG;
import static com.android.documentsui.State.ACTION_BROWSE;
import static com.android.documentsui.State.ACTION_CREATE;
import static com.android.documentsui.State.ACTION_MANAGE;
@@ -79,6 +78,7 @@ import android.util.TypedValue;
import android.view.ActionMode;
import android.view.DragEvent;
import android.view.GestureDetector;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
@@ -97,7 +97,6 @@ import com.android.documentsui.RecentsProvider.StateColumns;
import com.android.documentsui.model.DocumentInfo;
import com.android.documentsui.model.DocumentStack;
import com.android.documentsui.model.RootInfo;
import com.google.common.collect.Lists;
import java.util.ArrayList;
@@ -109,6 +108,8 @@ import java.util.List;
*/
public class DirectoryFragment extends Fragment {
public static final String TAG = "DirectoryFragment";
public static final int TYPE_NORMAL = 1;
public static final int TYPE_SEARCH = 2;
public static final int TYPE_RECENT_OPEN = 3;
@@ -130,6 +131,7 @@ public class DirectoryFragment extends Fragment {
private static final String EXTRA_IGNORE_STATE = "ignoreState";
private Model mModel;
private MultiSelectManager mSelectionManager;
private Model.UpdateListener mModelUpdateListener = new ModelUpdateListener();
private View mEmptyView;
@@ -268,7 +270,7 @@ public class DirectoryFragment extends Fragment {
}
// Clear any outstanding selection
mModel.clearSelection();
mSelectionManager.clearSelection();
}
@Override
@@ -299,16 +301,16 @@ public class DirectoryFragment extends Fragment {
// TODO: instead of inserting the view into the constructor, extract listener-creation code
// and set the listener on the view after the fact. Then the view doesn't need to be passed
// into the selection manager which is passed into the model.
MultiSelectManager selMgr= new MultiSelectManager(
// into the selection manager.
mSelectionManager = new MultiSelectManager(
mRecView,
listener,
state.allowMultiple
? MultiSelectManager.MODE_MULTIPLE
: MultiSelectManager.MODE_SINGLE);
selMgr.addCallback(new SelectionModeListener());
mSelectionManager.addCallback(new SelectionModeListener());
mModel = new Model(context, selMgr, mAdapter);
mModel = new Model(context, mAdapter);
mModel.addUpdateListener(mModelUpdateListener);
mType = getArguments().getInt(EXTRA_TYPE);
@@ -430,7 +432,7 @@ public class DirectoryFragment extends Fragment {
}
private boolean onSingleTapUp(MotionEvent e) {
if (Events.isTouchEvent(e) && mModel.getSelection().isEmpty()) {
if (Events.isTouchEvent(e) && mSelectionManager.getSelection().isEmpty()) {
int position = getEventAdapterPosition(e);
if (position != RecyclerView.NO_POSITION) {
return handleViewItem(position);
@@ -458,7 +460,7 @@ public class DirectoryFragment extends Fragment {
if (isDocumentEnabled(docMimeType, docFlags)) {
final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
((BaseActivity) getActivity()).onDocumentPicked(doc, mModel);
mModel.clearSelection();
mSelectionManager.clearSelection();
return true;
}
return false;
@@ -564,7 +566,7 @@ public class DirectoryFragment extends Fragment {
mRecView.setLayoutManager(layout);
// TODO: Once b/23691541 is resolved, use a listener within MultiSelectManager instead of
// imperatively calling this function.
mModel.mSelectionManager.handleLayoutChanged();
mSelectionManager.handleLayoutChanged();
// setting layout manager automatically invalidates existing ViewHolders.
mThumbSize = new Point(thumbSize, thumbSize);
}
@@ -620,7 +622,7 @@ public class DirectoryFragment extends Fragment {
@Override
public void onSelectionChanged() {
mModel.getSelection(mSelected);
mSelectionManager.getSelection(mSelected);
TypedValue color = new TypedValue();
if (mSelected.size() > 0) {
if (DEBUG) Log.d(TAG, "Maybe starting action mode.");
@@ -628,8 +630,7 @@ public class DirectoryFragment extends Fragment {
if (DEBUG) Log.d(TAG, "Yeah. Starting action mode.");
mActionMode = getActivity().startActionMode(this);
}
getActivity().getTheme().resolveAttribute(
R.attr.colorActionMode, color, true);
getActivity().getTheme().resolveAttribute(R.attr.colorActionMode, color, true);
updateActionMenu();
} else {
if (DEBUG) Log.d(TAG, "Finishing action mode.");
@@ -652,16 +653,17 @@ public class DirectoryFragment extends Fragment {
if (DEBUG) Log.d(TAG, "Handling action mode destroyed.");
mActionMode = null;
// clear selection
mModel.clearSelection();
mSelectionManager.clearSelection();
mSelected.clear();
mNoDeleteCount = 0;
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
int size = mSelectionManager.getSelection().size();
mode.getMenuInflater().inflate(R.menu.mode_directory, menu);
mode.setTitle(TextUtils.formatSelectedCount(mModel.getSelection().size()));
return mModel.getSelection().size() > 0;
mode.setTitle(TextUtils.formatSelectedCount(size));
return (size > 0);
}
@Override
@@ -681,7 +683,7 @@ public class DirectoryFragment extends Fragment {
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Selection selection = mModel.getSelection(new Selection());
Selection selection = mSelectionManager.getSelection(new Selection());
final int id = item.getItemId();
if (id == R.id.menu_open) {
@@ -920,15 +922,22 @@ public class DirectoryFragment extends Fragment {
public DocumentHolder onCreateViewHolder(ViewGroup parent, int viewType) {
final State state = getDisplayState(DirectoryFragment.this);
final LayoutInflater inflater = LayoutInflater.from(getContext());
View item = null;
switch (state.derivedMode) {
case MODE_GRID:
return new DocumentHolder(inflater.inflate(R.layout.item_doc_grid, parent, false));
item = inflater.inflate(R.layout.item_doc_grid, parent, false);
break;
case MODE_LIST:
return new DocumentHolder(inflater.inflate(R.layout.item_doc_list, parent, false));
item = inflater.inflate(R.layout.item_doc_list, parent, false);
break;
case MODE_UNKNOWN:
default:
throw new IllegalStateException("Unsupported layout mode.");
}
// Key event bubbling doesn't work properly, so instead of setting one key listener on
// the RecyclerView, we have to set it on each Item. See b/24865023.
item.setOnKeyListener(mSelectionManager);
return new DocumentHolder(item);
}
@Override
@@ -957,7 +966,7 @@ public class DirectoryFragment extends Fragment {
holder.docId = docId;
final View itemView = holder.view;
itemView.setActivated(mModel.isSelected(position));
itemView.setActivated(isSelected(position));
final View line1 = itemView.findViewById(R.id.line1);
final View line2 = itemView.findViewById(R.id.line2);
@@ -1289,7 +1298,7 @@ public class DirectoryFragment extends Fragment {
}
void copySelectedToClipboard() {
Selection sel = mModel.getSelection(new Selection());
Selection sel = mSelectionManager.getSelection(new Selection());
copySelectionToClipboard(sel);
}
@@ -1338,51 +1347,12 @@ public class DirectoryFragment extends Fragment {
}
void selectAllFiles() {
boolean changed = mModel.selectAll();
boolean changed = mSelectionManager.setItemsSelected(0, mModel.getItemCount(), true);
if (changed) {
updateDisplayState();
}
}
/**
* Scrolls to the top of the file list and focuses the first file.
*/
void focusFirstFile() {
focusFile(0);
}
/**
* Scrolls to the bottom of the file list and focuses the last file.
*/
void focusLastFile() {
focusFile(mAdapter.getItemCount() - 1);
}
/**
* Scrolls to and then focuses on the file at the given position.
*/
private void focusFile(final int pos) {
// Don't smooth scroll; that taxes the system unnecessarily and makes the scroll handling
// logic below more complicated.
mRecView.scrollToPosition(pos);
// If the item is already in view, focus it; otherwise, set a one-time listener to focus it
// when the scroll is completed.
RecyclerView.ViewHolder vh = mRecView.findViewHolderForAdapterPosition(pos);
if (vh != null) {
vh.itemView.requestFocus();
} else {
mRecView.addOnScrollListener(
new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView view, int dx, int dy) {
view.findViewHolderForAdapterPosition(pos).itemView.requestFocus();
view.removeOnScrollListener(this);
}
});
}
}
private void setupDragAndDropOnDirectoryView(View view) {
// Listen for drops on non-directory items and empty space.
view.setOnDragListener(mOnDragListener);
@@ -1472,9 +1442,10 @@ public class DirectoryFragment extends Fragment {
return Collections.EMPTY_LIST;
}
final List<DocumentInfo> selectedDocs = mModel.getSelectedDocuments();
final List<DocumentInfo> selectedDocs =
mModel.getDocuments(mSelectionManager.getSelection());
if (!selectedDocs.isEmpty()) {
if (!mModel.isSelected(position)) {
if (!isSelected(position)) {
// There is a selection that does not include the current item, drag nothing.
return Collections.EMPTY_LIST;
}
@@ -1694,12 +1665,15 @@ public class DirectoryFragment extends Fragment {
public void afterActivityCreated(DirectoryFragment fragment) {}
}
boolean isSelected(int position) {
return mSelectionManager.getSelection().contains(position);
}
/**
* The data model for the current loaded directory.
*/
@VisibleForTesting
public static final class Model implements DocumentContext {
private MultiSelectManager mSelectionManager;
private RecyclerView.Adapter<?> mViewAdapter;
private Context mContext;
private int mCursorCount;
@@ -1710,45 +1684,11 @@ public class DirectoryFragment extends Fragment {
@Nullable private String info;
@Nullable private String error;
Model(Context context, MultiSelectManager selectionManager,
RecyclerView.Adapter<?> viewAdapter) {
Model(Context context, RecyclerView.Adapter<?> viewAdapter) {
mContext = context;
mSelectionManager = selectionManager;
mViewAdapter = viewAdapter;
}
/**
* Selects all files in the current directory.
* @return true if the selection state changed for any files.
*/
boolean selectAll() {
return mSelectionManager.setItemsSelected(0, mCursorCount, true);
}
/**
* Clones the current selection into the given Selection object.
* @param selection
* @return The selection that was passed in, for convenience.
*/
Selection getSelection(Selection selection) {
return mSelectionManager.getSelection(selection);
}
/**
* @return The current selection (the live instance, not a copy).
*/
Selection getSelection() {
return mSelectionManager.getSelection();
}
boolean isSelected(int position) {
return mSelectionManager.getSelection().contains(position);
}
void clearSelection() {
mSelectionManager.clearSelection();
}
void update(DirectoryResult result) {
if (DEBUG) Log.i(TAG, "Updating model with new result set.");
@@ -1821,11 +1761,6 @@ public class DirectoryFragment extends Fragment {
return mIsLoading;
}
private List<DocumentInfo> getSelectedDocuments() {
Selection sel = getSelection(new Selection());
return getDocuments(sel);
}
List<DocumentInfo> getDocuments(Selection items) {
final int size = (items != null) ? items.size() : 0;

View File

@@ -39,6 +39,7 @@ import android.util.SparseIntArray;
import android.view.GestureDetector;
import android.view.GestureDetector.OnDoubleTapListener;
import android.view.GestureDetector.OnGestureListener;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
@@ -56,7 +57,7 @@ import java.util.List;
* Additionally it can be configured to restrict selection to a single element, @see
* #setSelectMode.
*/
public final class MultiSelectManager {
public final class MultiSelectManager implements View.OnKeyListener {
/** Selection mode for multiple select. **/
public static final int MODE_MULTIPLE = 0;
@@ -72,6 +73,8 @@ public final class MultiSelectManager {
private Selection mIntermediateSelection;
private Range mRanger;
private SelectionEnvironment mEnvironment;
private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
private Adapter<?> mAdapter;
@@ -95,10 +98,10 @@ public final class MultiSelectManager {
new RuntimeItemFinder(recyclerView),
mode);
mEnvironment = new RuntimeSelectionEnvironment(recyclerView);
if (mode == MODE_MULTIPLE) {
mBandManager = new BandController(
mHelper,
new RuntimeBandEnvironment(recyclerView));
mBandManager = new BandController(mHelper);
}
GestureDetector.SimpleOnGestureListener listener =
@@ -900,7 +903,7 @@ public final class MultiSelectManager {
* Provides functionality for BandController. Exists primarily to tests that are
* fully isolated from RecyclerView.
*/
interface BandEnvironment {
interface SelectionEnvironment {
void showBand(Rect rect);
void hideBand();
void addOnScrollListener(RecyclerView.OnScrollListener listener);
@@ -913,29 +916,39 @@ public final class MultiSelectManager {
Point createAbsolutePoint(Point relativePoint);
Rect getAbsoluteRectForChildViewAt(int index);
int getAdapterPositionAt(int index);
int getAdapterPositionForChildView(View view);
int getColumnCount();
int getRowCount();
int getChildCount();
int getVisibleChildCount();
void focusItem(int position);
}
/** RvFacade implementation backed by good ol' RecyclerView. */
private static final class RuntimeBandEnvironment implements BandEnvironment {
private static final class RuntimeSelectionEnvironment implements SelectionEnvironment {
private final RecyclerView mView;
private final Drawable mBand;
private boolean mIsOverlayShown = false;
RuntimeBandEnvironment(RecyclerView rv) {
RuntimeSelectionEnvironment(RecyclerView rv) {
mView = rv;
mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
}
@Override
public int getAdapterPositionForChildView(View view) {
if (view.getParent() == mView) {
return mView.getChildAdapterPosition(view);
} else {
return RecyclerView.NO_POSITION;
}
}
@Override
public int getAdapterPositionAt(int index) {
View child = mView.getChildAt(index);
return mView.getChildViewHolder(child).getAdapterPosition();
return getAdapterPositionForChildView(mView.getChildAt(index));
}
@Override
@@ -1032,6 +1045,28 @@ public final class MultiSelectManager {
public void hideBand() {
mView.getOverlay().remove(mBand);
}
@Override
public void focusItem(final int pos) {
// If the item is already in view, focus it; otherwise, scroll to it and focus it.
RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos);
if (vh != null) {
vh.itemView.requestFocus();
} else {
// Don't smooth scroll; that taxes the system unnecessarily and makes the scroll
// handling logic below more complicated. See b/24865658.
mView.scrollToPosition(pos);
// Set a one-time listener to request focus when the scroll has completed.
mView.addOnScrollListener(
new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView view, int dx, int dy) {
view.findViewHolderForAdapterPosition(pos).itemView.requestFocus();
view.removeOnScrollListener(this);
}
});
}
}
}
public interface Callback {
@@ -1175,7 +1210,6 @@ public final class MultiSelectManager {
private static final int NOT_SET = -1;
private final ItemFinder mItemFinder;
private final BandEnvironment mEnvironment;
private final Runnable mModelBuilder;
@Nullable private Rect mBounds;
@@ -1188,15 +1222,14 @@ public final class MultiSelectManager {
private long mScrollStartTime = NOT_SET;
private final Runnable mViewScroller = new ViewScroller();
public BandController(ItemFinder finder, final BandEnvironment environment) {
public BandController(ItemFinder finder) {
mItemFinder = finder;
mEnvironment = environment;
mEnvironment.addOnScrollListener(this);
mModelBuilder = new Runnable() {
@Override
public void run() {
mModel = new GridModel(environment);
mModel = new GridModel(mEnvironment);
mModel.addOnSelectionChangedListener(BandController.this);
}
};
@@ -1459,7 +1492,7 @@ public final class MultiSelectManager {
private static final int LOWER_LEFT = LOWER | LEFT;
private static final int LOWER_RIGHT = LOWER | RIGHT;
private final BandEnvironment mHelper;
private final SelectionEnvironment mHelper;
private final List<OnSelectionChangedListener> mOnSelectionChangedListeners =
new ArrayList<>();
@@ -1497,7 +1530,7 @@ public final class MultiSelectManager {
// should expand from when Shift+click is used.
private int mPositionNearestOrigin = NOT_SET;
GridModel(BandEnvironment helper) {
GridModel(SelectionEnvironment helper) {
mHelper = helper;
mHelper.addOnScrollListener(this);
}
@@ -2041,4 +2074,72 @@ public final class MultiSelectManager {
return true;
}
}
// TODO: Might have to move this to a more global level. e.g. What should happen if the
// user taps a file and then presses shift-down? Currently the RecyclerView never even sees
// the key event. Perhaps install a global key handler to catch those events while in
// selection mode?
@Override
public boolean onKey(View view, int keyCode, KeyEvent event) {
// Listen for key-down events. This allows the handler to respond appropriately when
// the user holds down the arrow keys for navigation.
if (event.getAction() != KeyEvent.ACTION_DOWN) {
return false;
}
int target = RecyclerView.NO_POSITION;
if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
target = 0;
} else if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
target = mAdapter.getItemCount() - 1;
} else {
// Find a navigation target based on the arrow key that the user pressed. Ignore
// navigation targets that aren't items in the recycler view.
int searchDir = -1;
switch (keyCode) {
case KeyEvent.KEYCODE_DPAD_UP:
searchDir = View.FOCUS_UP;
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
searchDir = View.FOCUS_DOWN;
break;
case KeyEvent.KEYCODE_DPAD_LEFT:
searchDir = View.FOCUS_LEFT;
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
searchDir = View.FOCUS_RIGHT;
break;
}
if (searchDir != -1) {
View targetView = view.focusSearch(searchDir);
target = mEnvironment.getAdapterPositionForChildView(targetView);
}
}
if (target == RecyclerView.NO_POSITION) {
// If there is no valid navigation target, don't handle the keypress.
return false;
}
// Focus the new file.
mEnvironment.focusItem(target);
if (event.isShiftPressed()) {
if (mSelection.isEmpty()) {
// If there is no selection, start a selection when the user presses shift-arrow.
toggleSelection(mEnvironment.getAdapterPositionForChildView(view));
} else {
// Deal with b/24802917 (selected items can't be focused) by adjusting the
// selection sorted the focused item isn't in the selection.
target -= Integer.signum(target - mRanger.mBegin);
mRanger.snapSelection(target);
}
} else if (!event.isShiftPressed() && !mSelection.isEmpty()) {
// If there is a selection, clear it if the user presses arrow with no shift.
clearSelection();
}
return true;
}
}

View File

@@ -64,7 +64,7 @@ public class DirectoryFragmentModelTest extends AndroidTestCase {
r.cursor = cursor;
// Instantiate the model with a dummy view adapter and listener that (for now) do nothing.
model = new Model(mContext, null, new DummyAdapter());
model = new Model(mContext, new DummyAdapter());
model.addUpdateListener(new DummyListener());
model.update(r);
}

View File

@@ -24,6 +24,7 @@ import android.graphics.Rect;
import android.support.v7.widget.RecyclerView.OnScrollListener;
import android.test.AndroidTestCase;
import android.util.SparseBooleanArray;
import android.view.View;
import com.android.documentsui.MultiSelectManager.GridModel;
@@ -34,14 +35,14 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase {
private static final int VIEWPORT_HEIGHT = 500;
private static GridModel model;
private static TestHelper helper;
private static TestEnvironment env;
private static SparseBooleanArray lastSelection;
private static int viewWidth;
private static void setUp(int numChildren, int numColumns) {
helper = new TestHelper(numChildren, numColumns);
env = new TestEnvironment(numChildren, numColumns);
viewWidth = VIEW_PADDING_PX + numColumns * (VIEW_PADDING_PX + CHILD_VIEW_EDGE_PX);
model = new GridModel(helper);
model = new GridModel(env);
model.addOnSelectionChangedListener(
new GridModel.OnSelectionChangedListener() {
@Override
@@ -54,7 +55,7 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase {
@Override
public void tearDown() {
model = null;
helper = null;
env = null;
lastSelection = null;
}
@@ -176,12 +177,12 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase {
}
private static void scroll(int dy) {
assertTrue(helper.verticalOffset + VIEWPORT_HEIGHT + dy <= helper.getTotalHeight());
helper.verticalOffset += dy;
assertTrue(env.verticalOffset + VIEWPORT_HEIGHT + dy <= env.getTotalHeight());
env.verticalOffset += dy;
model.onScrolled(null, 0, dy);
}
private static final class TestHelper implements MultiSelectManager.BandEnvironment {
private static final class TestEnvironment implements MultiSelectManager.SelectionEnvironment {
public int horizontalOffset = 0;
public int verticalOffset = 0;
@@ -189,7 +190,7 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase {
private final int mNumRows;
private final int mNumChildren;
public TestHelper(int numChildren, int numColumns) {
public TestEnvironment(int numChildren, int numColumns) {
mNumChildren = numChildren;
mNumColumns = numColumns;
mNumRows = (int) Math.ceil((double) numChildren / mNumColumns);
@@ -307,5 +308,15 @@ public class MultiSelectManager_GridModelTest extends AndroidTestCase {
public void removeCallback(Runnable r) {
throw new UnsupportedOperationException();
}
@Override
public int getAdapterPositionForChildView(View view) {
throw new UnsupportedOperationException();
}
@Override
public void focusItem(int i) {
throw new UnsupportedOperationException();
}
}
}