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

Commit 3182a92a authored by Ben Kwa's avatar Ben Kwa Committed by Android (Google) Code Review
Browse files

Merge "Refactor key handling and selection."

parents 6dfb9b39 6792489d
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;
@@ -827,7 +830,6 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
    @Override
    public void initDocumentHolder(DocumentHolder holder) {
        holder.addEventListener(mItemEventListener);
        holder.addOnKeyListener(mSelectionManager);
    }

    @Override
@@ -1230,7 +1232,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;
        }

@@ -1240,6 +1247,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;
@@ -239,7 +238,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;
@@ -248,10 +248,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);
        }
    }
@@ -334,32 +334,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
@@ -434,6 +457,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--) {
@@ -786,12 +827,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.
         */
@@ -811,18 +850,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
@@ -920,39 +950,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
@@ -1907,99 +1904,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