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.annotation.NonNull; 30 import androidx.annotation.Nullable; 31 import androidx.annotation.VisibleForTesting; 32 import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate; 33 import androidx.recyclerview.widget.RecyclerView; 34 import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener; 35 import androidx.recyclerview.widget.RecyclerView.OnScrollListener; 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 * @see SelectionTracker.Builder#withPointerTooltypes(int...) for details on the specific 52 * tooltypes routed to this helper. 53 * 54 * @param <K> Selection key type. @see {@link StorageStrategy} for supported types. 55 */ 56 class BandSelectionHelper<K> implements OnItemTouchListener { 57 58 static final String TAG = "BandSelectionHelper"; 59 static final boolean DEBUG = false; 60 61 private final BandHost mHost; 62 private final ItemKeyProvider<K> mKeyProvider; 63 private final SelectionTracker<K> mSelectionTracker; 64 private final BandPredicate mBandPredicate; 65 private final FocusDelegate<K> mFocusDelegate; 66 private final OperationMonitor mLock; 67 private final AutoScroller mScroller; 68 private final GridModel.SelectionObserver mGridObserver; 69 70 private @Nullable Point mCurrentPosition; 71 private @Nullable Point mOrigin; 72 private @Nullable GridModel mModel; 73 74 /** 75 * See {@link BandSelectionHelper#create}. 76 */ 77 BandSelectionHelper( 78 @NonNull BandHost host, 79 @NonNull AutoScroller scroller, 80 @NonNull ItemKeyProvider<K> keyProvider, 81 @NonNull SelectionTracker<K> selectionTracker, 82 @NonNull BandPredicate bandPredicate, 83 @NonNull FocusDelegate<K> focusDelegate, 84 @NonNull OperationMonitor lock) { 85 86 checkArgument(host != null); 87 checkArgument(scroller != null); 88 checkArgument(keyProvider != null); 89 checkArgument(selectionTracker != null); 90 checkArgument(bandPredicate != null); 91 checkArgument(focusDelegate != null); 92 checkArgument(lock != null); 93 94 mHost = host; 95 mKeyProvider = keyProvider; 96 mSelectionTracker = selectionTracker; 97 mBandPredicate = bandPredicate; 98 mFocusDelegate = focusDelegate; 99 mLock = lock; 100 101 mHost.addOnScrollListener( 102 new OnScrollListener() { 103 @Override 104 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 105 BandSelectionHelper.this.onScrolled(recyclerView, dx, dy); 106 } 107 }); 108 109 mScroller = scroller; 110 111 mGridObserver = new GridModel.SelectionObserver<K>() { 112 @Override 113 public void onSelectionChanged(Set<K> updatedSelection) { 114 mSelectionTracker.setProvisionalSelection(updatedSelection); 115 } 116 }; 117 } 118 119 /** 120 * Creates a new instance. 121 * 122 * @return new BandSelectionHelper instance. 123 */ 124 static <K> BandSelectionHelper create( 125 @NonNull RecyclerView recyclerView, 126 @NonNull AutoScroller scroller, 127 @DrawableRes int bandOverlayId, 128 @NonNull ItemKeyProvider<K> keyProvider, 129 @NonNull SelectionTracker<K> selectionTracker, 130 @NonNull SelectionPredicate<K> selectionPredicate, 131 @NonNull BandPredicate bandPredicate, 132 @NonNull FocusDelegate<K> focusDelegate, 133 @NonNull OperationMonitor lock) { 134 135 return new BandSelectionHelper<>( 136 new DefaultBandHost<>(recyclerView, bandOverlayId, keyProvider, selectionPredicate), 137 scroller, 138 keyProvider, 139 selectionTracker, 140 bandPredicate, 141 focusDelegate, 142 lock); 143 } 144 145 @VisibleForTesting 146 boolean isActive() { 147 boolean active = mModel != null; 148 if (DEBUG && active) { 149 mLock.checkStarted(); 150 } 151 return active; 152 } 153 154 /** 155 * Clients must call reset when there are any material changes to the layout of items 156 * in RecyclerView. 157 */ 158 void reset() { 159 if (!isActive()) { 160 return; 161 } 162 163 mHost.hideBand(); 164 if (mModel != null) { 165 mModel.stopCapturing(); 166 mModel.onDestroy(); 167 } 168 169 mModel = null; 170 mOrigin = null; 171 172 mScroller.reset(); 173 mLock.stop(); 174 } 175 176 @VisibleForTesting 177 boolean shouldStart(@NonNull MotionEvent e) { 178 // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent 179 // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when 180 // mouse moves. 181 return MotionEvents.isPrimaryMouseButtonPressed(e) 182 && MotionEvents.isActionMove(e) 183 && mBandPredicate.canInitiate(e) 184 && !isActive(); 185 } 186 187 @VisibleForTesting 188 boolean shouldStop(@NonNull MotionEvent e) { 189 return isActive() 190 && (MotionEvents.isActionUp(e) 191 || MotionEvents.isActionPointerUp(e) 192 || MotionEvents.isActionCancel(e)); 193 } 194 195 @Override 196 public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { 197 if (shouldStart(e)) { 198 startBandSelect(e); 199 } else if (shouldStop(e)) { 200 endBandSelect(); 201 } 202 203 return isActive(); 204 } 205 206 /** 207 * Processes a MotionEvent by starting, ending, or resizing the band select overlay. 208 */ 209 @Override 210 public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) { 211 if (shouldStop(e)) { 212 endBandSelect(); 213 return; 214 } 215 216 // We shouldn't get any events in this method when band select is not active, 217 // but it turns some guests show up late to the party. 218 // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh) 219 if (!isActive()) { 220 return; 221 } 222 223 if (DEBUG) { 224 checkArgument(MotionEvents.isActionMove(e)); 225 checkState(mModel != null); 226 } 227 228 mCurrentPosition = MotionEvents.getOrigin(e); 229 230 mModel.resizeSelection(mCurrentPosition); 231 232 resizeBand(); 233 mScroller.scroll(mCurrentPosition); 234 } 235 236 @Override 237 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { 238 } 239 240 /** 241 * Starts band select by adding the drawable to the RecyclerView's overlay. 242 */ 243 private void startBandSelect(@NonNull MotionEvent e) { 244 checkState(!isActive()); 245 246 if (!MotionEvents.isCtrlKeyPressed(e)) { 247 mSelectionTracker.clearSelection(); 248 } 249 250 Point origin = MotionEvents.getOrigin(e); 251 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); 252 253 mModel = mHost.createGridModel(); 254 mModel.addOnSelectionChangedListener(mGridObserver); 255 256 mLock.start(); 257 mFocusDelegate.clearFocus(); 258 mOrigin = origin; 259 // NOTE: Pay heed that resizeBand modifies the y coordinates 260 // in onScrolled. Not sure if model expects this. If not 261 // it should be defending against this. 262 mModel.startCapturing(mOrigin); 263 } 264 265 /** 266 * Resizes the band select rectangle by using the origin and the current pointer position as 267 * two opposite corners of the selection. 268 */ 269 private void resizeBand() { 270 Rect bounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), 271 Math.min(mOrigin.y, mCurrentPosition.y), 272 Math.max(mOrigin.x, mCurrentPosition.x), 273 Math.max(mOrigin.y, mCurrentPosition.y)); 274 275 if (VERBOSE) Log.v(TAG, "Resizing band! " + bounds); 276 mHost.showBand(bounds); 277 } 278 279 /** 280 * Ends band select by removing the overlay. 281 */ 282 private void endBandSelect() { 283 if (DEBUG) { 284 Log.d(TAG, "Ending band select."); 285 checkState(mModel != null); 286 } 287 288 // TODO: Currently when a band select operation ends outside 289 // of an item (e.g. in the empty area between items), 290 // getPositionNearestOrigin may return an unselected item. 291 // Since the point of this code is to establish the 292 // anchor point for subsequent range operations (SHIFT+CLICK) 293 // we really want to do a better job figuring out the last 294 // item selected (and nearest to the cursor). 295 int firstSelected = mModel.getPositionNearestOrigin(); 296 if (firstSelected != GridModel.NOT_SET 297 && mSelectionTracker.isSelected(mKeyProvider.getKey(firstSelected))) { 298 // Establish the band selection point as range anchor. This 299 // allows touch and keyboard based selection activities 300 // to be based on the band selection anchor point. 301 mSelectionTracker.anchorRange(firstSelected); 302 } 303 304 mSelectionTracker.mergeProvisionalSelection(); 305 reset(); 306 } 307 308 /** 309 * @see OnScrollListener 310 */ 311 private void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { 312 if (!isActive()) { 313 return; 314 } 315 316 // Adjust the y-coordinate of the origin the opposite number of pixels so that the 317 // origin remains in the same place relative to the view's items. 318 mOrigin.y -= dy; 319 resizeBand(); 320 } 321 322 /** 323 * Provides functionality for BandController. Exists primarily to tests that are 324 * fully isolated from RecyclerView. 325 * 326 * @param <K> Selection key type. @see {@link StorageStrategy} for supported types. 327 */ 328 abstract static class BandHost<K> { 329 330 /** 331 * Returns a new GridModel instance. 332 */ 333 abstract GridModel<K> createGridModel(); 334 335 /** 336 * Show the band covering the bounds. 337 * 338 * @param bounds The boundaries of the band to show. 339 */ 340 abstract void showBand(@NonNull Rect bounds); 341 342 /** 343 * Hide the band. 344 */ 345 abstract void hideBand(); 346 347 /** 348 * Add a listener to be notified on scroll events. 349 * 350 * @param listener 351 */ 352 abstract void addOnScrollListener(@NonNull OnScrollListener listener); 353 } 354 } 355