blob: 6aabd45490cd37285c7e5347564cdb9cd23256ef [file] [log] [blame]
/*
* 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 static androidx.recyclerview.selection.Shared.VERBOSE;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
import android.view.MotionEvent;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import java.util.Set;
/**
* Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
* instance. This class is responsible for rendering a band overlay and manipulating selection
* status of the items it intersects with.
*
* <p>
* Given the recycling nature of RecyclerView items that have scrolled off-screen would not
* be selectable with a band that itself was partially rendered off-screen. To address this,
* BandSelectionController builds a model of the list/grid information presented by RecyclerView as
* the user interacts with items using their pointer (and the band). Selectable items that intersect
* with the band, both on and off screen, are selected on pointer up.
*
* @see SelectionTracker.Builder#withPointerTooltypes(int...) for details on the specific
* tooltypes routed to this helper.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
class BandSelectionHelper<K> implements OnItemTouchListener {
static final String TAG = "BandSelectionHelper";
static final boolean DEBUG = false;
private final BandHost mHost;
private final ItemKeyProvider<K> mKeyProvider;
private final SelectionTracker<K> mSelectionTracker;
private final BandPredicate mBandPredicate;
private final FocusDelegate<K> mFocusDelegate;
private final OperationMonitor mLock;
private final AutoScroller mScroller;
private final GridModel.SelectionObserver mGridObserver;
private @Nullable Point mCurrentPosition;
private @Nullable Point mOrigin;
private @Nullable GridModel mModel;
/**
* See {@link BandSelectionHelper#create}.
*/
BandSelectionHelper(
@NonNull BandHost host,
@NonNull AutoScroller scroller,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull SelectionTracker<K> selectionTracker,
@NonNull BandPredicate bandPredicate,
@NonNull FocusDelegate<K> focusDelegate,
@NonNull OperationMonitor lock) {
checkArgument(host != null);
checkArgument(scroller != null);
checkArgument(keyProvider != null);
checkArgument(selectionTracker != null);
checkArgument(bandPredicate != null);
checkArgument(focusDelegate != null);
checkArgument(lock != null);
mHost = host;
mKeyProvider = keyProvider;
mSelectionTracker = selectionTracker;
mBandPredicate = bandPredicate;
mFocusDelegate = focusDelegate;
mLock = lock;
mHost.addOnScrollListener(
new OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
BandSelectionHelper.this.onScrolled(recyclerView, dx, dy);
}
});
mScroller = scroller;
mGridObserver = new GridModel.SelectionObserver<K>() {
@Override
public void onSelectionChanged(Set<K> updatedSelection) {
mSelectionTracker.setProvisionalSelection(updatedSelection);
}
};
}
/**
* Creates a new instance.
*
* @return new BandSelectionHelper instance.
*/
static <K> BandSelectionHelper create(
@NonNull RecyclerView recyclerView,
@NonNull AutoScroller scroller,
@DrawableRes int bandOverlayId,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull SelectionTracker<K> selectionTracker,
@NonNull SelectionPredicate<K> selectionPredicate,
@NonNull BandPredicate bandPredicate,
@NonNull FocusDelegate<K> focusDelegate,
@NonNull OperationMonitor lock) {
return new BandSelectionHelper<>(
new DefaultBandHost<>(recyclerView, bandOverlayId, keyProvider, selectionPredicate),
scroller,
keyProvider,
selectionTracker,
bandPredicate,
focusDelegate,
lock);
}
@VisibleForTesting
boolean isActive() {
boolean active = mModel != null;
if (DEBUG && active) {
mLock.checkStarted();
}
return active;
}
/**
* Clients must call reset when there are any material changes to the layout of items
* in RecyclerView.
*/
void reset() {
if (!isActive()) {
return;
}
mHost.hideBand();
if (mModel != null) {
mModel.stopCapturing();
mModel.onDestroy();
}
mModel = null;
mOrigin = null;
mScroller.reset();
mLock.stop();
}
@VisibleForTesting
boolean shouldStart(@NonNull MotionEvent e) {
// b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
// unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
// mouse moves.
return MotionEvents.isPrimaryMouseButtonPressed(e)
&& MotionEvents.isActionMove(e)
&& mBandPredicate.canInitiate(e)
&& !isActive();
}
@VisibleForTesting
boolean shouldStop(@NonNull MotionEvent e) {
return isActive()
&& (MotionEvents.isActionUp(e)
|| MotionEvents.isActionPointerUp(e)
|| MotionEvents.isActionCancel(e));
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
if (shouldStart(e)) {
startBandSelect(e);
} else if (shouldStop(e)) {
endBandSelect();
}
return isActive();
}
/**
* Processes a MotionEvent by starting, ending, or resizing the band select overlay.
*/
@Override
public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
if (shouldStop(e)) {
endBandSelect();
return;
}
// We shouldn't get any events in this method when band select is not active,
// but it turns some guests show up late to the party.
// Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
if (!isActive()) {
return;
}
if (DEBUG) {
checkArgument(MotionEvents.isActionMove(e));
checkState(mModel != null);
}
mCurrentPosition = MotionEvents.getOrigin(e);
mModel.resizeSelection(mCurrentPosition);
resizeBand();
mScroller.scroll(mCurrentPosition);
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
/**
* Starts band select by adding the drawable to the RecyclerView's overlay.
*/
private void startBandSelect(@NonNull MotionEvent e) {
checkState(!isActive());
if (!MotionEvents.isCtrlKeyPressed(e)) {
mSelectionTracker.clearSelection();
}
Point origin = MotionEvents.getOrigin(e);
if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
mModel = mHost.createGridModel();
mModel.addOnSelectionChangedListener(mGridObserver);
mLock.start();
mFocusDelegate.clearFocus();
mOrigin = origin;
// NOTE: Pay heed that resizeBand modifies the y coordinates
// in onScrolled. Not sure if model expects this. If not
// it should be defending against this.
mModel.startCapturing(mOrigin);
}
/**
* Resizes the band select rectangle by using the origin and the current pointer position as
* two opposite corners of the selection.
*/
private void resizeBand() {
Rect bounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
Math.min(mOrigin.y, mCurrentPosition.y),
Math.max(mOrigin.x, mCurrentPosition.x),
Math.max(mOrigin.y, mCurrentPosition.y));
if (VERBOSE) Log.v(TAG, "Resizing band! " + bounds);
mHost.showBand(bounds);
}
/**
* Ends band select by removing the overlay.
*/
private void endBandSelect() {
if (DEBUG) {
Log.d(TAG, "Ending band select.");
checkState(mModel != null);
}
// TODO: Currently when a band select operation ends outside
// of an item (e.g. in the empty area between items),
// getPositionNearestOrigin may return an unselected item.
// Since the point of this code is to establish the
// anchor point for subsequent range operations (SHIFT+CLICK)
// we really want to do a better job figuring out the last
// item selected (and nearest to the cursor).
int firstSelected = mModel.getPositionNearestOrigin();
if (firstSelected != GridModel.NOT_SET
&& mSelectionTracker.isSelected(mKeyProvider.getKey(firstSelected))) {
// Establish the band selection point as range anchor. This
// allows touch and keyboard based selection activities
// to be based on the band selection anchor point.
mSelectionTracker.anchorRange(firstSelected);
}
mSelectionTracker.mergeProvisionalSelection();
reset();
}
/**
* @see OnScrollListener
*/
private void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (!isActive()) {
return;
}
// Adjust the y-coordinate of the origin the opposite number of pixels so that the
// origin remains in the same place relative to the view's items.
mOrigin.y -= dy;
resizeBand();
}
/**
* Provides functionality for BandController. Exists primarily to tests that are
* fully isolated from RecyclerView.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
abstract static class BandHost<K> {
/**
* Returns a new GridModel instance.
*/
abstract GridModel<K> createGridModel();
/**
* Show the band covering the bounds.
*
* @param bounds The boundaries of the band to show.
*/
abstract void showBand(@NonNull Rect bounds);
/**
* Hide the band.
*/
abstract void hideBand();
/**
* Add a listener to be notified on scroll events.
*
* @param listener
*/
abstract void addOnScrollListener(@NonNull OnScrollListener listener);
}
}