/* * 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. * *

* 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 Selection key type. @see {@link StorageStrategy} for supported types. */ class BandSelectionHelper implements OnItemTouchListener { static final String TAG = "BandSelectionHelper"; static final boolean DEBUG = false; private final BandHost mHost; private final ItemKeyProvider mKeyProvider; private final SelectionTracker mSelectionTracker; private final BandPredicate mBandPredicate; private final FocusDelegate 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 keyProvider, @NonNull SelectionTracker selectionTracker, @NonNull BandPredicate bandPredicate, @NonNull FocusDelegate 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() { @Override public void onSelectionChanged(Set updatedSelection) { mSelectionTracker.setProvisionalSelection(updatedSelection); } }; } /** * Creates a new instance. * * @return new BandSelectionHelper instance. */ static BandSelectionHelper create( @NonNull RecyclerView recyclerView, @NonNull AutoScroller scroller, @DrawableRes int bandOverlayId, @NonNull ItemKeyProvider keyProvider, @NonNull SelectionTracker selectionTracker, @NonNull SelectionPredicate selectionPredicate, @NonNull BandPredicate bandPredicate, @NonNull FocusDelegate 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 Selection key type. @see {@link StorageStrategy} for supported types. */ abstract static class BandHost { /** * Returns a new GridModel instance. */ abstract GridModel 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); } }