1 /*
2  * Copyright 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.recyclerview.selection;
18 
19 import static androidx.core.util.Preconditions.checkArgument;
20 import static androidx.core.util.Preconditions.checkState;
21 import static androidx.recyclerview.selection.Shared.VERBOSE;
22 
23 import android.graphics.Point;
24 import android.graphics.Rect;
25 import android.util.Log;
26 import android.view.MotionEvent;
27 
28 import androidx.annotation.DrawableRes;
29 import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
30 import androidx.recyclerview.widget.RecyclerView;
31 import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
32 import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
33 
34 import org.jspecify.annotations.NonNull;
35 import org.jspecify.annotations.Nullable;
36 
37 import java.util.Set;
38 
39 /**
40  * Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
41  * instance. This class is responsible for rendering a band overlay and manipulating selection
42  * status of the items it intersects with.
43  *
44  * <p>
45  * Given the recycling nature of RecyclerView items that have scrolled off-screen would not
46  * be selectable with a band that itself was partially rendered off-screen. To address this,
47  * BandSelectionController builds a model of the list/grid information presented by RecyclerView as
48  * the user interacts with items using their pointer (and the band). Selectable items that intersect
49  * with the band, both on and off screen, are selected on pointer up.
50  *
51  * @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
52  */
53 class BandSelectionHelper<K> implements OnItemTouchListener, Resettable {
54 
55     static final String TAG = "BandSelectionHelper";
56     static final boolean DEBUG = false;
57 
58     private final BandHost<K> mHost;
59     private final ItemKeyProvider<K> mKeyProvider;
60     @SuppressWarnings("WeakerAccess") /* synthetic access */
61     final SelectionTracker<K> mSelectionTracker;
62     private final BandPredicate mBandPredicate;
63     private final FocusDelegate<K> mFocusDelegate;
64     private final OperationMonitor mLock;
65     private final AutoScroller mScroller;
66     private final GridModel.SelectionObserver<K> mGridObserver;
67 
68     private @Nullable Point mCurrentPosition;
69     private @Nullable Point mOrigin;
70     private @Nullable GridModel<K> mModel;
71 
72     /**
73      * See {@link BandSelectionHelper#create}.
74      */
BandSelectionHelper( @onNull BandHost<K> host, @NonNull AutoScroller scroller, @NonNull ItemKeyProvider<K> keyProvider, @NonNull SelectionTracker<K> selectionTracker, @NonNull BandPredicate bandPredicate, @NonNull FocusDelegate<K> focusDelegate, @NonNull OperationMonitor lock)75     BandSelectionHelper(
76             @NonNull BandHost<K> host,
77             @NonNull AutoScroller scroller,
78             @NonNull ItemKeyProvider<K> keyProvider,
79             @NonNull SelectionTracker<K> selectionTracker,
80             @NonNull BandPredicate bandPredicate,
81             @NonNull FocusDelegate<K> focusDelegate,
82             @NonNull OperationMonitor lock) {
83 
84         checkArgument(host != null);
85         checkArgument(scroller != null);
86         checkArgument(keyProvider != null);
87         checkArgument(selectionTracker != null);
88         checkArgument(bandPredicate != null);
89         checkArgument(focusDelegate != null);
90         checkArgument(lock != null);
91 
92         mHost = host;
93         mKeyProvider = keyProvider;
94         mSelectionTracker = selectionTracker;
95         mBandPredicate = bandPredicate;
96         mFocusDelegate = focusDelegate;
97         mLock = lock;
98 
99         mHost.addOnScrollListener(
100                 new OnScrollListener() {
101                     @Override
102                     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
103                         BandSelectionHelper.this.onScrolled(recyclerView, dx, dy);
104                     }
105                 });
106 
107         mScroller = scroller;
108 
109         mGridObserver = new GridModel.SelectionObserver<K>() {
110             @Override
111             public void onSelectionChanged(Set<K> updatedSelection) {
112                 mSelectionTracker.setProvisionalSelection(updatedSelection);
113             }
114         };
115     }
116 
117     /**
118      * Creates a new instance.
119      *
120      * @return new BandSelectionHelper instance.
121      */
create( @onNull 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)122     static <K> BandSelectionHelper<K> create(
123             @NonNull RecyclerView recyclerView,
124             @NonNull AutoScroller scroller,
125             @DrawableRes int bandOverlayId,
126             @NonNull ItemKeyProvider<K> keyProvider,
127             @NonNull SelectionTracker<K> selectionTracker,
128             @NonNull SelectionPredicate<K> selectionPredicate,
129             @NonNull BandPredicate bandPredicate,
130             @NonNull FocusDelegate<K> focusDelegate,
131             @NonNull OperationMonitor lock) {
132 
133         return new BandSelectionHelper<>(
134                 new DefaultBandHost<>(recyclerView, bandOverlayId, keyProvider, selectionPredicate),
135                 scroller,
136                 keyProvider,
137                 selectionTracker,
138                 bandPredicate,
139                 focusDelegate,
140                 lock);
141     }
142 
isActive()143     private boolean isActive() {
144         boolean started = mModel != null;
145         if (DEBUG) mLock.checkStarted(started);
146         return started;
147     }
148 
149     /**
150      * Clients must call reset when there are any material changes to the layout of items
151      * in RecyclerView.
152      */
153     @Override
reset()154     public void reset() {
155         if (!isActive()) {
156             if (DEBUG) Log.d(TAG, "Ignoring reset request, not active.");
157             return;
158         }
159         if (DEBUG) Log.d(TAG, "Handling reset request.");
160         mHost.hideBand();
161         if (mModel != null) {
162             mModel.stopCapturing();
163             mModel.onDestroy();
164         }
165 
166         mModel = null;
167         mOrigin = null;
168 
169         mScroller.reset();
170         // mLock is reset by reset manager.
171     }
172 
173     @Override
isResetRequired()174     public boolean isResetRequired() {
175         return isActive();
176     }
177 
shouldStart(@onNull MotionEvent e)178     private boolean shouldStart(@NonNull MotionEvent e) {
179         // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
180         // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
181         // mouse moves.
182         return MotionEvents.isPrimaryMouseButtonPressed(e)
183                 && MotionEvents.isActionMove(e)
184                 && mBandPredicate.canInitiate(e)
185                 && !isActive();
186     }
187 
shouldStop(@onNull MotionEvent e)188     private boolean shouldStop(@NonNull MotionEvent e) {
189         return isActive() && MotionEvents.isActionUp(e);
190     }
191 
192     @Override
onInterceptTouchEvent(@onNull RecyclerView unused, @NonNull MotionEvent e)193     public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
194         if (shouldStart(e)) {
195             startBandSelect(e);
196         } else if (shouldStop(e)) {
197             endBandSelect();
198         }
199 
200         return isActive();
201     }
202 
203     /**
204      * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
205      */
206     @Override
onTouchEvent(@onNull RecyclerView unused, @NonNull MotionEvent e)207     public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
208         if (shouldStop(e)) {
209             endBandSelect();
210             return;
211         }
212 
213         // We shouldn't get any events in this method when band select is not active,
214         // but it turns out some guests show up late to the party.
215         // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
216         if (!isActive()) {
217             return;
218         }
219 
220         if (DEBUG) {
221             checkArgument(MotionEvents.isActionMove(e));
222             checkState(mModel != null);
223         }
224 
225         mCurrentPosition = MotionEvents.getOrigin(e);
226 
227         mModel.resizeSelection(mCurrentPosition);
228 
229         resizeBand();
230         mScroller.scroll(mCurrentPosition);
231     }
232 
233     @Override
onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)234     public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
235     }
236 
237     /**
238      * Starts band select by adding the drawable to the RecyclerView's overlay.
239      */
startBandSelect(@onNull MotionEvent e)240     private void startBandSelect(@NonNull MotionEvent e) {
241         if (DEBUG) {
242             checkState(!isActive());
243         }
244 
245         if (!MotionEvents.isCtrlKeyPressed(e)) {
246             mSelectionTracker.clearSelection();
247         }
248 
249         Point origin = MotionEvents.getOrigin(e);
250         if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
251 
252         mModel = mHost.createGridModel();
253         mModel.addOnSelectionChangedListener(mGridObserver);
254 
255         mLock.start();
256         mFocusDelegate.clearFocus();
257         mOrigin = origin;
258         mCurrentPosition = origin;
259 
260         // NOTE: Pay heed that resizeBand modifies the y coordinates
261         // in onScrolled. Not sure if model expects this. If not
262         // it should be defending against this.
263         mModel.startCapturing(mOrigin);
264     }
265 
266     /**
267      * Resizes the band select rectangle by using the origin and the current pointer position as
268      * two opposite corners of the selection.
269      */
resizeBand()270     private void resizeBand() {
271         Rect bounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
272                 Math.min(mOrigin.y, mCurrentPosition.y),
273                 Math.max(mOrigin.x, mCurrentPosition.x),
274                 Math.max(mOrigin.y, mCurrentPosition.y));
275 
276         if (VERBOSE) Log.v(TAG, "Resizing band! " + bounds);
277         mHost.showBand(bounds);
278     }
279 
280     /**
281      * Ends band select by removing the overlay.
282      */
endBandSelect()283     private void endBandSelect() {
284         if (DEBUG) {
285             Log.d(TAG, "Ending band select.");
286             checkState(mModel != null);
287         }
288 
289         // TODO: Currently when a band select operation ends outside
290         // of an item (e.g. in the empty area between items),
291         // getPositionNearestOrigin may return an unselected item.
292         // Since the point of this code is to establish the
293         // anchor point for subsequent range operations (SHIFT+CLICK)
294         // we really want to do a better job figuring out the last
295         // item selected (and nearest to the cursor).
296         int firstSelected = mModel.getPositionNearestOrigin();
297         if (firstSelected != GridModel.NOT_SET
298                 && mSelectionTracker.isSelected(mKeyProvider.getKey(firstSelected))) {
299             // Establish the band selection point as range anchor. This
300             // allows touch and keyboard based selection activities
301             // to be based on the band selection anchor point.
302             mSelectionTracker.anchorRange(firstSelected);
303         }
304 
305         mSelectionTracker.mergeProvisionalSelection();
306         mLock.stop();
307 
308         mHost.hideBand();
309         if (mModel != null) {
310             mModel.stopCapturing();
311             mModel.onDestroy();
312         }
313 
314         mModel = null;
315         mOrigin = null;
316 
317         mScroller.reset();
318     }
319 
320     /**
321      * @see OnScrollListener
322      */
323     @SuppressWarnings("WeakerAccess") /* synthetic access */
onScrolled(@onNull RecyclerView recyclerView, int dx, int dy)324     void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
325         if (!isActive()) {
326             return;
327         }
328 
329         // mOrigin and mCurrentPosition should never be null when onScrolled is called,
330         // but "never say never" increasingly looks like a motto to follow.
331         // For this reason we guard those specific cases and provide a clear
332         // error message in the logs.
333         if (mOrigin == null) {
334             Log.e(TAG, "onScrolled called while mOrigin null.");
335             if (DEBUG) throw new IllegalStateException("mOrigin is null.");
336             return;
337         }
338 
339         if (mCurrentPosition == null) {
340             Log.e(TAG, "onScrolled called while mCurrentPosition null.");
341             if (DEBUG) throw new IllegalStateException("mCurrentPosition is null.");
342             return;
343         }
344 
345         // Adjust the y-coordinate of the origin the opposite number of pixels so that the
346         // origin remains in the same place relative to the view's items.
347         mOrigin.y -= dy;
348         resizeBand();
349     }
350 
351     /**
352      * Provides functionality for BandController. Exists primarily to tests that are
353      * fully isolated from RecyclerView.
354      *
355      * @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
356      */
357     abstract static class BandHost<K> {
358 
359         /**
360          * Returns a new GridModel instance.
361          */
createGridModel()362         abstract GridModel<K> createGridModel();
363 
364         /**
365          * Show the band covering the bounds.
366          *
367          * @param bounds The boundaries of the band to show.
368          */
showBand(@onNull Rect bounds)369         abstract void showBand(@NonNull Rect bounds);
370 
371         /**
372          * Hide the band.
373          */
hideBand()374         abstract void hideBand();
375 
376         /**
377          * Add a listener to be notified on scroll events.
378          */
addOnScrollListener(@onNull OnScrollListener listener)379         abstract void addOnScrollListener(@NonNull OnScrollListener listener);
380     }
381 }
382