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

Commit d2e8bcc9 authored by Ben Lin's avatar Ben Lin Committed by android-build-merger
Browse files

[multi part] Gesture Multi-Select feature.

am: 40f44889

Change-Id: Iae5e81533e1ab29693ed2e5f2cda9599e2f751a6
parents 2cdee4ba 40f44889
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -39,7 +39,7 @@
        android:background="@color/material_grey_50"
        android:visibility="gone"/>

    <com.android.documentsui.dirlist.TouchSwipeRefreshLayout
    <com.android.documentsui.dirlist.DocumentsSwipeRefreshLayout
        android:id="@+id/refresh_layout"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
@@ -113,6 +113,6 @@
            </LinearLayout>
        </FrameLayout>

    </com.android.documentsui.dirlist.TouchSwipeRefreshLayout>
    </com.android.documentsui.dirlist.DocumentsSwipeRefreshLayout>

</com.android.documentsui.dirlist.AnimationView>
+27 −13
Original line number Diff line number Diff line
@@ -155,7 +155,6 @@ public class DirectoryFragment extends Fragment
    private FocusManager mFocusManager;

    private IconHelper mIconHelper;

    private SwipeRefreshLayout mRefreshLayout;
    private View mEmptyView;
    private RecyclerView mRecView;
@@ -322,10 +321,21 @@ public class DirectoryFragment extends Fragment
                this::onActivate,
                (DocumentDetails ignored) -> {
                    return onDeleteSelectedDocuments();
                });
                },
                this::onDragAndDrop,
                this::onGestureMultiSelect);

        final int edgeHeight = (int) getResources().getDimension(R.dimen.autoscroll_edge_height);
        mMultiSelectHelper = GestureMultiSelectHelper.create(mColumnCount,
                edgeHeight,
                mAdapter::getModelId,
                mSelectionMgr,
                mRecView);
        mMultiSelectHelper.setEnabled(state.derivedMode == MODE_GRID);

        mGestureDetector =
                new ListeningGestureDetector(this.getContext(), mDragHelper, mInputHandler);
                new ListeningGestureDetector(this.getContext(), mDragHelper,
                        mInputHandler, mMultiSelectHelper);

        mRecView.addOnItemTouchListener(mGestureDetector);
        mEmptyView.setOnTouchListener(mGestureDetector);
@@ -530,6 +540,7 @@ public class DirectoryFragment extends Fragment

    private void updateDisplayState() {
        State state = getDisplayState();
        mMultiSelectHelper.setEnabled(state.derivedMode == MODE_GRID);
        updateLayout(state.derivedMode);
        mRecView.setAdapter(mAdapter);
    }
@@ -1302,11 +1313,6 @@ public class DirectoryFragment extends Fragment
            // is handled at the list/grid view level.
            view.setOnDragListener(mOnDragListener);
        }

        if (mTuner.dragAndDropEnabled()) {
            // Make all items draggable.
            view.setOnLongClickListener(onLongClickListener);
        }
    }

    void dragStarted() {
@@ -1555,13 +1561,21 @@ public class DirectoryFragment extends Fragment


    private DragStartHelper mDragHelper = new DragStartHelper(null, mOnDragStartListener);
    private GestureMultiSelectHelper mMultiSelectHelper;

    private View.OnLongClickListener onLongClickListener = new View.OnLongClickListener() {
        @Override
        public boolean onLongClick(View v) {
            return mDragHelper.onLongClick(v);
    private boolean onDragAndDrop(InputEvent event) {
        if (mTuner.dragAndDropEnabled()) {
            View childView = mRecView.findChildViewUnder(event.getX(), event.getY());
            return mDragHelper.onLongClick(childView);
        }
        return false;
    }

    private boolean onGestureMultiSelect(InputEvent event) {
        mMultiSelectHelper.start();

        return true;
    }
    };

    private boolean canSelect(DocumentDetails doc) {
        return canSelect(doc.getModelId());
+6 −5
Original line number Diff line number Diff line
@@ -26,18 +26,19 @@ import android.view.MotionEvent;
import com.android.documentsui.Events;

/**
 * A {@link SwipeRefreshLayout} that only refresh on touch events.
 * A {@link SwipeRefreshLayout} that does not intercept any touch events. This relies on its nested
 * view to scroll in order to cause a refresh.
 */
public class TouchSwipeRefreshLayout extends SwipeRefreshLayout {
public class DocumentsSwipeRefreshLayout extends SwipeRefreshLayout {

    private static final int[] COLOR_RES = new int[] { android.R.attr.colorAccent };
    private static int COLOR_ACCENT_INDEX = 0;

    public TouchSwipeRefreshLayout(Context context) {
    public DocumentsSwipeRefreshLayout(Context context) {
        this(context, null);
    }

    public TouchSwipeRefreshLayout(Context context, AttributeSet attrs) {
    public DocumentsSwipeRefreshLayout(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray a = context.obtainStyledAttributes(COLOR_RES);
@@ -48,6 +49,6 @@ public class TouchSwipeRefreshLayout extends SwipeRefreshLayout {

    @Override
    public boolean onInterceptTouchEvent(MotionEvent e) {
        return Events.isMouseEvent(e) ? false : super.onInterceptTouchEvent(e);
        return false;
    }
}
+353 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2016 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.dirlist;

import android.annotation.IntDef;
import android.graphics.Point;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;

import com.android.documentsui.Events.InputEvent;
import com.android.documentsui.Events.MotionInputEvent;
import com.android.documentsui.dirlist.ViewAutoScroller.ScrollActionDelegate;
import com.android.documentsui.dirlist.ViewAutoScroller.ScrollDistanceDelegate;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Function;
import java.util.function.IntSupplier;

/*
 * Helper class used to intercept events that could cause a gesture multi-select, and keeps
 * the interception going if necessary.
 */
class GestureMultiSelectHelper {

    // Gesture can be used to either select or erase file selections. These are used to define the
    // type of on-going gestures.
    @IntDef(flag = true, value = {
            TYPE_NONE,
            TYPE_SELECTION,
            TYPE_ERASE
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface SelectType {}
    public static final int TYPE_NONE = 0;
    public static final int TYPE_SELECTION = 1;
    public static final int TYPE_ERASE = 2;

    // User intent. When intercepting an event, we can see if user intends to scroll, select, or
    // the intent is unknown.
    @IntDef(flag = true, value = {
            TYPE_UNKNOWN,
            TYPE_SELECT,
            TYPE_SCROLL
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface GestureSelectIntent {}
    public static final int TYPE_UNKNOWN = 0;
    public static final int TYPE_SELECT = 1;
    public static final int TYPE_SCROLL = 2;

    private final MultiSelectManager mSelectionMgr;
    private final Runnable mDragScroller;
    private final Function<Integer, String> mModelIdFinder;
    private final int mAutoScrollEdgeHeight;
    private final int mColumnCount;
    private final IntSupplier mHeight;
    private final Set<String> mCurrentSelectedIds = new HashSet<>();
    private int mLastGlidedItemPos = -1;
    private int mLastStartedItemPos = -1;
    private boolean mEnabled = false;
    private Point mLastStartedPoint;
    private Point mLastInterceptedPoint;
    private @SelectType int mType = TYPE_NONE;
    private @GestureSelectIntent int mUserIntent = TYPE_UNKNOWN;

    GestureMultiSelectHelper(
            int columnCount,
            int autoScrollEdgeHeight,
            Function<Integer, String> modelIdFinder,
            MultiSelectManager selectionMgr,
            IntSupplier heightSupplier,
            ScrollActionDelegate actionDelegate) {
        mColumnCount = columnCount;
        mAutoScrollEdgeHeight = autoScrollEdgeHeight;
        mModelIdFinder = modelIdFinder;
        mSelectionMgr = selectionMgr;
        mHeight = heightSupplier;

        ScrollDistanceDelegate distanceDelegate = new ScrollDistanceDelegate() {
            @Override
            public Point getCurrentPosition() {
                return mLastInterceptedPoint;
            }

            @Override
            public int getViewHeight() {
                return mHeight.getAsInt();
            }

            @Override
            public boolean isActive() {
                return mSelectionMgr.hasSelection();
            }
        };

        mDragScroller = new ViewAutoScroller(
                mAutoScrollEdgeHeight, distanceDelegate, actionDelegate);
    }

    static GestureMultiSelectHelper create(
            int columnCount,
            int autoScrollEdgeHeight,
            Function<Integer, String> modelIdFinder,
            MultiSelectManager selectionMgr,
            View scrollView) {
        ScrollActionDelegate actionDelegate = new ScrollActionDelegate() {
            @Override
            public void scrollBy(int dy) {
                scrollView.scrollBy(0, dy);
            }

            @Override
            public void runAtNextFrame(Runnable r) {
                scrollView.postOnAnimation(r);

            }

            @Override
            public void removeCallback(Runnable r) {
                scrollView.removeCallbacks(r);
            }
        };
        GestureMultiSelectHelper helper =
                new GestureMultiSelectHelper(columnCount, autoScrollEdgeHeight, modelIdFinder,
                        selectionMgr, scrollView::getHeight, actionDelegate);

        return helper;
    }

    // Explicitly kick off a gesture multi-select without any second guessing
    void start() {
        mUserIntent = TYPE_SELECT;
    }

    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {

        if (!mEnabled) {
            return false;
        }

        boolean handled = false;
        if (e.getAction() == MotionEvent.ACTION_DOWN) {
            handled = handleInterceptedDownEvent(rv, e);
        }

        if (e.getAction() == MotionEvent.ACTION_MOVE) {
            handled = handleInterceptedMoveEvent(rv, e);
        }

        if (e.getAction() == MotionEvent.ACTION_UP) {
            handled = handleUpEvent(rv, e);
        }

        return handled;
    }

    public void onTouchEvent(RecyclerView rv, MotionEvent e) {
        if (!mEnabled) {
            return;
        }

        if (e.getAction() == MotionEvent.ACTION_UP) {
            handleUpEvent(rv, e);
        }

        if (e.getAction() == MotionEvent.ACTION_MOVE) {
            handleOnTouchMoveEvent(rv, e);
        }
    }

    public void setEnabled(boolean enabled) {
        mEnabled = enabled;
    }

    // Called when an ACTION_DOWN event is intercepted.
    // Sets mode to ERASE if the item below the MotionEvent is already selected
    // Else, sets it to SELECTION mode.
    private boolean handleInterceptedDownEvent(RecyclerView rv, MotionEvent e) {
        View itemView = rv.findChildViewUnder(e.getX(), e.getY());
        try (InputEvent event = MotionInputEvent.obtain(e, rv)) {
            mLastStartedPoint = event.getOrigin();
            if (itemView != null) {
                mLastStartedItemPos = rv.getChildAdapterPosition(itemView);
                mLastGlidedItemPos = mLastStartedItemPos;
                String modelId = mModelIdFinder.apply(mLastStartedItemPos);
                if (mSelectionMgr.getSelection().contains(modelId)) {
                    mType = TYPE_ERASE;
                } else {
                    mType = TYPE_SELECTION;
                }
            }
        }
        return false;
    }

    // Called when an ACTION_MOVE event is intercepted.
    private boolean handleInterceptedMoveEvent(RecyclerView rv, MotionEvent e) {
        if (shouldInterceptMoveEvent(rv, e)) {
            View itemView = rv.findChildViewUnder(e.getX(), e.getY());
            int pos = rv.getChildAdapterPosition(itemView);
            String modelId = mModelIdFinder.apply(pos);
            mCurrentSelectedIds.add(modelId);
            return true;
        }
        return false;
    }

    // Called when ACTION_UP event is intercepted.
    // Essentially, since this means all gesture movement is over, reset everything.
    private boolean handleUpEvent(RecyclerView rv, MotionEvent e) {
        mType = TYPE_NONE;
        mLastGlidedItemPos = -1;
        mLastStartedItemPos = -1;
        mLastStartedPoint = null;
        mUserIntent = TYPE_UNKNOWN;
        mCurrentSelectedIds.clear();
        return false;
    }

    // Call when an intercepted ACTION_MOVE event is passed down.
    // At this point, we are sure user wants to gesture multi-select.
    private void handleOnTouchMoveEvent(RecyclerView rv, MotionEvent e) {
        try (InputEvent event = MotionInputEvent.obtain(e, rv)) {
            mLastInterceptedPoint = event.getOrigin();

            // If user has moved his pointer to the bottom-right empty pane (ie. to the right of the
            // last item of the recycler view), we would want to set that as the currentItemPos
            View lastItem = rv.getLayoutManager()
                    .getChildAt(rv.getLayoutManager().getChildCount() - 1);
            boolean bottomRight = e.getX() > lastItem.getRight() && e.getY() > lastItem.getTop();

            // Since views get attached & detached from RecyclerView,
            // {@link LayoutManager#getChildCount} can return a different number from the actual
            // number
            // of items in the adapter. Using the adapter is the for sure way to get the actual last
            // item position.
            int lastGlidedItemPos = (bottomRight) ? rv.getAdapter().getItemCount() - 1
                    : rv.getChildAdapterPosition(rv.findChildViewUnder(e.getX(), e.getY()));
            if (lastGlidedItemPos != RecyclerView.NO_POSITION
                    && mLastGlidedItemPos != lastGlidedItemPos) {
                doGestureMultiSelect(mLastStartedItemPos, lastGlidedItemPos);
                mLastGlidedItemPos = lastGlidedItemPos;
            }
            if (insideDragZone(rv)) {
                mDragScroller.run();
            }
        }
    }

    /* Given the start position and the end position, select or erase everything in-between.
     * @param startPos The adapter position of the start item.
     * @param endPos  The adapter position of the end item.
     */
    private void doGestureMultiSelect(int startPos, int endPos) {
        boolean selectionMode = (mType == TYPE_SELECTION);

        // First, reset everything that's currently selected/erased except the start item
        mCurrentSelectedIds.remove(mModelIdFinder.apply(startPos));
        mSelectionMgr.setItemsSelected(mCurrentSelectedIds, !selectionMode);

        // Then clear the set
        mCurrentSelectedIds.clear();

        // Add everything to be selected/erased
        if (startPos > endPos) {
            addItemsToModelIds(endPos, startPos);
        } else {
            addItemsToModelIds(startPos, endPos);
        }

        mSelectionMgr.setItemsSelected(mCurrentSelectedIds, selectionMode);
    }

    // Helper for {@code doGestureMultiSelect (int, int)}. Add all items from startPos <= i <=
    // endPos into mModelIds.
    private void addItemsToModelIds(int startPos, int endPos) {
        for (int i = startPos; i <= endPos; i++) {
            String modelId = mModelIdFinder.apply(i);
            if (modelId != null) {
                mCurrentSelectedIds.add(modelId);
            }
        }
    }

    // Logic dictating whether a particular ACTION_MOVE event should be intercepted or not.
    // If user has already shown some clear intent to want to select, we will always return true.
    // If user has moved to an adjacent item, two possible cases:
    // 1. User moved left/right. Then it's explicit that they want to multi-select.
    // 2. User moved top/bottom. Then it's explicit that they want to scroll/natural behavior.
    private boolean shouldInterceptMoveEvent(RecyclerView rv, MotionEvent e) {
        try (InputEvent event = MotionInputEvent.obtain(e, rv)) {
            mLastInterceptedPoint = event.getOrigin();

            if (mUserIntent == TYPE_SELECT) {
                return true;
            }

            int startItemPos = rv.getChildAdapterPosition(rv.findChildViewUnder(mLastStartedPoint.x,
                    mLastStartedPoint.y));
            int currentItemPos = rv
                    .getChildAdapterPosition(rv.findChildViewUnder(e.getX(), e.getY()));
            if (startItemPos == RecyclerView.NO_POSITION ||
                    currentItemPos == RecyclerView.NO_POSITION) {
                // It's possible that user either started gesture from an empty space, or is so far
                // moving his finger to an empty space. Either way, we should not consume the event,
                // so
                // return false.
                return false;
            }

            if (mLastGlidedItemPos != currentItemPos) {
                int diff = Math.abs(startItemPos - currentItemPos);
                if (diff == 1 && mSelectionMgr.hasSelection()) {
                    mUserIntent = TYPE_SELECT;
                    return true;
                } else if (diff == mColumnCount) {
                    mUserIntent = TYPE_SCROLL;
                }
            }
        }
        return false;
    }

    private boolean insideDragZone(View scrollView) {
        if (mLastInterceptedPoint == null) {
            return false;
        }

        boolean shouldScrollUp = mLastInterceptedPoint.y < mAutoScrollEdgeHeight
                && scrollView.canScrollVertically(-1);
        boolean shouldScrollDown = mLastInterceptedPoint.y > scrollView.getHeight() -
                mAutoScrollEdgeHeight && scrollView.canScrollVertically(1);
        return shouldScrollUp || shouldScrollDown;
    }
}
 No newline at end of file
+25 −15
Original line number Diff line number Diff line
@@ -25,41 +25,51 @@ import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnTouchListener;

import com.android.documentsui.Events.InputEvent;

//Receives event meant for both directory and empty view, and either pass them to
//{@link UserInputHandler} for simple gestures (Single Tap, Long-Press), or intercept them for
//other types of gestures (drag n' drop)
final class ListeningGestureDetector extends GestureDetector
        implements OnItemTouchListener, OnTouchListener {

    private DragStartHelper mDragHelper;
    private UserInputHandler mInputHandler;
    private final DragStartHelper mDragHelper;
    private final GestureMultiSelectHelper mGestureSelectHelper;

    public ListeningGestureDetector(
            Context context, DragStartHelper dragHelper, UserInputHandler handler) {
            Context context,
            DragStartHelper dragHelper,
            UserInputHandler<? extends InputEvent> handler,
            GestureMultiSelectHelper gestureMultiSelectHelper) {
        super(context, handler);
        mDragHelper = dragHelper;
        mInputHandler = handler;
        mGestureSelectHelper = gestureMultiSelectHelper;
        setOnDoubleTapListener(handler);
    }

    @Override
    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
        // Detect drag events. When a drag is detected, intercept the rest of the gesture.
        // Detect drag events from mouse. When a drag is detected, intercept the rest of the
        // gesture.
        View itemView = rv.findChildViewUnder(e.getX(), e.getY());
        if (itemView != null && mDragHelper.onTouch(itemView, e)) {
            return true;
        }

        if (mGestureSelectHelper.onInterceptTouchEvent(rv, e)) {
            return true;
        }

        // Forward unhandled events to UserInputHandler.
        return onTouchEvent(e);
    }

    @Override
    public void onTouchEvent(RecyclerView rv, MotionEvent e) {
        View itemView = rv.findChildViewUnder(e.getX(), e.getY());
        mDragHelper.onTouch(itemView,  e);
        // Note: even though this event is being handled as part of a drag gesture, continue
        // forwarding to the GestureDetector. The detector needs to see the entire cluster of
        // events in order to properly interpret gestures.
        mGestureSelectHelper.onTouchEvent(rv, e);

        // Note: even though this event is being handled as part of gesture-multi select, continue
        // forwarding to the GestureDetector. The detector needs to see the entire cluster of events
        // in order to properly interpret other gestures, such as long press.
        onTouchEvent(e);
    }

Loading