| /* |
| * Copyright 2017 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 androidx.recyclerview.selection; |
| |
| import static androidx.core.util.Preconditions.checkArgument; |
| import static androidx.core.util.Preconditions.checkState; |
| |
| import android.graphics.Point; |
| import android.util.Log; |
| import android.view.MotionEvent; |
| import android.view.View; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.core.view.ViewCompat; |
| import androidx.recyclerview.widget.RecyclerView; |
| import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; |
| |
| /** |
| * 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. |
| */ |
| final class GestureSelectionHelper implements OnItemTouchListener { |
| |
| private static final String TAG = "GestureSelectionHelper"; |
| |
| private final SelectionTracker<?> mSelectionMgr; |
| private final AutoScroller mScroller; |
| private final ViewDelegate mView; |
| private final OperationMonitor mLock; |
| |
| private int mLastStartedItemPos = -1; |
| private boolean mStarted = false; |
| private Point mLastInterceptedPoint; |
| |
| /** |
| * See {@link GestureSelectionHelper#create} for convenience |
| * method. |
| */ |
| GestureSelectionHelper( |
| @NonNull SelectionTracker<?> selectionTracker, |
| @NonNull ViewDelegate view, |
| @NonNull AutoScroller scroller, |
| @NonNull OperationMonitor lock) { |
| |
| checkArgument(selectionTracker != null); |
| checkArgument(view != null); |
| checkArgument(scroller != null); |
| checkArgument(lock != null); |
| |
| mSelectionMgr = selectionTracker; |
| mView = view; |
| mScroller = scroller; |
| mLock = lock; |
| } |
| |
| /** |
| * Explicitly kicks off a gesture multi-select. |
| */ |
| 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 (mLastStartedItemPos < 0) { |
| 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.checkStopped(); |
| |
| mStarted = true; |
| mLock.start(); |
| } |
| |
| @Override |
| /** @hide */ |
| public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { |
| if (MotionEvents.isMouseEvent(e)) { |
| if (Shared.DEBUG) Log.w(TAG, "Unexpected Mouse event. Check configuration."); |
| } |
| |
| switch (e.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: |
| // NOTE: Unlike events with other actions, RecyclerView eats |
| // "DOWN" events. So even if we return true here we'll |
| // never see an event w/ ACTION_DOWN passed to onTouchEvent. |
| return handleInterceptedDownEvent(e); |
| case MotionEvent.ACTION_MOVE: |
| return mStarted; |
| } |
| |
| return false; |
| } |
| |
| @Override |
| /** @hide */ |
| public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { |
| checkState(mStarted); |
| |
| switch (e.getActionMasked()) { |
| case MotionEvent.ACTION_MOVE: |
| handleMoveEvent(e); |
| break; |
| case MotionEvent.ACTION_UP: |
| handleUpEvent(e); |
| break; |
| case MotionEvent.ACTION_CANCEL: |
| handleCancelEvent(e); |
| break; |
| } |
| } |
| |
| @Override |
| /** @hide */ |
| public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { |
| } |
| |
| // Called when an ACTION_DOWN event is intercepted. |
| // If down event happens on an item, we mark that item's position as last started. |
| private boolean handleInterceptedDownEvent(@NonNull MotionEvent e) { |
| mLastStartedItemPos = mView.getItemUnder(e); |
| return mLastStartedItemPos != RecyclerView.NO_POSITION; |
| } |
| |
| // 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(@NonNull MotionEvent e) { |
| mSelectionMgr.mergeProvisionalSelection(); |
| endSelection(); |
| if (mLastStartedItemPos > -1) { |
| mSelectionMgr.startRange(mLastStartedItemPos); |
| } |
| } |
| |
| // 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(@NonNull MotionEvent unused) { |
| mSelectionMgr.clearProvisionalSelection(); |
| endSelection(); |
| } |
| |
| private void endSelection() { |
| checkState(mStarted); |
| |
| mLastStartedItemPos = -1; |
| mStarted = false; |
| mScroller.reset(); |
| mLock.stop(); |
| } |
| |
| // 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(@NonNull MotionEvent e) { |
| mLastInterceptedPoint = MotionEvents.getOrigin(e); |
| |
| int lastGlidedItemPos = mView.getLastGlidedItemPosition(e); |
| if (lastGlidedItemPos != RecyclerView.NO_POSITION) { |
| extendSelection(lastGlidedItemPos); |
| } |
| |
| mScroller.scroll(mLastInterceptedPoint); |
| } |
| |
| // 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 extendSelection(int endPos) { |
| mSelectionMgr.extendProvisionalRange(endPos); |
| } |
| |
| /** |
| * Returns a new instance of GestureSelectionHelper. |
| */ |
| static GestureSelectionHelper create( |
| @NonNull SelectionTracker selectionMgr, |
| @NonNull RecyclerView recyclerView, |
| @NonNull AutoScroller scroller, |
| @NonNull OperationMonitor lock) { |
| |
| return new GestureSelectionHelper( |
| selectionMgr, |
| new RecyclerViewDelegate(recyclerView), |
| scroller, |
| lock); |
| } |
| |
| @VisibleForTesting |
| abstract static class ViewDelegate { |
| abstract int getHeight(); |
| |
| abstract int getItemUnder(@NonNull MotionEvent e); |
| |
| abstract int getLastGlidedItemPosition(@NonNull MotionEvent e); |
| } |
| |
| @VisibleForTesting |
| static final class RecyclerViewDelegate extends ViewDelegate { |
| |
| private final RecyclerView mRecyclerView; |
| |
| RecyclerViewDelegate(@NonNull RecyclerView recyclerView) { |
| checkArgument(recyclerView != null); |
| mRecyclerView = recyclerView; |
| } |
| |
| @Override |
| int getHeight() { |
| return mRecyclerView.getHeight(); |
| } |
| |
| @Override |
| int getItemUnder(@NonNull MotionEvent e) { |
| View child = mRecyclerView.findChildViewUnder(e.getX(), e.getY()); |
| return child != null |
| ? mRecyclerView.getChildAdapterPosition(child) |
| : RecyclerView.NO_POSITION; |
| } |
| |
| @Override |
| int getLastGlidedItemPosition(@NonNull 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 = mRecyclerView.getLayoutManager() |
| .getChildAt(mRecyclerView.getLayoutManager().getChildCount() - 1); |
| int direction = ViewCompat.getLayoutDirection(mRecyclerView); |
| 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(mRecyclerView.getHeight(), e.getY()); |
| return (pastLastItem) ? mRecyclerView.getAdapter().getItemCount() - 1 |
| : mRecyclerView.getChildAdapterPosition( |
| mRecyclerView.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, @NonNull 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; |
| } |
| } |
| } |
| } |