1 /* 2 * Copyright (C) 2015 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 com.android.documentsui.selection; 18 19 import static android.support.v4.util.Preconditions.checkArgument; 20 import static android.support.v4.util.Preconditions.checkState; 21 22 import android.graphics.Point; 23 import android.graphics.Rect; 24 import android.os.Build; 25 import android.support.annotation.Nullable; 26 import android.support.annotation.VisibleForTesting; 27 import android.support.v7.widget.RecyclerView; 28 import android.support.v7.widget.RecyclerView.OnItemTouchListener; 29 import android.support.v7.widget.RecyclerView.OnScrollListener; 30 import android.util.Log; 31 import android.view.MotionEvent; 32 33 import com.android.documentsui.selection.SelectionHelper.SelectionPredicate; 34 import com.android.documentsui.selection.SelectionHelper.StableIdProvider; 35 import com.android.documentsui.selection.ViewAutoScroller.ScrollHost; 36 import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks; 37 38 import java.util.ArrayList; 39 import java.util.List; 40 import java.util.Set; 41 42 /** 43 * Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView} 44 * instance. This class is responsible for rendering a band overlay and manipulating selection 45 * status of the items it intersects with. 46 * 47 * <p> Given the recycling nature of RecyclerView items that have scrolled off-screen would not 48 * be selectable with a band that itself was partially rendered off-screen. To address this, 49 * BandSelectionController builds a model of the list/grid information presented by RecyclerView as 50 * the user interacts with items using their pointer (and the band). Selectable items that intersect 51 * with the band, both on and off screen, are selected on pointer up. 52 */ 53 public class BandSelectionHelper implements OnItemTouchListener { 54 55 static final boolean DEBUG = false; 56 static final String TAG = "BandController"; 57 58 private final BandHost mHost; 59 private final StableIdProvider mStableIds; 60 private final RecyclerView.Adapter<?> mAdapter; 61 private final SelectionHelper mSelectionHelper; 62 private final SelectionPredicate mSelectionPredicate; 63 private final BandPredicate mBandPredicate; 64 private final ContentLock mLock; 65 private final Runnable mViewScroller; 66 private final GridModel.SelectionObserver mGridObserver; 67 private final List<Runnable> mBandStartedListeners = new ArrayList<>(); 68 69 @Nullable private Rect mBounds; 70 @Nullable private Point mCurrentPosition; 71 @Nullable private Point mOrigin; 72 @Nullable private GridModel mModel; 73 74 public BandSelectionHelper( 75 BandHost host, 76 RecyclerView.Adapter<?> adapter, 77 StableIdProvider stableIds, 78 SelectionHelper selectionHelper, 79 SelectionPredicate selectionPredicate, 80 BandPredicate bandPredicate, 81 ContentLock lock) { 82 83 checkArgument(host != null); 84 checkArgument(adapter != null); 85 checkArgument(stableIds != null); 86 checkArgument(selectionHelper != null); 87 checkArgument(selectionPredicate != null); 88 checkArgument(bandPredicate != null); 89 checkArgument(lock != null); 90 91 mHost = host; 92 mStableIds = stableIds; 93 mAdapter = adapter; 94 mSelectionHelper = selectionHelper; 95 mSelectionPredicate = selectionPredicate; 96 mBandPredicate = bandPredicate; 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 mViewScroller = new ViewAutoScroller( 108 new ScrollHost() { 109 @Override 110 public Point getCurrentPosition() { 111 return mCurrentPosition; 112 } 113 114 @Override 115 public int getViewHeight() { 116 return mHost.getHeight(); 117 } 118 119 @Override 120 public boolean isActive() { 121 return BandSelectionHelper.this.isActive(); 122 } 123 }, 124 host); 125 126 mAdapter.registerAdapterDataObserver( 127 new RecyclerView.AdapterDataObserver() { 128 @Override 129 public void onChanged() { 130 if (isActive()) { 131 endBandSelect(); 132 } 133 } 134 135 @Override 136 public void onItemRangeChanged( 137 int startPosition, int itemCount, Object payload) { 138 // No change in position. Ignoring. 139 } 140 141 @Override 142 public void onItemRangeInserted(int startPosition, int itemCount) { 143 if (isActive()) { 144 endBandSelect(); 145 } 146 } 147 148 @Override 149 public void onItemRangeRemoved(int startPosition, int itemCount) { 150 assert(startPosition >= 0); 151 assert(itemCount > 0); 152 153 // TODO: Should update grid model. 154 } 155 156 @Override 157 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 158 throw new UnsupportedOperationException(); 159 } 160 }); 161 162 mGridObserver = new GridModel.SelectionObserver() { 163 @Override 164 public void onSelectionChanged(Set<String> updatedSelection) { 165 mSelectionHelper.setProvisionalSelection(updatedSelection); 166 } 167 }; 168 } 169 170 @VisibleForTesting 171 boolean isActive() { 172 boolean active = mModel != null; 173 if (Build.IS_DEBUGGABLE && active) { 174 mLock.checkLocked(); 175 } 176 return active; 177 } 178 179 /** 180 * Adds a new listener to be notified when band is created. 181 */ 182 public void addOnBandStartedListener(Runnable listener) { 183 checkArgument(listener != null); 184 185 mBandStartedListeners.add(listener); 186 } 187 188 /** 189 * Removes listener. No-op if listener was not previously installed. 190 */ 191 public void removeOnBandStartedListener(Runnable listener) { 192 mBandStartedListeners.remove(listener); 193 } 194 195 /** 196 * Clients must call reset when there are any material changes to the layout of items 197 * in RecyclerView. 198 */ 199 public void reset() { 200 if (!isActive()) { 201 return; 202 } 203 204 mHost.hideBand(); 205 mModel.stopCapturing(); 206 mModel.onDestroy(); 207 mModel = null; 208 mOrigin = null; 209 mLock.unblock(); 210 } 211 212 boolean shouldStart(MotionEvent e) { 213 // Don't start, or extend bands on non-left clicks. 214 if (!MotionEvents.isPrimaryButtonPressed(e)) { 215 return false; 216 } 217 218 // TODO: Refactor to NOT have side-effects on this "should" method. 219 // Weird things happen if we keep up band select 220 // when touch events happen. 221 if (isActive() && !MotionEvents.isMouseEvent(e)) { 222 endBandSelect(); 223 return false; 224 } 225 226 // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent 227 // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when 228 // mouse moves, or else starting band selection on mouse down can cause problems as events 229 // don't get routed correctly to onTouchEvent. 230 return !isActive() 231 && MotionEvents.isActionMove(e) 232 // the initial button move via mouse-touch (ie. down press) 233 // The adapter inserts items for UI layout purposes that aren't 234 // associated with files. Checking against actual modelIds count 235 // effectively ignores those UI layout items. 236 && !mStableIds.getStableIds().isEmpty() 237 && mBandPredicate.canInitiate(e); 238 } 239 240 public boolean shouldStop(MotionEvent e) { 241 return isActive() 242 && MotionEvents.isMouseEvent(e) 243 && (MotionEvents.isActionUp(e) 244 || MotionEvents.isActionPointerUp(e) 245 || MotionEvents.isActionCancel(e)); 246 } 247 248 @Override 249 public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) { 250 if (shouldStart(e)) { 251 if (!MotionEvents.isCtrlKeyPressed(e)) { 252 mSelectionHelper.clearSelection(); 253 } 254 255 startBandSelect(MotionEvents.getOrigin(e)); 256 return isActive(); 257 } 258 259 if (shouldStop(e)) { 260 endBandSelect(); 261 checkState(mModel == null); 262 // fall through to return false, because the band eeess done! 263 } 264 265 return false; 266 } 267 268 /** 269 * Processes a MotionEvent by starting, ending, or resizing the band select overlay. 270 * @param input 271 */ 272 @Override 273 public void onTouchEvent(RecyclerView unused, MotionEvent e) { 274 if (shouldStop(e)) { 275 endBandSelect(); 276 return; 277 } 278 279 // We shouldn't get any events in this method when band select is not active, 280 // but it turns some guests show up late to the party. 281 // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh) 282 if (!isActive()) { 283 return; 284 } 285 286 assert MotionEvents.isActionMove(e); 287 288 mCurrentPosition = MotionEvents.getOrigin(e); 289 mModel.resizeSelection(mCurrentPosition); 290 291 scrollViewIfNecessary(); 292 resizeBand(); 293 } 294 295 @Override 296 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} 297 298 /** 299 * Starts band select by adding the drawable to the RecyclerView's overlay. 300 */ 301 private void startBandSelect(Point origin) { 302 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); 303 304 reset(); 305 mModel = new GridModel(mHost, mStableIds, mSelectionPredicate); 306 mModel.addOnSelectionChangedListener(mGridObserver); 307 308 mLock.block(); 309 notifyBandStarted(); 310 mOrigin = origin; 311 mModel.startCapturing(mOrigin); 312 } 313 314 private void notifyBandStarted() { 315 for (Runnable listener : mBandStartedListeners) { 316 listener.run(); 317 } 318 } 319 320 private void scrollViewIfNecessary() { 321 mHost.removeCallback(mViewScroller); 322 mViewScroller.run(); 323 mHost.invalidateView(); 324 } 325 326 /** 327 * Resizes the band select rectangle by using the origin and the current pointer position as 328 * two opposite corners of the selection. 329 */ 330 private void resizeBand() { 331 mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), 332 Math.min(mOrigin.y, mCurrentPosition.y), 333 Math.max(mOrigin.x, mCurrentPosition.x), 334 Math.max(mOrigin.y, mCurrentPosition.y)); 335 336 mHost.showBand(mBounds); 337 } 338 339 /** 340 * Ends band select by removing the overlay. 341 */ 342 private void endBandSelect() { 343 if (DEBUG) Log.d(TAG, "Ending band select."); 344 345 // TODO: Currently when a band select operation ends outside 346 // of an item (e.g. in the empty area between items), 347 // getPositionNearestOrigin may return an unselected item. 348 // Since the point of this code is to establish the 349 // anchor point for subsequent range operations (SHIFT+CLICK) 350 // we really want to do a better job figuring out the last 351 // item selected (and nearest to the cursor). 352 int firstSelected = mModel.getPositionNearestOrigin(); 353 if (firstSelected != GridModel.NOT_SET 354 && mSelectionHelper.isSelected(mStableIds.getStableId(firstSelected))) { 355 // Establish the band selection point as range anchor. This 356 // allows touch and keyboard based selection activities 357 // to be based on the band selection anchor point. 358 mSelectionHelper.anchorRange(firstSelected); 359 } 360 361 mSelectionHelper.mergeProvisionalSelection(); 362 reset(); 363 } 364 365 /** 366 * @see RecyclerView.OnScrollListener 367 */ 368 private void onScrolled(RecyclerView recyclerView, int dx, int dy) { 369 if (!isActive()) { 370 return; 371 } 372 373 // Adjust the y-coordinate of the origin the opposite number of pixels so that the 374 // origin remains in the same place relative to the view's items. 375 mOrigin.y -= dy; 376 resizeBand(); 377 } 378 379 /** 380 * Provides functionality for BandController. Exists primarily to tests that are 381 * fully isolated from RecyclerView. 382 */ 383 public static abstract class BandHost extends ScrollerCallbacks { 384 public abstract void showBand(Rect rect); 385 public abstract void hideBand(); 386 public abstract void addOnScrollListener(RecyclerView.OnScrollListener listener); 387 public abstract void removeOnScrollListener(RecyclerView.OnScrollListener listener); 388 public abstract int getHeight(); 389 public abstract void invalidateView(); 390 public abstract Point createAbsolutePoint(Point relativePoint); 391 public abstract Rect getAbsoluteRectForChildViewAt(int index); 392 public abstract int getAdapterPositionAt(int index); 393 public abstract int getColumnCount(); 394 public abstract int getChildCount(); 395 public abstract int getVisibleChildCount(); 396 /** 397 * @return true if the item at adapter position is attached to a view. 398 */ 399 public abstract boolean hasView(int adapterPosition); 400 } 401 } 402