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

Commit 93d8ef47 authored by Steve McKay's avatar Steve McKay
Browse files

Start selection mode with single mouse click...

Double clicking an item opens it.
BandSelectManager tells MultiSelectManager where its selection begins
    so Shift+Click behavior can be complimentary.
BandSelectManager more actively manages selection...so it doesn't
    clear existing selection on mouse down.

Change-Id: Ibe65e793e84463d333a19f363dfb0d42c37480e3
parent 5dc71c65
Loading
Loading
Loading
Loading
+66 −15
Original line number Diff line number Diff line
@@ -16,7 +16,9 @@

package com.android.documentsui;

import static com.android.documentsui.Events.isMouseEvent;
import static com.android.internal.util.Preconditions.checkState;
import static java.lang.String.format;

import android.graphics.Point;
import android.graphics.Rect;
@@ -24,6 +26,7 @@ import android.graphics.drawable.Drawable;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.RecyclerView.ViewHolder;
import android.util.Log;
import android.util.SparseBooleanArray;
import android.view.MotionEvent;
import android.view.View;

@@ -34,6 +37,8 @@ import android.view.View;
 */
public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {

    private static final int NOT_SELECTED = -1;

    // For debugging purposes.
    private static final String TAG = "BandSelectManager";
    private static final boolean DEBUG = false;
@@ -41,10 +46,17 @@ public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
    private final RecyclerView mRecyclerView;
    private final MultiSelectManager mSelectManager;
    private final Drawable mRegionSelectorDrawable;
    private final SparseBooleanArray mSelectedByBand = new SparseBooleanArray();

    private boolean mIsBandSelectActive = false;
    private Point mOrigin;
    private Rect mRegionBounds;
    private Rect mBounds;
    // Maintain the last selection made by band, so if bounds shink back, we can unselect
    // the respective items.

    // Track information
    private int mCursorDeltaY = 0;
    private int mFirstSelected = NOT_SELECTED;

    /**
     * @param recyclerView
@@ -109,11 +121,17 @@ public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
     * @param pointerPosition
     */
    private void resizeBandSelectRectangle(Point pointerPosition) {
        mRegionBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),

        if (mBounds != null) {
            mCursorDeltaY = pointerPosition.y - mBounds.bottom;
        }

        mBounds = new Rect(Math.min(mOrigin.x, pointerPosition.x),
                Math.min(mOrigin.y, pointerPosition.y),
                Math.max(mOrigin.x, pointerPosition.x),
                Math.max(mOrigin.y, pointerPosition.y));
        mRegionSelectorDrawable.setBounds(mRegionBounds);

        mRegionSelectorDrawable.setBounds(mBounds);
    }

    /**
@@ -122,14 +140,53 @@ public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
     * Final optimized implementation, with support for managing offscreen selection to come.
     */
    private void selectChildrenCoveredBySelection() {

        // track top and bottom selections. Details on why this is useful below.
        int first = NOT_SELECTED;
        int last = NOT_SELECTED;

        for (int i = 0; i < mRecyclerView.getChildCount(); i++) {

            View child = mRecyclerView.getChildAt(i);
            ViewHolder holder = mRecyclerView.getChildViewHolder(child);
            Rect childRect = new Rect();
            child.getHitRect(childRect);

            boolean doRectsOverlap = Rect.intersects(childRect, mRegionBounds);
            mSelectManager.setItemSelected(holder.getAdapterPosition(), doRectsOverlap);
            boolean shouldSelect = Rect.intersects(childRect, mBounds);
            int position = holder.getAdapterPosition();

            // This also allows us to clear the selection of elements
            // that only temporarily entered the bounds of the band.
            if (mSelectedByBand.get(position) && !shouldSelect) {
                mSelectManager.setItemSelected(position, false);
                mSelectedByBand.delete(position);
            }

            // We need to keep track of the first and last items selected.
            // We'll use this information along with cursor direction
            // to determine the starting point of the selection.
            // We provide this information to selection manager
            // to enable more natural user interaction when working
            // with Shift+Click and multiple contiguous selection ranges.
            if (shouldSelect) {
                if (first == NOT_SELECTED) {
                    first = position;
                } else {
                    last = position;
                }
                mSelectManager.setItemSelected(position, true);
                mSelectedByBand.put(position, true);
            }
        }

        // Remember which is the last selected item, so we can
        // share that with selection manager when band select ends.
        // It'll use that as it's begin selection point when
        // user SHIFT+Clicks.
        if (mCursorDeltaY < 0 && last != NOT_SELECTED) {
            mFirstSelected = last;
        } else if (mCursorDeltaY > 0 && first != NOT_SELECTED) {
            mFirstSelected = first;
        }
    }

@@ -139,16 +196,10 @@ public class BandSelectManager extends RecyclerView.SimpleOnItemTouchListener {
    private void endBandSelect() {
        if (DEBUG) Log.d(TAG, "Ending band select.");
        mIsBandSelectActive = false;
        mSelectedByBand.clear();
        mRecyclerView.getOverlay().remove(mRegionSelectorDrawable);
        if (mFirstSelected != NOT_SELECTED) {
            mSelectManager.setSelectionFocusBegin(mFirstSelected);
        }

    /**
     * Determines whether the provided event was triggered by a mouse (as opposed to a finger or
     * stylus).
     * @param e
     * @return
     */
    private static boolean isMouseEvent(MotionEvent e) {
        return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
    }
}
+32 −10
Original line number Diff line number Diff line
@@ -286,6 +286,11 @@ public class DirectoryFragment extends Fragment {
                    public boolean onSingleTapUp(MotionEvent e) {
                        return DirectoryFragment.this.onSingleTapUp(e);
                    }
                    @Override
                    public boolean onDoubleTap(MotionEvent e) {
                        Log.d(TAG, "Handling double tap.");
                        return DirectoryFragment.this.onDoubleTap(e);
                    }
                };

        mSelectionManager = new MultiSelectManager(mRecView, listener);
@@ -425,9 +430,27 @@ public class DirectoryFragment extends Fragment {
    }

    private boolean onSingleTapUp(MotionEvent e) {
        if (!Events.isMouseEvent(e)) {
            int position = getEventAdapterPosition(e);
            if (position != RecyclerView.NO_POSITION) {
                return handleViewItem(position);
            }
        }
        return false;
    }

    protected boolean onDoubleTap(MotionEvent e) {
        if (Events.isMouseEvent(e)) {
            Log.d(TAG, "Handling double tap from mouse.");
            int position = getEventAdapterPosition(e);
            if (position != RecyclerView.NO_POSITION) {
                return handleViewItem(position);
            }
        }
        return false;
    }

    private boolean handleViewItem(int position) {
        final Cursor cursor = mAdapter.getItem(position);
        checkNotNull(cursor, "Cursor cannot be null.");
        final String docMimeType = getCursorString(cursor, Document.COLUMN_MIME_TYPE);
@@ -435,10 +458,9 @@ public class DirectoryFragment extends Fragment {
        if (isDocumentEnabled(docMimeType, docFlags)) {
            final DocumentInfo doc = DocumentInfo.fromDirectoryCursor(cursor);
            ((BaseActivity) getActivity()).onDocumentPicked(doc);
            mSelectionManager.clearSelection();
            return true;
        }
        }

        return false;
    }

+54 −0
Original line number Diff line number Diff line
/*
 * 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.view.KeyEvent;
import android.view.MotionEvent;

/**
 * Utility code for dealing with MotionEvents.
 */
final class Events {

    /**
     * Returns true if event was triggered by a mouse.
     */
    static boolean isMouseEvent(MotionEvent e) {
        return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
    }

    /**
     * Returns true if event was triggered by a mouse.
     */
    static boolean isMouseType(int toolType) {
        return toolType == MotionEvent.TOOL_TYPE_MOUSE;
    }

    /**
     * Returns true if the shift is pressed.
     */
    boolean isShiftPressed(MotionEvent e) {
        return hasShiftBit(e.getMetaState());
    }

    /**
     * Returns true if the "SHIFT" bit is set.
     */
    static boolean hasShiftBit(int metaState) {
        return (metaState & KeyEvent.META_SHIFT_ON) != 0;
    }
}
+103 −52
Original line number Diff line number Diff line
@@ -26,13 +26,11 @@ import android.support.v7.widget.RecyclerView.AdapterDataObserver;
import android.util.Log;
import android.util.SparseBooleanArray;
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;

import com.android.internal.util.Preconditions;

import com.google.common.annotations.VisibleForTesting;

import java.util.ArrayList;
@@ -51,7 +49,7 @@ public final class MultiSelectManager {
    // Only created when selection is cleared.
    private Selection mIntermediateSelection;

    private Ranger mRanger;
    private Range mRanger;
    private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);

    private Adapter<?> mAdapter;
@@ -60,8 +58,11 @@ public final class MultiSelectManager {
    /**
     * @param recyclerView
     * @param gestureDelegate Option delage gesture listener.
     * @template A gestureDelegate that implements both {@link OnGestureListener}
     *     and {@link OnDoubleTapListener}
     */
    public MultiSelectManager(final RecyclerView recyclerView, OnGestureListener gestureDelegate) {
    public <L extends OnGestureListener & OnDoubleTapListener> MultiSelectManager(
            final RecyclerView recyclerView, L gestureDelegate) {
        this(
                recyclerView.getAdapter(),
                new RecyclerViewHelper() {
@@ -86,11 +87,15 @@ public final class MultiSelectManager {
                    }
                };

        CompositeOnGestureListener<? extends Object> compositeListener =
                new CompositeOnGestureListener<>(listener, gestureDelegate);
        final GestureDetector detector = new GestureDetector(
                recyclerView.getContext(),
                gestureDelegate == null
                        ? listener
                        : new CompositeOnGestureListener(listener, gestureDelegate));
                        : compositeListener);

        detector.setOnDoubleTapListener(compositeListener);

        recyclerView.addOnItemTouchListener(
                new RecyclerView.OnItemTouchListener() {
@@ -236,17 +241,40 @@ public final class MultiSelectManager {
        }
    }

    private 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.");
        }

        onLongPress(position);
    }

    /**
     * TODO: Roll this back into {@link #onLongPress(MotionEvent)} once MotionEvent
     * can be mocked.
     *
     * @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);
    }

    /**
     * @param e
     * @return true if the event was consumed.
     */
    private boolean onSingleTapUp(MotionEvent e) {
        if (DEBUG) Log.d(TAG, "Handling tap event.");
        if (mSelection.isEmpty()) {
            return false;
        }

        return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState());
        return onSingleTapUp(mHelper.findEventPosition(e), e.getMetaState(), e.getToolType(0));
    }

    /**
@@ -255,58 +283,33 @@ public final class MultiSelectManager {
     *
     * @param position
     * @param metaState as returned from {@link MotionEvent#getMetaState()}.
     * @param toolType
     * @return true if the event was consumed.
     * @hide
     */
    @VisibleForTesting
    boolean onSingleTapUp(int position, int metaState) {
    boolean onSingleTapUp(int position, int metaState, int toolType) {
        if (mSelection.isEmpty()) {
            // if this is a mouse click on an item, start selection mode.
            if (position != RecyclerView.NO_POSITION && Events.isMouseType(toolType)) {
                toggleSelection(position);
            }
            return false;
        }

        if (position == RecyclerView.NO_POSITION) {
            if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
            clearSelection();
            return true;
            return false;
        }

        if (isShiftPressed(metaState) && mRanger != null) {
        if (Events.hasShiftBit(metaState) && mRanger != null) {
            mRanger.snapSelection(position);
        } else {
            toggleSelection(position);
        }
        return true;
    }

    private static boolean isShiftPressed(int metaState) {
        return (metaState & KeyEvent.META_SHIFT_ON) != 0;
    }

    private 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.");
        }

        onLongPress(position);
    }

    /**
     * TODO: Roll this back into {@link #onLongPress(MotionEvent)} once MotionEvent
     * can be mocked.
     *
     * @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);
        return false;
    }

    /**
@@ -334,12 +337,25 @@ public final class MultiSelectManager {
            // By recreating Ranger at this point, we allow the user to create
            // multiple separate contiguous ranges with SHIFT+Click & Click.
            if (selected) {
                mRanger = new Ranger(position);
                setSelectionFocusBegin(position);
            }
            return selected;
        }
    }

    /**
     * Sets the magic location at which a selection range begins. This
     * value is consulted when determining how to extend, and modify
     * selection ranges.
     *
     * @throws IllegalStateException if {@code position} is not already be selected
     * @param position
     */
    void setSelectionFocusBegin(int position) {
        checkState(mSelection.contains(position));
        mRanger = new Range(position);
    }

    /**
     * Try to select all elements in range. Not that callbacks can cancel selection
     * of specific items, so some or even all items may not reflect the desired
@@ -420,18 +436,18 @@ public final class MultiSelectManager {
    /**
     * Class providing support for managing range selections.
     */
    private final class Ranger {
    private final class Range {
        private static final int UNDEFINED = -1;

        final int mBegin;
        int mEnd = UNDEFINED;

        public Ranger(int begin) {
        public Range(int begin) {
            if (DEBUG) Log.d(TAG, String.format("New Ranger(%d) created.", begin));
            mBegin = begin;
        }

        void snapSelection(int position) {
        private void snapSelection(int position) {
            checkState(mRanger != null);
            checkArgument(position != RecyclerView.NO_POSITION);

@@ -720,12 +736,17 @@ public final class MultiSelectManager {
    /**
     * A composite {@code OnGestureDetector} that allows us to delegate unhandled
     * events to an outside party (presumably DirectoryFragment).
     * @template A gestureDelegate that implements both {@link OnGestureListener}
     *     and {@link OnDoubleTapListener}
     */
    private static final class CompositeOnGestureListener implements OnGestureListener {
    private static final class
            CompositeOnGestureListener<L extends OnGestureListener & OnDoubleTapListener>
            implements OnGestureListener, OnDoubleTapListener {

        private OnGestureListener[] mListeners;
        private L[] mListeners;

        public CompositeOnGestureListener(OnGestureListener... listeners) {
        @SafeVarargs
        public CompositeOnGestureListener(L... listeners) {
            mListeners = listeners;
        }

@@ -782,5 +803,35 @@ public final class MultiSelectManager {
            }
            return false;
        }

        @Override
        public boolean onSingleTapConfirmed(MotionEvent e) {
            for (int i = 0; i < mListeners.length; i++) {
                if (mListeners[i].onSingleTapConfirmed(e)) {
                    return true;
                }
            }
            return false;
        }

        @Override
        public boolean onDoubleTap(MotionEvent e) {
            for (int i = 0; i < mListeners.length; i++) {
                if (mListeners[i].onDoubleTap(e)) {
                    return true;
                }
            }
            return false;
        }

        @Override
        public boolean onDoubleTapEvent(MotionEvent e) {
            for (int i = 0; i < mListeners.length; i++) {
                if (mListeners[i].onDoubleTapEvent(e)) {
                    return true;
                }
            }
            return false;
        }
    }
}
+65 −26
Original line number Diff line number Diff line
@@ -64,6 +64,35 @@ public class MultiSelectManagerTest {
        mManager.addCallback(mCallback);
    }

    @Test
    public void mouseClick_StartsSelectionMode() {
        click(7);
        assertSelection(7);
    }

    @Test
    public void mouseClick_ShiftClickExtendsSelection() {
        click(7);
        shiftClick(11);
        assertRangeSelection(7, 11);
    }

    @Test
    public void mouseClick_NoPosition_ClearsSelection() {
        mManager.onLongPress(7);
        click(11);
        click(RecyclerView.NO_POSITION);
        assertSelection();
    }

    @Test
    public void setSelectionFocusBegin() {
        mManager.setItemSelected(7, true);
        mManager.setSelectionFocusBegin(7);
        shiftClick(11);
        assertRangeSelection(7, 11);
    }

    @Test
    public void longPress_StartsSelectionMode() {
        mManager.onLongPress(7);
@@ -77,64 +106,58 @@ public class MultiSelectManagerTest {
        assertSelection(7, 99);
    }

    @Test
    public void singleTapUp_DoesNotSelectBeforeLongPress() {
        mManager.onSingleTapUp(99, 0);
        assertSelection();
    }

    @Test
    public void singleTapUp_UnselectsSelectedItem() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(7, 0);
        tap(7);
        assertSelection();
    }

    @Test
    public void singleTapUp_NoPositionClearsSelection() {
    public void singleTapUp_NoPosition_ClearsSelection() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(11, 0);
        mManager.onSingleTapUp(RecyclerView.NO_POSITION, 0);
        tap(11);
        tap(RecyclerView.NO_POSITION);
        assertSelection();
    }

    @Test
    public void singleTapUp_ExtendsSelection() {
        mManager.onLongPress(99);
        mManager.onSingleTapUp(7, 0);
        mManager.onSingleTapUp(13, 0);
        mManager.onSingleTapUp(129899, 0);
        tap(7);
        tap(13);
        tap(129899);
        assertSelection(7, 99, 13, 129899);
    }

    @Test
    public void singleTapUp_ShiftCreatesRangeSelection() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
        shiftTap(17);
        assertRangeSelection(7, 17);
    }

    @Test
    public void singleTapUp_ShiftCreatesRangeSeletion_Backwards() {
        mManager.onLongPress(17);
        mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
        shiftTap(7);
        assertRangeSelection(7, 17);
    }

    @Test
    public void singleTapUp_SecondShiftClickExtendsSelection() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
        shiftTap(11);
        shiftTap(17);
        assertRangeSelection(7, 17);
    }

    @Test
    public void singleTapUp_MultipleContiguousRangesSelected() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(11, KeyEvent.META_SHIFT_ON);
        mManager.onSingleTapUp(20, 0);
        mManager.onSingleTapUp(25, KeyEvent.META_SHIFT_ON);
        shiftTap(11);
        tap(20);
        shiftTap(25);
        assertRangeSelected(7, 11);
        assertRangeSelected(20, 25);
        assertSelectionSize(11);
@@ -143,16 +166,16 @@ public class MultiSelectManagerTest {
    @Test
    public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
        mManager.onSingleTapUp(10, KeyEvent.META_SHIFT_ON);
        shiftTap(17);
        shiftTap(10);
        assertRangeSelection(7, 10);
    }

    @Test
    public void singleTapUp_ShiftReducesSelectionRange_FromPreviousShiftClick_Backwards() {
        mManager.onLongPress(17);
        mManager.onSingleTapUp(7, KeyEvent.META_SHIFT_ON);
        mManager.onSingleTapUp(14, KeyEvent.META_SHIFT_ON);
        shiftTap(7);
        shiftTap(14);
        assertRangeSelection(14, 17);
    }

@@ -160,11 +183,27 @@ public class MultiSelectManagerTest {
    @Test
    public void singleTapUp_ShiftReversesSelectionDirection() {
        mManager.onLongPress(7);
        mManager.onSingleTapUp(17, KeyEvent.META_SHIFT_ON);
        mManager.onSingleTapUp(0, KeyEvent.META_SHIFT_ON);
        shiftTap(17);
        shiftTap(0);
        assertRangeSelection(0, 7);
    }

    private void tap(int position) {
        mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
    }

    private void shiftTap(int position) {
        mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_FINGER);
    }

    private void click(int position) {
        mManager.onSingleTapUp(position, 0, MotionEvent.TOOL_TYPE_MOUSE);
    }

    private void shiftClick(int position) {
        mManager.onSingleTapUp(position, KeyEvent.META_SHIFT_ON, MotionEvent.TOOL_TYPE_MOUSE);
    }

    private void assertSelected(int... expected) {
        for (int i = 0; i < expected.length; i++) {
            Selection selection = mManager.getSelection();