Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 6792489d authored by Ben Kwa's avatar Ben Kwa
Browse files

Refactor key handling and selection.

- Pull key handling code out of the MultiSelectManager.

- Tighten up the semantics around range selection:
  - Create an API on MultiSelectManager for handling multi-select.
  - Make the range selection more opinionated (e.g. more state checks),
    to simplify the design and code.

BUG=25195767

Change-Id: I4bbe446ed3059150499db3d28e581b2e68405266
parent a262f246
Loading
Loading
Loading
Loading
+21 −0
Original line number Diff line number Diff line
@@ -77,6 +77,27 @@ public final class Events {
        return hasShiftBit(e.getMetaState());
    }

    /**
     * Whether or not the given keyCode represents a navigation keystroke (e.g. up, down, home).
     *
     * @param keyCode
     * @return
     */
    public static boolean isNavigationKeyCode(int keyCode) {
        switch (keyCode) {
            case KeyEvent.KEYCODE_DPAD_UP:
            case KeyEvent.KEYCODE_DPAD_DOWN:
            case KeyEvent.KEYCODE_DPAD_LEFT:
            case KeyEvent.KEYCODE_DPAD_RIGHT:
            case KeyEvent.KEYCODE_MOVE_HOME:
            case KeyEvent.KEYCODE_MOVE_END:
                return true;
            default:
                return false;
        }
    }


    /**
     * Returns true if the "SHIFT" bit is set.
     */
+132 −3
Original line number Diff line number Diff line
@@ -66,6 +66,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;
@@ -99,7 +100,6 @@ import com.android.documentsui.model.DocumentStack;
import com.android.documentsui.model.RootInfo;
import com.android.documentsui.services.FileOperationService;
import com.android.documentsui.services.FileOperations;

import com.google.common.collect.Lists;

import java.util.ArrayList;
@@ -529,6 +529,9 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
            final Cursor cursor = mModel.getItem(modelId);
            checkNotNull(cursor, "Cursor cannot be null.");

            // TODO: Should this be happening in onSelectionChanged? Technically this callback is
            // triggered on "silent" selection updates (i.e. we might be reacting to unfinalized
            // selection changes here)
            final int docFlags = getCursorInt(cursor, Document.COLUMN_FLAGS);
            if ((docFlags & Document.FLAG_SUPPORTS_DELETE) == 0) {
                mNoDeleteCount += selected ? 1 : -1;
@@ -821,7 +824,6 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
    @Override
    public void initDocumentHolder(DocumentHolder holder) {
        holder.addEventListener(mItemEventListener);
        holder.addOnKeyListener(mSelectionManager);
    }

    @Override
@@ -1223,7 +1225,12 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
    private class ItemEventListener implements DocumentHolder.EventListener {
        @Override
        public boolean onActivate(DocumentHolder doc) {
            // Toggle selection if we're in selection mode, othewise, view item.
            if (mSelectionManager.hasSelection()) {
                mSelectionManager.toggleSelection(doc.modelId);
            } else {
                handleViewItem(doc.modelId);
            }
            return true;
        }

@@ -1233,6 +1240,128 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
            mSelectionManager.setSelectionRangeBegin(doc.getAdapterPosition());
            return true;
        }

        @Override
        public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
            // Only handle key-down events. This is simpler, consistent with most other UIs, and
            // enables the handling of repeated key events from holding down a key.
            if (event.getAction() != KeyEvent.ACTION_DOWN) {
                return false;
            }

            boolean handled = false;
            if (Events.isNavigationKeyCode(keyCode)) {
                // Find the target item and focus it.
                int endPos = findTargetPosition(doc.itemView, keyCode);

                if (endPos != RecyclerView.NO_POSITION) {
                    focusItem(endPos);

                    // Handle any necessary adjustments to selection.
                    boolean extendSelection = event.isShiftPressed();
                    if (extendSelection) {
                        int startPos = doc.getAdapterPosition();
                        mSelectionManager.selectRange(startPos, endPos);
                    }
                    handled = true;
                }
            } else {
                // Handle enter key events
                if (keyCode == KeyEvent.KEYCODE_ENTER) {
                    handled = onActivate(doc);
                }
            }

            return handled;
        }

        /**
         * Finds the destination position where the focus should land for a given navigation event.
         *
         * @param view The view that received the event.
         * @param keyCode The key code for the event.
         * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
         */
        private int findTargetPosition(View view, int keyCode) {
            if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
                return 0;
            }

            if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
                return mAdapter.getItemCount() - 1;
            }

            // Find a navigation target based on the arrow key that the user pressed.
            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);
                // TargetView can be null, for example, if the user pressed <down> at the bottom
                // of the list.
                if (targetView != null) {
                    // Ignore navigation targets that aren't items in the RecyclerView.
                    if (targetView.getParent() == mRecView) {
                        return mRecView.getChildAdapterPosition(targetView);
                    }
                }
            }

            return RecyclerView.NO_POSITION;
        }

        /**
         * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
         * necessary.
         *
         * @param pos
         */
        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 = mRecView.findViewHolderForAdapterPosition(pos);
            if (vh != null) {
                vh.itemView.requestFocus();
            } else {
                mRecView.smoothScrollToPosition(pos);
                // Set a one-time listener to request focus when the scroll has completed.
                mRecView.addOnScrollListener(
                    new RecyclerView.OnScrollListener() {
                        @Override
                        public void onScrollStateChanged (RecyclerView view, int newState) {
                            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                                // When scrolling stops, find the item and focus it.
                                RecyclerView.ViewHolder vh =
                                        view.findViewHolderForAdapterPosition(pos);
                                if (vh != null) {
                                    vh.itemView.requestFocus();
                                } else {
                                    // This might happen in weird corner cases, e.g. if the user is
                                    // scrolling while a delete operation is in progress.  In that
                                    // case, just don't attempt to focus the missing item.
                                    Log.w(
                                        TAG, "Unable to focus position " + pos + " after a scroll");
                                }
                                view.removeOnScrollListener(this);
                            }
                        }
                    });
            }
        }


    }

    private final class ModelUpdateListener implements Model.UpdateListener {
+15 −7
Original line number Diff line number Diff line
@@ -84,13 +84,7 @@ public abstract class DocumentHolder
    public boolean onKey(View v, int keyCode, KeyEvent event) {
        // Event listener should always be set.
        checkNotNull(mEventListener);
        // Intercept enter key-up events, and treat them as clicks.  Forward other events.
        if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_ENTER) {
            return mEventListener.onActivate(this);
        } else if (mKeyListener != null) {
            return mKeyListener.onKey(v, keyCode, event);
        }
        return false;
        return mEventListener.onKey(this,  keyCode,  event);
    }

    public void addEventListener(DocumentHolder.EventListener listener) {
@@ -159,15 +153,29 @@ public abstract class DocumentHolder
     */
    interface EventListener {
        /**
         * Handles activation events on the document holder.
         *
         * @param doc The target DocumentHolder
         * @return Whether the event was handled.
         */
        public boolean onActivate(DocumentHolder doc);

        /**
         * Handles selection events on the document holder.
         *
         * @param doc The target DocumentHolder
         * @return Whether the event was handled.
         */
        public boolean onSelect(DocumentHolder doc);

        /**
         * Handles key events on the document holder.
         *
         * @param doc The target DocumentHolder.
         * @param keyCode Key code for the event.
         * @param event KeyEvent for the event.
         * @return Whether the event was handled.
         */
        public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event);
    }
}
+65 −163
Original line number Diff line number Diff line
@@ -34,7 +34,6 @@ import android.util.Log;
import android.util.SparseArray;
import android.util.SparseBooleanArray;
import android.util.SparseIntArray;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;

@@ -57,7 +56,7 @@ import java.util.Set;
 * Additionally it can be configured to restrict selection to a single element, @see
 * #setSelectMode.
 */
public final class MultiSelectManager implements View.OnKeyListener {
public final class MultiSelectManager {

    /** Selection mode for multiple select. **/
    public static final int MODE_MULTIPLE = 0;
@@ -237,7 +236,8 @@ public final class MultiSelectManager implements View.OnKeyListener {
    }

    /**
     * Clears the selection, without notifying anyone.
     * Clears the selection, without notifying selection listeners. UI elements still need to be
     * notified about state changes so that they can update their appearance.
     */
    private void clearSelectionQuietly() {
        mRanger = null;
@@ -246,10 +246,10 @@ public final class MultiSelectManager implements View.OnKeyListener {
            return;
        }

        Selection intermediateSelection = getSelection(new Selection());
        Selection oldSelection = getSelection(new Selection());
        mSelection.clear();

        for (String id: intermediateSelection.getAll()) {
        for (String id: oldSelection.getAll()) {
            notifyItemStateChanged(id, false);
        }
    }
@@ -332,32 +332,55 @@ public final class MultiSelectManager implements View.OnKeyListener {
        if (mSelection.contains(modelId)) {
            changed = attemptDeselect(modelId);
        } else {
            boolean canSelect = notifyBeforeItemStateChange(modelId, true);
            if (!canSelect) {
                return;
            changed = attemptSelect(modelId);
        }

        if (changed) {
            notifySelectionChanged();
        }
            if (mSingleSelect && hasSelection()) {
                clearSelectionQuietly();
    }

            // Here we're already in selection mode. In that case
            // When a simple click/tap (without SHIFT) creates causes
            // an item to be selected.
            // By recreating Ranger at this point, we allow the user to create
            // multiple separate contiguous ranges with SHIFT+Click & Click.
            selectAndNotify(modelId);
            changed = true;
    /**
     * Handle a range selection event.
     * <li> If the MSM is currently in single-select mode, only the last item in the range will
     * actually be selected.
     * <li>If a range selection is not already active, one will be started, and the given range of
     * items will be selected.  The given startPos becomes the anchor for the range selection.
     * <li>If a range selection is already active, the anchor is not changed. The range is extended
     * from its current anchor to endPos.
     *
     * @param startPos
     * @param endPos
     */
    public void selectRange(int startPos, int endPos) {
        // In single-select mode, just select the last item in the range.
        if (mSingleSelect) {
            attemptSelect(mAdapter.getModelId(endPos));
            return;
        }

        if (changed) {
        // In regular (i.e. multi-select) mode
        if (!isRangeSelectionActive()) {
            // If a range selection isn't active, start one up
            attemptSelect(mAdapter.getModelId(startPos));
            setSelectionRangeBegin(startPos);
        }
        // Extend the range selection
        mRanger.snapSelection(endPos);
        notifySelectionChanged();
    }

    /**
     * @return Whether or not there is a current range selection active.
     */
    private boolean isRangeSelectionActive() {
        return mRanger != null;
    }

    /**
     * Sets the magic location at which a selection range begins. This
     * value is consulted when determining how to extend, and modify
     * selection ranges.
     * Sets the magic location at which a selection range begins (the selection anchor). This value
     * is consulted when determining how to extend, and modify selection ranges. Calling this when a
     * range selection is active will reset the range selection.
     *
     * @throws IllegalStateException if {@code position} is not already be selected
     * @param position
@@ -432,6 +455,24 @@ public final class MultiSelectManager implements View.OnKeyListener {
        }
    }

    /**
     * @param id
     * @return True if the update was applied.
     */
    private boolean attemptSelect(String id) {
        checkArgument(id != null);
        boolean canSelect = notifyBeforeItemStateChange(id, true);
        if (!canSelect) {
            return false;
        }
        if (mSingleSelect && hasSelection()) {
            clearSelectionQuietly();
        }

        selectAndNotify(id);
        return true;
    }

    private boolean notifyBeforeItemStateChange(String id, boolean nextState) {
        int lastListener = mCallbacks.size() - 1;
        for (int i = lastListener; i > -1; i--) {
@@ -784,12 +825,10 @@ public final class MultiSelectManager implements View.OnKeyListener {
        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);
        /**
         * Layout items are excluded from the GridModel.
         */
@@ -809,18 +848,9 @@ public final class MultiSelectManager implements View.OnKeyListener {
            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) {
            return getAdapterPositionForChildView(mView.getChildAt(index));
            return mView.getChildAdapterPosition(mView.getChildAt(index));
        }

        @Override
@@ -918,39 +948,6 @@ public final class MultiSelectManager implements View.OnKeyListener {
            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 {
                mView.smoothScrollToPosition(pos);
                // Set a one-time listener to request focus when the scroll has completed.
                mView.addOnScrollListener(
                    new RecyclerView.OnScrollListener() {
                        @Override
                        public void onScrollStateChanged (RecyclerView view, int newState) {
                            if (newState == RecyclerView.SCROLL_STATE_IDLE) {
                                // When scrolling stops, find the item and focus it.
                                RecyclerView.ViewHolder vh =
                                        view.findViewHolderForAdapterPosition(pos);
                                if (vh != null) {
                                    vh.itemView.requestFocus();
                                } else {
                                    // This might happen in weird corner cases, e.g. if the user is
                                    // scrolling while a delete operation is in progress.  In that
                                    // case, just don't attempt to focus the missing item.
                                    Log.w(
                                        TAG, "Unable to focus position " + pos + " after a scroll");
                                }
                                view.removeOnScrollListener(this);
                            }
                        }
                    });
            }
        }

        @Override
        public boolean isLayoutItem(int pos) {
            // The band selection model only operates on documents and directories. Exclude other
@@ -1905,99 +1902,4 @@ public final class MultiSelectManager implements View.OnKeyListener {
            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;
        }

        // Here we unpack information from the event and pass it to an more
        // easily tested method....basically eliminating the need to synthesize
        // events and views and so on in our tests.
        int endPos = findTargetPosition(view, keyCode);
        if (endPos == RecyclerView.NO_POSITION) {
            // If there is no valid navigation target, don't handle the keypress.
            return false;
        }

        int startPos = mEnvironment.getAdapterPositionForChildView(view);

        return changeFocus(startPos, endPos, event.isShiftPressed());
    }

    /**
     * @param startPosition The current focus position.
     * @param targetPosition The adapter position to focus.
     * @param extendSelection
     */
    @VisibleForTesting
    boolean changeFocus(int startPosition, int targetPosition, boolean extendSelection) {
        // Focus the new file.
        mEnvironment.focusItem(targetPosition);

        if (extendSelection) {
            if (mSingleSelect) {
                // We're in single select and have an existing selection.
                // Our best guess as to what the user would expect is to advance the selection.
                clearSelection();
                toggleSelection(targetPosition);
            } else {
                if (!hasSelection()) {
                    // No selection - start a selection when the user presses shift-arrow.
                    toggleSelection(startPosition);
                    setSelectionRangeBegin(startPosition);
                }
                mRanger.snapSelection(targetPosition);
                notifySelectionChanged();
            }
        }

        return true;
    }

    /**
     * Returns the adapter position that the key combo is targeted at.
     */
    private int findTargetPosition(View view, int keyCode) {
        int position = RecyclerView.NO_POSITION;
        if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
            position = 0;
        } else if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
            position = 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);
                // TargetView can be null, for example, if the user pressed <down> at the bottom of
                // the list.
                if (targetView != null) {
                    position = mEnvironment.getAdapterPositionForChildView(targetView);
                }
            }
        }
        return position;
    }
}
+6 −0
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import android.graphics.Rect;
import android.os.SystemClock;
import android.test.AndroidTestCase;
import android.test.suitebuilder.annotation.SmallTest;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.MotionEvent.PointerCoords;
@@ -130,5 +131,10 @@ public class DocumentHolderTest extends AndroidTestCase {
            return true;
        }

        @Override
        public boolean onKey(DocumentHolder doc, int keyCode, KeyEvent event) {
            return false;
        }

    }
}
Loading