| /* |
| * 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.selection; |
| |
| import static android.support.v4.util.Preconditions.checkArgument; |
| import static android.support.v4.util.Preconditions.checkState; |
| |
| import android.graphics.Point; |
| import android.os.Build; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.VisibleForTesting; |
| import android.support.v7.widget.RecyclerView; |
| import android.support.v7.widget.RecyclerView.OnItemTouchListener; |
| |
| import android.util.Log; |
| import android.view.MotionEvent; |
| import android.view.View; |
| |
| import com.android.documentsui.selection.ViewAutoScroller.ScrollHost; |
| import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks; |
| |
| /** |
| * GestureSelectionHelper provides logic that interprets a combination |
| * of motions and gestures in order to provide gesture driven selection support |
| * when used in conjunction with RecyclerView and other classes in the ReyclerView |
| * selection support package. |
| */ |
| public final class GestureSelectionHelper extends ScrollHost implements OnItemTouchListener { |
| |
| private static final String TAG = "GestureSelectionHelper"; |
| |
| private final SelectionHelper mSelectionMgr; |
| private final Runnable mScroller; |
| private final ViewDelegate mView; |
| private final ContentLock mLock; |
| private final ItemDetailsLookup mItemLookup; |
| |
| private int mLastTouchedItemPosition = RecyclerView.NO_POSITION; |
| private boolean mStarted = false; |
| private Point mLastInterceptedPoint; |
| |
| /** |
| * See {@link #create(SelectionHelper, RecyclerView, ContentLock)} for convenience |
| * method. |
| */ |
| @VisibleForTesting |
| GestureSelectionHelper( |
| SelectionHelper selectionHelper, |
| ViewDelegate view, |
| ContentLock lock, |
| ItemDetailsLookup itemLookup) { |
| |
| checkArgument(selectionHelper != null); |
| checkArgument(view != null); |
| checkArgument(lock != null); |
| checkArgument(itemLookup != null); |
| |
| mSelectionMgr = selectionHelper; |
| mView = view; |
| mLock = lock; |
| mItemLookup = itemLookup; |
| |
| mScroller = new ViewAutoScroller(this, mView); |
| } |
| |
| /** |
| * Explicitly kicks off a gesture multi-select. |
| * |
| * @return true if started. |
| */ |
| public void start() { |
| checkState(!mStarted); |
| // See: b/70518185. It appears start() is being called via onLongPress |
| // even though we never received an intial handleInterceptedDownEvent |
| // where we would usually initialize mLastStartedItemPos. |
| if (mLastTouchedItemPosition == RecyclerView.NO_POSITION) { |
| Log.w(TAG, "Illegal state. Can't start without valid mLastStartedItemPos."); |
| return; |
| } |
| |
| // Partner code in MotionInputHandler ensures items |
| // are selected and range established prior to |
| // start being called. |
| // Verify the truth of that statement here |
| // to make the implicit coupling less of a time bomb. |
| checkState(mSelectionMgr.isRangeActive()); |
| |
| mLock.checkUnlocked(); |
| |
| mStarted = true; |
| mLock.block(); |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) { |
| if (MotionEvents.isMouseEvent(e)) { |
| if (Shared.DEBUG) Log.w(TAG, "Unexpected Mouse event. Check configuration."); |
| } |
| |
| // TODO(b/109808552): It seems that mLastStartedItemPos should likely be set as a method |
| // parameter in start(). |
| if (e.getActionMasked() == MotionEvent.ACTION_DOWN) { |
| if (mItemLookup.getItemDetails(e) != null) { |
| mLastTouchedItemPosition = mView.getItemUnder(e); |
| } |
| } |
| |
| // See handleTouch(MotionEvent) javadoc for explanation as to why this is correct. |
| return handleTouch(e); |
| } |
| |
| @Override |
| /** @hide */ |
| public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { |
| // See handleTouch(MotionEvent) javadoc for explanation as to why this is correct. |
| handleTouch(e); |
| } |
| |
| /** |
| * If selection has started, will handle all appropriate types of MotionEvents and will return |
| * true if this OnItemTouchListener should start intercepting the rest of the MotionEvents. |
| * |
| * <p>This code, and the fact that this method is used by both OnInterceptTouchEvent and |
| * OnTouchEvent, is correct and valid because: |
| * <ol> |
| * <li>MotionEvents that aren't ACTION_DOWN are only ever passed to either onInterceptTouchEvent |
| * or onTouchEvent; never to both. The MotionEvents we are handling in this method are not |
| * ACTION_DOWN, and therefore, its appropriate that both the onInterceptTouchEvent and |
| * onTouchEvent code paths cross this method. |
| * <li>This method returns true when we want to intercept MotionEvents. OnInterceptTouchEvent |
| * uses that information to determine its own return, and OnMotionEvent doesn't have a return |
| * so this methods return value is irrelevant to it. |
| * </ol> |
| */ |
| private boolean handleTouch(MotionEvent e) { |
| if (!mStarted) { |
| return false; |
| } |
| |
| switch (e.getActionMasked()) { |
| case MotionEvent.ACTION_MOVE: |
| handleMoveEvent(e); |
| return true; |
| case MotionEvent.ACTION_UP: |
| handleUpEvent(); |
| return true; |
| case MotionEvent.ACTION_CANCEL: |
| handleCancelEvent(); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| @Override |
| public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { |
| } |
| |
| // Called when ACTION_UP event is to be handled. |
| // Essentially, since this means all gesture movement is over, reset everything and apply |
| // provisional selection. |
| private void handleUpEvent() { |
| mSelectionMgr.mergeProvisionalSelection(); |
| endSelection(); |
| if (mLastTouchedItemPosition != RecyclerView.NO_POSITION) { |
| mSelectionMgr.startRange(mLastTouchedItemPosition); |
| } |
| } |
| |
| // Called when ACTION_CANCEL event is to be handled. |
| // This means this gesture selection is aborted, so reset everything and abandon provisional |
| // selection. |
| private void handleCancelEvent() { |
| mSelectionMgr.clearProvisionalSelection(); |
| endSelection(); |
| } |
| |
| private void endSelection() { |
| checkState(mStarted); |
| |
| mLastTouchedItemPosition = RecyclerView.NO_POSITION; |
| mStarted = false; |
| mLock.unblock(); |
| } |
| |
| // Call when an intercepted ACTION_MOVE event is passed down. |
| // At this point, we are sure user wants to gesture multi-select. |
| private void handleMoveEvent(MotionEvent e) { |
| mLastInterceptedPoint = MotionEvents.getOrigin(e); |
| |
| int lastGlidedItemPos = mView.getLastGlidedItemPosition(e); |
| if (lastGlidedItemPos != RecyclerView.NO_POSITION) { |
| doGestureMultiSelect(lastGlidedItemPos); |
| } |
| scrollIfNecessary(); |
| } |
| |
| // It's possible for events to go over the top/bottom of the RecyclerView. |
| // We want to get a Y-coordinate within the RecyclerView so we can find the childView underneath |
| // correctly. |
| private static float getInboundY(float max, float y) { |
| if (y < 0f) { |
| return 0f; |
| } else if (y > max) { |
| return max; |
| } |
| return y; |
| } |
| |
| /* Given the end position, select everything in-between. |
| * @param endPos The adapter position of the end item. |
| */ |
| private void doGestureMultiSelect(int endPos) { |
| mSelectionMgr.extendProvisionalRange(endPos); |
| } |
| |
| private void scrollIfNecessary() { |
| mScroller.run(); |
| } |
| |
| @Override |
| public Point getCurrentPosition() { |
| return mLastInterceptedPoint; |
| } |
| |
| @Override |
| public int getViewHeight() { |
| return mView.getHeight(); |
| } |
| |
| @Override |
| public boolean isActive() { |
| return mStarted && mSelectionMgr.hasSelection(); |
| } |
| |
| /** |
| * Returns a new instance of GestureSelectionHelper, wrapping the |
| * RecyclerView in a test friendly wrapper. |
| */ |
| public static GestureSelectionHelper create( |
| SelectionHelper selectionMgr, |
| RecyclerView recycler, |
| ContentLock lock, |
| ItemDetailsLookup itemLookup) { |
| |
| return new GestureSelectionHelper( |
| selectionMgr, new RecyclerViewDelegate(recycler), lock, itemLookup); |
| } |
| |
| @VisibleForTesting |
| static abstract class ViewDelegate extends ScrollerCallbacks { |
| abstract int getHeight(); |
| |
| abstract int getItemUnder(MotionEvent e); |
| |
| abstract int getLastGlidedItemPosition(MotionEvent e); |
| } |
| |
| @VisibleForTesting |
| static final class RecyclerViewDelegate extends ViewDelegate { |
| |
| private final RecyclerView mView; |
| |
| RecyclerViewDelegate(RecyclerView view) { |
| checkArgument(view != null); |
| mView = view; |
| } |
| |
| @Override |
| int getHeight() { |
| return mView.getHeight(); |
| } |
| |
| @Override |
| int getItemUnder(MotionEvent e) { |
| View child = mView.findChildViewUnder(e.getX(), e.getY()); |
| return child != null |
| ? mView.getChildAdapterPosition(child) |
| : RecyclerView.NO_POSITION; |
| } |
| |
| @Override |
| int getLastGlidedItemPosition(MotionEvent e) { |
| // 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 = mView.getLayoutManager() |
| .getChildAt(mView.getLayoutManager().getChildCount() - 1); |
| int direction = |
| mView.getContext().getResources().getConfiguration().getLayoutDirection(); |
| final boolean pastLastItem = isPastLastItem(lastItem.getTop(), |
| lastItem.getLeft(), |
| lastItem.getRight(), |
| e, |
| direction); |
| |
| // 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. |
| final float inboundY = getInboundY(mView.getHeight(), e.getY()); |
| return (pastLastItem) ? mView.getAdapter().getItemCount() - 1 |
| : mView.getChildAdapterPosition(mView.findChildViewUnder(e.getX(), inboundY)); |
| } |
| |
| /* |
| * Check to see if MotionEvent if past a particular item, i.e. to the right or to the bottom |
| * of the item. |
| * For RTL, it would to be to the left or to the bottom of the item. |
| */ |
| @VisibleForTesting |
| static boolean isPastLastItem(int top, int left, int right, MotionEvent e, int direction) { |
| if (direction == View.LAYOUT_DIRECTION_LTR) { |
| return e.getX() > right && e.getY() > top; |
| } else { |
| return e.getX() < left && e.getY() > top; |
| } |
| } |
| |
| @Override |
| public void scrollBy(int dy) { |
| mView.scrollBy(0, dy); |
| } |
| |
| @Override |
| public void runAtNextFrame(Runnable r) { |
| mView.postOnAnimation(r); |
| } |
| |
| @Override |
| public void removeCallback(Runnable r) { |
| mView.removeCallbacks(r); |
| } |
| } |
| } |