/*
* 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.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.selection.SelectionTracker.SelectionPredicate;
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 SelectionTracker.SelectionPredicate<?> mSelectionPredicate;
private final AutoScroller mScroller;
private final ViewDelegate mView;
private final OperationMonitor mLock;
private boolean mStarted = false;
/**
* See {@link GestureSelectionHelper#create} for convenience
* method.
*/
GestureSelectionHelper(
@NonNull SelectionTracker<?> selectionTracker,
@NonNull SelectionPredicate<?> selectionPredicate,
@NonNull ViewDelegate view,
@NonNull AutoScroller scroller,
@NonNull OperationMonitor lock) {
checkArgument(selectionTracker != null);
checkArgument(selectionPredicate != null);
checkArgument(view != null);
checkArgument(scroller != null);
checkArgument(lock != null);
mSelectionMgr = selectionTracker;
mSelectionPredicate = selectionPredicate;
mView = view;
mScroller = scroller;
mLock = lock;
}
/**
* Explicitly kicks off a gesture multi-select.
*/
void start() {
checkState(!mStarted);
// Partner code in MotionInputHandler ensures items
// are selected and range anchor initialized 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) {
switch (e.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
return mStarted;
default:
return false;
}
}
@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;
}
if (!mSelectionMgr.isRangeActive()) {
Log.e(TAG,
"Internal state of GestureSelectionHelper out of sync w/ SelectionTracker "
+ "(isRangeActive is false). Ignoring event and resetting state.");
endSelection();
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
/** @hide */
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();
}
// 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);
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) {
int lastGlidedItemPos = mView.getLastGlidedItemPosition(e);
if (mSelectionPredicate.canSetStateAtPosition(lastGlidedItemPos, true)) {
extendSelection(lastGlidedItemPos);
}
mScroller.scroll(MotionEvents.getOrigin(e));
}
// 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.
@SuppressWarnings("WeakerAccess") /* synthetic access */
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 SelectionPredicate<?> selectionPredicate,
@NonNull RecyclerView recyclerView,
@NonNull AutoScroller scroller,
@NonNull OperationMonitor lock) {
return new GestureSelectionHelper(
selectionMgr,
selectionPredicate,
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;
}
}
}
}