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 com.android.documentsui.base.Shared.DEBUG; 20 import static com.android.documentsui.ui.ViewAutoScroller.NOT_SET; 21 22 import android.graphics.Point; 23 import android.graphics.Rect; 24 import android.graphics.drawable.Drawable; 25 import android.support.annotation.Nullable; 26 import android.support.annotation.VisibleForTesting; 27 import android.support.v7.widget.GridLayoutManager; 28 import android.support.v7.widget.RecyclerView; 29 import android.support.v7.widget.RecyclerView.OnScrollListener; 30 import android.util.Log; 31 import android.util.SparseArray; 32 import android.util.SparseBooleanArray; 33 import android.util.SparseIntArray; 34 import android.view.View; 35 36 import com.android.documentsui.DirectoryReloadLock; 37 import com.android.documentsui.R; 38 import com.android.documentsui.base.Events.InputEvent; 39 import com.android.documentsui.dirlist.DocumentsAdapter; 40 import com.android.documentsui.ui.ViewAutoScroller; 41 import com.android.documentsui.ui.ViewAutoScroller.ScrollActionDelegate; 42 import com.android.documentsui.ui.ViewAutoScroller.ScrollDistanceDelegate; 43 44 import java.util.ArrayList; 45 import java.util.Collections; 46 import java.util.HashSet; 47 import java.util.List; 48 import java.util.Map; 49 import java.util.Set; 50 import java.util.function.IntPredicate; 51 52 /** 53 * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView} 54 * and {@link SelectionManager}. This class is responsible for rendering the band select 55 * overlay and selecting overlaid items via SelectionManager. 56 */ 57 public class BandController extends OnScrollListener { 58 59 private static final String TAG = "BandController"; 60 61 private final Runnable mModelBuilder; 62 private final SelectionEnvironment mEnvironment; 63 private final DocumentsAdapter mAdapter; 64 private final SelectionManager mSelectionManager; 65 private final DirectoryReloadLock mLock; 66 private final Runnable mViewScroller; 67 private final GridModel.OnSelectionChangedListener mGridListener; 68 69 @Nullable private Rect mBounds; 70 @Nullable private Point mCurrentPosition; 71 @Nullable private Point mOrigin; 72 @Nullable private BandController.GridModel mModel; 73 74 private Selection mSelection; 75 76 public BandController( 77 final RecyclerView view, 78 DocumentsAdapter adapter, 79 SelectionManager selectionManager, 80 DirectoryReloadLock lock, 81 IntPredicate gridItemTester) { 82 this(new RuntimeSelectionEnvironment(view), adapter, selectionManager, lock, gridItemTester); 83 } 84 85 @VisibleForTesting 86 BandController( 87 SelectionEnvironment env, 88 DocumentsAdapter adapter, 89 SelectionManager selectionManager, 90 DirectoryReloadLock lock, 91 IntPredicate gridItemTester) { 92 93 mLock = lock; 94 selectionManager.bindContoller(this); 95 96 mEnvironment = env; 97 mAdapter = adapter; 98 mSelectionManager = selectionManager; 99 100 mEnvironment.addOnScrollListener(this); 101 mViewScroller = new ViewAutoScroller( 102 new ScrollDistanceDelegate() { 103 @Override 104 public Point getCurrentPosition() { 105 return mCurrentPosition; 106 } 107 108 @Override 109 public int getViewHeight() { 110 return mEnvironment.getHeight(); 111 } 112 113 @Override 114 public boolean isActive() { 115 return BandController.this.isActive(); 116 } 117 }, 118 env); 119 120 mAdapter.registerAdapterDataObserver( 121 new RecyclerView.AdapterDataObserver() { 122 @Override 123 public void onChanged() { 124 if (isActive()) { 125 endBandSelect(); 126 } 127 } 128 129 @Override 130 public void onItemRangeChanged( 131 int startPosition, int itemCount, Object payload) { 132 // No change in position. Ignoring. 133 } 134 135 @Override 136 public void onItemRangeInserted(int startPosition, int itemCount) { 137 if (isActive()) { 138 endBandSelect(); 139 } 140 } 141 142 @Override 143 public void onItemRangeRemoved(int startPosition, int itemCount) { 144 assert(startPosition >= 0); 145 assert(itemCount > 0); 146 147 // TODO: Should update grid model. 148 } 149 150 @Override 151 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 152 throw new UnsupportedOperationException(); 153 } 154 }); 155 156 mGridListener = new GridModel.OnSelectionChangedListener() { 157 158 @Override 159 public void onSelectionChanged(Set<String> updatedSelection) { 160 BandController.this.onSelectionChanged(updatedSelection); 161 } 162 163 @Override 164 public boolean onBeforeItemStateChange(String id, boolean nextState) { 165 return BandController.this.onBeforeItemStateChange(id, nextState); 166 } 167 }; 168 169 mModelBuilder = new Runnable() { 170 @Override 171 public void run() { 172 mModel = new GridModel(mEnvironment, gridItemTester, mAdapter); 173 mModel.addOnSelectionChangedListener(mGridListener); 174 } 175 }; 176 } 177 178 @VisibleForTesting 179 boolean isActive() { 180 return mModel != null; 181 } 182 183 void bindSelection(Selection selection) { 184 mSelection = selection; 185 } 186 187 public boolean onInterceptTouchEvent(InputEvent e) { 188 if (shouldStart(e)) { 189 if (!e.isCtrlKeyDown()) { 190 mSelectionManager.clearSelection(); 191 } 192 startBandSelect(e.getOrigin()); 193 } else if (shouldStop(e)) { 194 endBandSelect(); 195 } 196 197 return isActive(); 198 } 199 200 /** 201 * Handle a change in layout by cleaning up and getting rid of the old model and creating 202 * a new model which will track the new layout. 203 */ 204 public void handleLayoutChanged() { 205 if (mModel != null) { 206 mModel.removeOnSelectionChangedListener(mGridListener); 207 mModel.stopListening(); 208 209 // build a new model, all fresh and happy. 210 mModelBuilder.run(); 211 } 212 } 213 214 public boolean shouldStart(InputEvent e) { 215 // Don't start, or extend bands on non-left clicks. 216 if (!e.isPrimaryButtonPressed()) { 217 return false; 218 } 219 220 if (!e.isMouseEvent() && isActive()) { 221 // Weird things happen if we keep up band select 222 // when touch events happen. 223 endBandSelect(); 224 return false; 225 } 226 227 // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent 228 // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when 229 // mouse moves, or else starting band selection on mouse down can cause problems as events 230 // don't get routed correctly to onTouchEvent. 231 return !isActive() 232 && e.isActionMove() // the initial button move via mouse-touch (ie. down press) 233 && mAdapter.hasModelIds() // we want to check against actual modelIds count to 234 // avoid dummy view count from the AdapterWrapper 235 && !e.isOverDragHotspot(); 236 237 } 238 239 public boolean shouldStop(InputEvent input) { 240 return isActive() 241 && input.isMouseEvent() 242 && (input.isActionUp() || input.isMultiPointerActionUp() || input.isActionCancel()); 243 } 244 245 /** 246 * Processes a MotionEvent by starting, ending, or resizing the band select overlay. 247 * @param input 248 */ 249 public void onTouchEvent(InputEvent input) { 250 assert(input.isMouseEvent()); 251 252 if (shouldStop(input)) { 253 endBandSelect(); 254 return; 255 } 256 257 // We shouldn't get any events in this method when band select is not active, 258 // but it turns some guests show up late to the party. 259 // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh) 260 if (!isActive()) { 261 return; 262 } 263 264 assert(input.isActionMove()); 265 mCurrentPosition = input.getOrigin(); 266 mModel.resizeSelection(input.getOrigin()); 267 scrollViewIfNecessary(); 268 resizeBandSelectRectangle(); 269 } 270 271 /** 272 * Starts band select by adding the drawable to the RecyclerView's overlay. 273 */ 274 private void startBandSelect(Point origin) { 275 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); 276 277 mLock.block(); 278 mOrigin = origin; 279 mModelBuilder.run(); // Creates a new selection model. 280 mModel.startSelection(mOrigin); 281 } 282 283 /** 284 * Scrolls the view if necessary. 285 */ 286 private void scrollViewIfNecessary() { 287 mEnvironment.removeCallback(mViewScroller); 288 mViewScroller.run(); 289 mEnvironment.invalidateView(); 290 } 291 292 /** 293 * Resizes the band select rectangle by using the origin and the current pointer position as 294 * two opposite corners of the selection. 295 */ 296 private void resizeBandSelectRectangle() { 297 mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), 298 Math.min(mOrigin.y, mCurrentPosition.y), 299 Math.max(mOrigin.x, mCurrentPosition.x), 300 Math.max(mOrigin.y, mCurrentPosition.y)); 301 mEnvironment.showBand(mBounds); 302 } 303 304 /** 305 * Ends band select by removing the overlay. 306 */ 307 private void endBandSelect() { 308 if (DEBUG) Log.d(TAG, "Ending band select."); 309 310 mEnvironment.hideBand(); 311 mSelection.applyProvisionalSelection(); 312 mModel.endSelection(); 313 int firstSelected = mModel.getPositionNearestOrigin(); 314 if (firstSelected != NOT_SET) { 315 if (mSelection.contains(mAdapter.getModelId(firstSelected))) { 316 // TODO: firstSelected should really be lastSelected, we want to anchor the item 317 // where the mouse-up occurred. 318 mSelectionManager.setSelectionRangeBegin(firstSelected); 319 } else { 320 // TODO: Check if this is really happening. 321 Log.w(TAG, "First selected by band is NOT in selection!"); 322 } 323 } 324 325 mModel = null; 326 mOrigin = null; 327 mLock.unblock(); 328 } 329 330 private void onSelectionChanged(Set<String> updatedSelection) { 331 Map<String, Boolean> delta = mSelection.setProvisionalSelection(updatedSelection); 332 for (Map.Entry<String, Boolean> entry: delta.entrySet()) { 333 mSelectionManager.notifyItemStateChanged(entry.getKey(), entry.getValue()); 334 } 335 mSelectionManager.notifySelectionChanged(); 336 } 337 338 private boolean onBeforeItemStateChange(String id, boolean nextState) { 339 return mSelectionManager.canSetState(id, nextState); 340 } 341 342 @Override 343 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 344 if (!isActive()) { 345 return; 346 } 347 348 // Adjust the y-coordinate of the origin the opposite number of pixels so that the 349 // origin remains in the same place relative to the view's items. 350 mOrigin.y -= dy; 351 resizeBandSelectRectangle(); 352 } 353 354 /** 355 * Provides a band selection item model for views within a RecyclerView. This class queries the 356 * RecyclerView to determine where its items are placed; then, once band selection is underway, 357 * it alerts listeners of which items are covered by the selections. 358 */ 359 @VisibleForTesting 360 static final class GridModel extends RecyclerView.OnScrollListener { 361 362 public static final int NOT_SET = -1; 363 364 // Enum values used to determine the corner at which the origin is located within the 365 private static final int UPPER = 0x00; 366 private static final int LOWER = 0x01; 367 private static final int LEFT = 0x00; 368 private static final int RIGHT = 0x02; 369 private static final int UPPER_LEFT = UPPER | LEFT; 370 private static final int UPPER_RIGHT = UPPER | RIGHT; 371 private static final int LOWER_LEFT = LOWER | LEFT; 372 private static final int LOWER_RIGHT = LOWER | RIGHT; 373 374 private final SelectionEnvironment mHelper; 375 private final IntPredicate mGridItemTester; 376 private final DocumentsAdapter mAdapter; 377 378 private final List<GridModel.OnSelectionChangedListener> mOnSelectionChangedListeners = 379 new ArrayList<>(); 380 381 // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed 382 // by their y-offset. For example, if the first column of the view starts at an x-value of 5, 383 // mColumns.get(5) would return an array of positions in that column. Within that array, the 384 // value for key y is the adapter position for the item whose y-offset is y. 385 private final SparseArray<SparseIntArray> mColumns = new SparseArray<>(); 386 387 // List of limits along the x-axis (columns). 388 // This list is sorted from furthest left to furthest right. 389 private final List<GridModel.Limits> mColumnBounds = new ArrayList<>(); 390 391 // List of limits along the y-axis (rows). Note that this list only contains items which 392 // have been in the viewport. 393 private final List<GridModel.Limits> mRowBounds = new ArrayList<>(); 394 395 // The adapter positions which have been recorded so far. 396 private final SparseBooleanArray mKnownPositions = new SparseBooleanArray(); 397 398 // Array passed to registered OnSelectionChangedListeners. One array is created and reused 399 // throughout the lifetime of the object. 400 private final Set<String> mSelection = new HashSet<>(); 401 402 // The current pointer (in absolute positioning from the top of the view). 403 private Point mPointer = null; 404 405 // The bounds of the band selection. 406 private RelativePoint mRelativeOrigin; 407 private RelativePoint mRelativePointer; 408 409 private boolean mIsActive; 410 411 // Tracks where the band select originated from. This is used to determine where selections 412 // should expand from when Shift+click is used. 413 private int mPositionNearestOrigin = NOT_SET; 414 415 GridModel(SelectionEnvironment helper, IntPredicate gridItemTester, DocumentsAdapter adapter) { 416 mHelper = helper; 417 mAdapter = adapter; 418 mGridItemTester = gridItemTester; 419 mHelper.addOnScrollListener(this); 420 } 421 422 /** 423 * Stops listening to the view's scrolls. Call this function before discarding a 424 * BandSelecModel object to prevent memory leaks. 425 */ 426 void stopListening() { 427 mHelper.removeOnScrollListener(this); 428 } 429 430 /** 431 * Start a band select operation at the given point. 432 * @param relativeOrigin The origin of the band select operation, relative to the viewport. 433 * For example, if the view is scrolled to the bottom, the top-left of the viewport 434 * would have a relative origin of (0, 0), even though its absolute point has a higher 435 * y-value. 436 */ 437 void startSelection(Point relativeOrigin) { 438 recordVisibleChildren(); 439 if (isEmpty()) { 440 // The selection band logic works only if there is at least one visible child. 441 return; 442 } 443 444 mIsActive = true; 445 mPointer = mHelper.createAbsolutePoint(relativeOrigin); 446 mRelativeOrigin = new RelativePoint(mPointer); 447 mRelativePointer = new RelativePoint(mPointer); 448 computeCurrentSelection(); 449 notifyListeners(); 450 } 451 452 /** 453 * Resizes the selection by adjusting the pointer (i.e., the corner of the selection 454 * opposite the origin. 455 * @param relativePointer The pointer (opposite of the origin) of the band select operation, 456 * relative to the viewport. For example, if the view is scrolled to the bottom, the 457 * top-left of the viewport would have a relative origin of (0, 0), even though its 458 * absolute point has a higher y-value. 459 */ 460 @VisibleForTesting 461 void resizeSelection(Point relativePointer) { 462 mPointer = mHelper.createAbsolutePoint(relativePointer); 463 updateModel(); 464 } 465 466 /** 467 * Ends the band selection. 468 */ 469 void endSelection() { 470 mIsActive = false; 471 } 472 473 /** 474 * @return The adapter position for the item nearest the origin corresponding to the latest 475 * band select operation, or NOT_SET if the selection did not cover any items. 476 */ 477 int getPositionNearestOrigin() { 478 return mPositionNearestOrigin; 479 } 480 481 @Override 482 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 483 if (!mIsActive) { 484 return; 485 } 486 487 mPointer.x += dx; 488 mPointer.y += dy; 489 recordVisibleChildren(); 490 updateModel(); 491 } 492 493 /** 494 * Queries the view for all children and records their location metadata. 495 */ 496 private void recordVisibleChildren() { 497 for (int i = 0; i < mHelper.getVisibleChildCount(); i++) { 498 int adapterPosition = mHelper.getAdapterPositionAt(i); 499 // Sometimes the view is not attached, as we notify the multi selection manager 500 // synchronously, while views are attached asynchronously. As a result items which 501 // are in the adapter may not actually have a corresponding view (yet). 502 if (mHelper.hasView(adapterPosition) && 503 mGridItemTester.test(adapterPosition) && 504 !mKnownPositions.get(adapterPosition)) { 505 mKnownPositions.put(adapterPosition, true); 506 recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition); 507 } 508 } 509 } 510 511 /** 512 * Checks if there are any recorded children. 513 */ 514 private boolean isEmpty() { 515 return mColumnBounds.size() == 0 || mRowBounds.size() == 0; 516 } 517 518 /** 519 * Updates the limits lists and column map with the given item metadata. 520 * @param absoluteChildRect The absolute rectangle for the child view being processed. 521 * @param adapterPosition The position of the child view being processed. 522 */ 523 private void recordItemData(Rect absoluteChildRect, int adapterPosition) { 524 if (mColumnBounds.size() != mHelper.getColumnCount()) { 525 // If not all x-limits have been recorded, record this one. 526 recordLimits( 527 mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right)); 528 } 529 530 recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom)); 531 532 SparseIntArray columnList = mColumns.get(absoluteChildRect.left); 533 if (columnList == null) { 534 columnList = new SparseIntArray(); 535 mColumns.put(absoluteChildRect.left, columnList); 536 } 537 columnList.put(absoluteChildRect.top, adapterPosition); 538 } 539 540 /** 541 * Ensures limits exists within the sorted list limitsList, and adds it to the list if it 542 * does not exist. 543 */ 544 private void recordLimits(List<GridModel.Limits> limitsList, GridModel.Limits limits) { 545 int index = Collections.binarySearch(limitsList, limits); 546 if (index < 0) { 547 limitsList.add(~index, limits); 548 } 549 } 550 551 /** 552 * Handles a moved pointer; this function determines whether the pointer movement resulted 553 * in a selection change and, if it has, notifies listeners of this change. 554 */ 555 private void updateModel() { 556 RelativePoint old = mRelativePointer; 557 mRelativePointer = new RelativePoint(mPointer); 558 if (old != null && mRelativePointer.equals(old)) { 559 return; 560 } 561 562 computeCurrentSelection(); 563 notifyListeners(); 564 } 565 566 /** 567 * Computes the currently-selected items. 568 */ 569 private void computeCurrentSelection() { 570 if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) { 571 updateSelection(computeBounds()); 572 } else { 573 mSelection.clear(); 574 mPositionNearestOrigin = NOT_SET; 575 } 576 } 577 578 /** 579 * Notifies all listeners of a selection change. Note that this function simply passes 580 * mSelection, so computeCurrentSelection() should be called before this 581 * function. 582 */ 583 private void notifyListeners() { 584 for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) { 585 listener.onSelectionChanged(mSelection); 586 } 587 } 588 589 /** 590 * @param rect Rectangle including all covered items. 591 */ 592 private void updateSelection(Rect rect) { 593 int columnStart = 594 Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left)); 595 assert(columnStart >= 0); 596 int columnEnd = columnStart; 597 598 for (int i = columnStart; i < mColumnBounds.size() 599 && mColumnBounds.get(i).lowerLimit <= rect.right; i++) { 600 columnEnd = i; 601 } 602 603 int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top)); 604 if (rowStart < 0) { 605 mPositionNearestOrigin = NOT_SET; 606 return; 607 } 608 609 int rowEnd = rowStart; 610 for (int i = rowStart; i < mRowBounds.size() 611 && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) { 612 rowEnd = i; 613 } 614 615 updateSelection(columnStart, columnEnd, rowStart, rowEnd); 616 } 617 618 /** 619 * Computes the selection given the previously-computed start- and end-indices for each 620 * row and column. 621 */ 622 private void updateSelection( 623 int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) { 624 if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d", 625 columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex)); 626 627 mSelection.clear(); 628 for (int column = columnStartIndex; column <= columnEndIndex; column++) { 629 SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit); 630 for (int row = rowStartIndex; row <= rowEndIndex; row++) { 631 // The default return value for SparseIntArray.get is 0, which is a valid 632 // position. Use a sentry value to prevent erroneously selecting item 0. 633 final int rowKey = mRowBounds.get(row).lowerLimit; 634 int position = items.get(rowKey, NOT_SET); 635 if (position != NOT_SET) { 636 String id = mAdapter.getModelId(position); 637 if (id != null) { 638 // The adapter inserts items for UI layout purposes that aren't associated 639 // with files. Those will have a null model ID. Don't select them. 640 if (canSelect(id)) { 641 mSelection.add(id); 642 } 643 } 644 if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex, 645 row, rowStartIndex, rowEndIndex)) { 646 // If this is the position nearest the origin, record it now so that it 647 // can be returned by endSelection() later. 648 mPositionNearestOrigin = position; 649 } 650 } 651 } 652 } 653 } 654 655 /** 656 * @return True if the item is selectable. 657 */ 658 private boolean canSelect(String id) { 659 // TODO: Simplify the logic, so the check whether we can select is done in one place. 660 // Consider injecting ActivityConfig, or move the checks from MultiSelectManager to 661 // Selection. 662 for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) { 663 if (!listener.onBeforeItemStateChange(id, true)) { 664 return false; 665 } 666 } 667 return true; 668 } 669 670 /** 671 * @return Returns true if the position is the nearest to the origin, or, in the case of the 672 * lower-right corner, whether it is possible that the position is the nearest to the 673 * origin. See comment below for reasoning for this special case. 674 */ 675 private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, 676 int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) { 677 int corner = computeCornerNearestOrigin(); 678 switch (corner) { 679 case UPPER_LEFT: 680 return columnIndex == columnStartIndex && rowIndex == rowStartIndex; 681 case UPPER_RIGHT: 682 return columnIndex == columnEndIndex && rowIndex == rowStartIndex; 683 case LOWER_LEFT: 684 return columnIndex == columnStartIndex && rowIndex == rowEndIndex; 685 case LOWER_RIGHT: 686 // Note that in some cases, the last row will not have as many items as there 687 // are columns (e.g., if there are 4 items and 3 columns, the second row will 688 // only have one item in the first column). This function is invoked for each 689 // position from left to right, so return true for any position in the bottom 690 // row and only the right-most position in the bottom row will be recorded. 691 return rowIndex == rowEndIndex; 692 default: 693 throw new RuntimeException("Invalid corner type."); 694 } 695 } 696 697 /** 698 * Listener for changes in which items have been band selected. 699 */ 700 static interface OnSelectionChangedListener { 701 public void onSelectionChanged(Set<String> updatedSelection); 702 public boolean onBeforeItemStateChange(String id, boolean nextState); 703 } 704 705 void addOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) { 706 mOnSelectionChangedListeners.add(listener); 707 } 708 709 void removeOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) { 710 mOnSelectionChangedListeners.remove(listener); 711 } 712 713 /** 714 * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side 715 * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides 716 * of item columns and the top- and bottom sides of item rows so that it can be determined 717 * whether the pointer is located within the bounds of an item. 718 */ 719 private static class Limits implements Comparable<GridModel.Limits> { 720 int lowerLimit; 721 int upperLimit; 722 723 Limits(int lowerLimit, int upperLimit) { 724 this.lowerLimit = lowerLimit; 725 this.upperLimit = upperLimit; 726 } 727 728 @Override 729 public int compareTo(GridModel.Limits other) { 730 return lowerLimit - other.lowerLimit; 731 } 732 733 @Override 734 public boolean equals(Object other) { 735 if (!(other instanceof GridModel.Limits)) { 736 return false; 737 } 738 739 return ((GridModel.Limits) other).lowerLimit == lowerLimit && 740 ((GridModel.Limits) other).upperLimit == upperLimit; 741 } 742 743 @Override 744 public String toString() { 745 return "(" + lowerLimit + ", " + upperLimit + ")"; 746 } 747 } 748 749 /** 750 * The location of a coordinate relative to items. This class represents a general area of the 751 * view as it relates to band selection rather than an explicit point. For example, two 752 * different points within an item are considered to have the same "location" because band 753 * selection originating within the item would select the same items no matter which point 754 * was used. Same goes for points between items as well as those at the very beginning or end 755 * of the view. 756 * 757 * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the 758 * advantage of tying the value to the Limits of items along that axis. This allows easy 759 * selection of items within those Limits as opposed to a search through every item to see if a 760 * given coordinate value falls within those Limits. 761 */ 762 private static class RelativeCoordinate 763 implements Comparable<GridModel.RelativeCoordinate> { 764 /** 765 * Location describing points after the last known item. 766 */ 767 static final int AFTER_LAST_ITEM = 0; 768 769 /** 770 * Location describing points before the first known item. 771 */ 772 static final int BEFORE_FIRST_ITEM = 1; 773 774 /** 775 * Location describing points between two items. 776 */ 777 static final int BETWEEN_TWO_ITEMS = 2; 778 779 /** 780 * Location describing points within the limits of one item. 781 */ 782 static final int WITHIN_LIMITS = 3; 783 784 /** 785 * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM, 786 * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS. 787 */ 788 final int type; 789 790 /** 791 * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type == 792 * BETWEEN_TWO_ITEMS. 793 */ 794 GridModel.Limits limitsBeforeCoordinate; 795 796 /** 797 * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS. 798 */ 799 GridModel.Limits limitsAfterCoordinate; 800 801 // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM. 802 GridModel.Limits mFirstKnownItem; 803 // Limits of the last known item; only populated when type == AFTER_LAST_ITEM. 804 GridModel.Limits mLastKnownItem; 805 806 /** 807 * @param limitsList The sorted limits list for the coordinate type. If this 808 * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise, 809 * mYLimitsList should be pased. 810 * @param value The coordinate value. 811 */ 812 RelativeCoordinate(List<GridModel.Limits> limitsList, int value) { 813 int index = Collections.binarySearch(limitsList, new Limits(value, value)); 814 815 if (index >= 0) { 816 this.type = WITHIN_LIMITS; 817 this.limitsBeforeCoordinate = limitsList.get(index); 818 } else if (~index == 0) { 819 this.type = BEFORE_FIRST_ITEM; 820 this.mFirstKnownItem = limitsList.get(0); 821 } else if (~index == limitsList.size()) { 822 GridModel.Limits lastLimits = limitsList.get(limitsList.size() - 1); 823 if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) { 824 this.type = WITHIN_LIMITS; 825 this.limitsBeforeCoordinate = lastLimits; 826 } else { 827 this.type = AFTER_LAST_ITEM; 828 this.mLastKnownItem = lastLimits; 829 } 830 } else { 831 GridModel.Limits limitsBeforeIndex = limitsList.get(~index - 1); 832 if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) { 833 this.type = WITHIN_LIMITS; 834 this.limitsBeforeCoordinate = limitsList.get(~index - 1); 835 } else { 836 this.type = BETWEEN_TWO_ITEMS; 837 this.limitsBeforeCoordinate = limitsList.get(~index - 1); 838 this.limitsAfterCoordinate = limitsList.get(~index); 839 } 840 } 841 } 842 843 int toComparisonValue() { 844 if (type == BEFORE_FIRST_ITEM) { 845 return mFirstKnownItem.lowerLimit - 1; 846 } else if (type == AFTER_LAST_ITEM) { 847 return mLastKnownItem.upperLimit + 1; 848 } else if (type == BETWEEN_TWO_ITEMS) { 849 return limitsBeforeCoordinate.upperLimit + 1; 850 } else { 851 return limitsBeforeCoordinate.lowerLimit; 852 } 853 } 854 855 @Override 856 public boolean equals(Object other) { 857 if (!(other instanceof GridModel.RelativeCoordinate)) { 858 return false; 859 } 860 861 GridModel.RelativeCoordinate otherCoordinate = (GridModel.RelativeCoordinate) other; 862 return toComparisonValue() == otherCoordinate.toComparisonValue(); 863 } 864 865 @Override 866 public int compareTo(GridModel.RelativeCoordinate other) { 867 return toComparisonValue() - other.toComparisonValue(); 868 } 869 } 870 871 /** 872 * The location of a point relative to the Limits of nearby items; consists of both an x- and 873 * y-RelativeCoordinateLocation. 874 */ 875 private class RelativePoint { 876 final GridModel.RelativeCoordinate xLocation; 877 final GridModel.RelativeCoordinate yLocation; 878 879 RelativePoint(Point point) { 880 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x); 881 this.yLocation = new RelativeCoordinate(mRowBounds, point.y); 882 } 883 884 @Override 885 public boolean equals(Object other) { 886 if (!(other instanceof RelativePoint)) { 887 return false; 888 } 889 890 RelativePoint otherPoint = (RelativePoint) other; 891 return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation); 892 } 893 } 894 895 /** 896 * Generates a rectangle which contains the items selected by the pointer and origin. 897 * @return The rectangle, or null if no items were selected. 898 */ 899 private Rect computeBounds() { 900 Rect rect = new Rect(); 901 rect.left = getCoordinateValue( 902 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation), 903 mColumnBounds, 904 true); 905 rect.right = getCoordinateValue( 906 max(mRelativeOrigin.xLocation, mRelativePointer.xLocation), 907 mColumnBounds, 908 false); 909 rect.top = getCoordinateValue( 910 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation), 911 mRowBounds, 912 true); 913 rect.bottom = getCoordinateValue( 914 max(mRelativeOrigin.yLocation, mRelativePointer.yLocation), 915 mRowBounds, 916 false); 917 return rect; 918 } 919 920 /** 921 * Computes the corner of the selection nearest the origin. 922 * @return 923 */ 924 private int computeCornerNearestOrigin() { 925 int cornerValue = 0; 926 927 if (mRelativeOrigin.yLocation == 928 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) { 929 cornerValue |= UPPER; 930 } else { 931 cornerValue |= LOWER; 932 } 933 934 if (mRelativeOrigin.xLocation == 935 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) { 936 cornerValue |= LEFT; 937 } else { 938 cornerValue |= RIGHT; 939 } 940 941 return cornerValue; 942 } 943 944 private GridModel.RelativeCoordinate min(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) { 945 return first.compareTo(second) < 0 ? first : second; 946 } 947 948 private GridModel.RelativeCoordinate max(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) { 949 return first.compareTo(second) > 0 ? first : second; 950 } 951 952 /** 953 * @return The absolute coordinate (i.e., the x- or y-value) of the given relative 954 * coordinate. 955 */ 956 private int getCoordinateValue(GridModel.RelativeCoordinate coordinate, 957 List<GridModel.Limits> limitsList, boolean isStartOfRange) { 958 switch (coordinate.type) { 959 case RelativeCoordinate.BEFORE_FIRST_ITEM: 960 return limitsList.get(0).lowerLimit; 961 case RelativeCoordinate.AFTER_LAST_ITEM: 962 return limitsList.get(limitsList.size() - 1).upperLimit; 963 case RelativeCoordinate.BETWEEN_TWO_ITEMS: 964 if (isStartOfRange) { 965 return coordinate.limitsAfterCoordinate.lowerLimit; 966 } else { 967 return coordinate.limitsBeforeCoordinate.upperLimit; 968 } 969 case RelativeCoordinate.WITHIN_LIMITS: 970 return coordinate.limitsBeforeCoordinate.lowerLimit; 971 } 972 973 throw new RuntimeException("Invalid coordinate value."); 974 } 975 976 private boolean areItemsCoveredByBand( 977 RelativePoint first, RelativePoint second) { 978 return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) && 979 doesCoordinateLocationCoverItems(first.yLocation, second.yLocation); 980 } 981 982 private boolean doesCoordinateLocationCoverItems( 983 GridModel.RelativeCoordinate pointerCoordinate, 984 GridModel.RelativeCoordinate originCoordinate) { 985 if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM && 986 originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) { 987 return false; 988 } 989 990 if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM && 991 originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) { 992 return false; 993 } 994 995 if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && 996 originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && 997 pointerCoordinate.limitsBeforeCoordinate.equals( 998 originCoordinate.limitsBeforeCoordinate) && 999 pointerCoordinate.limitsAfterCoordinate.equals( 1000 originCoordinate.limitsAfterCoordinate)) { 1001 return false; 1002 } 1003 1004 return true; 1005 } 1006 } 1007 1008 /** 1009 * Provides functionality for BandController. Exists primarily to tests that are 1010 * fully isolated from RecyclerView. 1011 */ 1012 interface SelectionEnvironment extends ScrollActionDelegate { 1013 void showBand(Rect rect); 1014 void hideBand(); 1015 void addOnScrollListener(RecyclerView.OnScrollListener listener); 1016 void removeOnScrollListener(RecyclerView.OnScrollListener listener); 1017 int getHeight(); 1018 void invalidateView(); 1019 Point createAbsolutePoint(Point relativePoint); 1020 Rect getAbsoluteRectForChildViewAt(int index); 1021 int getAdapterPositionAt(int index); 1022 int getColumnCount(); 1023 int getChildCount(); 1024 int getVisibleChildCount(); 1025 /** 1026 * Items may be in the adapter, but without an attached view. 1027 */ 1028 boolean hasView(int adapterPosition); 1029 } 1030 1031 /** Recycler view facade implementation backed by good ol' RecyclerView. */ 1032 private static final class RuntimeSelectionEnvironment implements SelectionEnvironment { 1033 1034 private final RecyclerView mView; 1035 private final Drawable mBand; 1036 1037 private boolean mIsOverlayShown = false; 1038 1039 RuntimeSelectionEnvironment(RecyclerView view) { 1040 mView = view; 1041 mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay); 1042 } 1043 1044 @Override 1045 public int getAdapterPositionAt(int index) { 1046 return mView.getChildAdapterPosition(mView.getChildAt(index)); 1047 } 1048 1049 @Override 1050 public void addOnScrollListener(RecyclerView.OnScrollListener listener) { 1051 mView.addOnScrollListener(listener); 1052 } 1053 1054 @Override 1055 public void removeOnScrollListener(RecyclerView.OnScrollListener listener) { 1056 mView.removeOnScrollListener(listener); 1057 } 1058 1059 @Override 1060 public Point createAbsolutePoint(Point relativePoint) { 1061 return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(), 1062 relativePoint.y + mView.computeVerticalScrollOffset()); 1063 } 1064 1065 @Override 1066 public Rect getAbsoluteRectForChildViewAt(int index) { 1067 final View child = mView.getChildAt(index); 1068 final Rect childRect = new Rect(); 1069 child.getHitRect(childRect); 1070 childRect.left += mView.computeHorizontalScrollOffset(); 1071 childRect.right += mView.computeHorizontalScrollOffset(); 1072 childRect.top += mView.computeVerticalScrollOffset(); 1073 childRect.bottom += mView.computeVerticalScrollOffset(); 1074 return childRect; 1075 } 1076 1077 @Override 1078 public int getChildCount() { 1079 return mView.getAdapter().getItemCount(); 1080 } 1081 1082 @Override 1083 public int getVisibleChildCount() { 1084 return mView.getChildCount(); 1085 } 1086 1087 @Override 1088 public int getColumnCount() { 1089 RecyclerView.LayoutManager layoutManager = mView.getLayoutManager(); 1090 if (layoutManager instanceof GridLayoutManager) { 1091 return ((GridLayoutManager) layoutManager).getSpanCount(); 1092 } 1093 1094 // Otherwise, it is a list with 1 column. 1095 return 1; 1096 } 1097 1098 @Override 1099 public int getHeight() { 1100 return mView.getHeight(); 1101 } 1102 1103 @Override 1104 public void invalidateView() { 1105 mView.invalidate(); 1106 } 1107 1108 @Override 1109 public void runAtNextFrame(Runnable r) { 1110 mView.postOnAnimation(r); 1111 } 1112 1113 @Override 1114 public void removeCallback(Runnable r) { 1115 mView.removeCallbacks(r); 1116 } 1117 1118 @Override 1119 public void scrollBy(int dy) { 1120 mView.scrollBy(0, dy); 1121 } 1122 1123 @Override 1124 public void showBand(Rect rect) { 1125 mBand.setBounds(rect); 1126 1127 if (!mIsOverlayShown) { 1128 mView.getOverlay().add(mBand); 1129 } 1130 } 1131 1132 @Override 1133 public void hideBand() { 1134 mView.getOverlay().remove(mBand); 1135 } 1136 1137 @Override 1138 public boolean hasView(int pos) { 1139 return mView.findViewHolderForAdapterPosition(pos) != null; 1140 } 1141 } 1142 } 1143