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