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.dirlist; 18 19 import static com.android.documentsui.Shared.DEBUG; 20 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DIRECTORY; 21 import static com.android.documentsui.dirlist.ModelBackedDocumentsAdapter.ITEM_TYPE_DOCUMENT; 22 23 import android.annotation.IntDef; 24 import android.graphics.Point; 25 import android.graphics.Rect; 26 import android.graphics.drawable.Drawable; 27 import android.os.Parcel; 28 import android.os.Parcelable; 29 import android.support.annotation.Nullable; 30 import android.support.annotation.VisibleForTesting; 31 import android.support.v7.widget.GridLayoutManager; 32 import android.support.v7.widget.RecyclerView; 33 import android.util.Log; 34 import android.util.SparseArray; 35 import android.util.SparseBooleanArray; 36 import android.util.SparseIntArray; 37 import android.view.MotionEvent; 38 import android.view.View; 39 40 import com.android.documentsui.Events.InputEvent; 41 import com.android.documentsui.Events.MotionInputEvent; 42 import com.android.documentsui.R; 43 44 import java.lang.annotation.Retention; 45 import java.lang.annotation.RetentionPolicy; 46 import java.util.ArrayList; 47 import java.util.Collection; 48 import java.util.Collections; 49 import java.util.HashMap; 50 import java.util.HashSet; 51 import java.util.List; 52 import java.util.Map; 53 import java.util.Set; 54 55 /** 56 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView. 57 * Additionally it can be configured to restrict selection to a single element, @see 58 * #setSelectMode. 59 */ 60 public final class MultiSelectManager { 61 62 @IntDef(flag = true, value = { 63 MODE_MULTIPLE, 64 MODE_SINGLE 65 }) 66 @Retention(RetentionPolicy.SOURCE) 67 public @interface SelectionMode {} 68 public static final int MODE_MULTIPLE = 0; 69 public static final int MODE_SINGLE = 1; 70 71 private static final String TAG = "MultiSelectManager"; 72 73 private final Selection mSelection = new Selection(); 74 75 private final SelectionEnvironment mEnvironment; 76 private final DocumentsAdapter mAdapter; 77 private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1); 78 79 private Range mRanger; 80 private boolean mSingleSelect; 81 82 @Nullable private BandController mBandManager; 83 84 85 /** 86 * @param mode Selection single or multiple selection mode. 87 * @param initialSelection selection state probably preserved in external state. 88 */ MultiSelectManager( final RecyclerView recyclerView, DocumentsAdapter adapter, @SelectionMode int mode, @Nullable Selection initialSelection)89 public MultiSelectManager( 90 final RecyclerView recyclerView, 91 DocumentsAdapter adapter, 92 @SelectionMode int mode, 93 @Nullable Selection initialSelection) { 94 95 this(new RuntimeSelectionEnvironment(recyclerView), adapter, mode, initialSelection); 96 97 if (mode == MODE_MULTIPLE) { 98 // TODO: Don't load this on low memory devices. 99 mBandManager = new BandController(); 100 } 101 102 recyclerView.addOnItemTouchListener( 103 new RecyclerView.OnItemTouchListener() { 104 @Override 105 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { 106 if (mBandManager != null) { 107 return mBandManager.handleEvent(new MotionInputEvent(e, recyclerView)); 108 } 109 return false; 110 } 111 112 @Override 113 public void onTouchEvent(RecyclerView rv, MotionEvent e) { 114 mBandManager.processInputEvent( 115 new MotionInputEvent(e, recyclerView)); 116 } 117 @Override 118 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {} 119 }); 120 } 121 122 /** 123 * Constructs a new instance with {@code adapter} and {@code helper}. 124 * @param runtimeSelectionEnvironment 125 * @hide 126 */ 127 @VisibleForTesting MultiSelectManager( SelectionEnvironment environment, DocumentsAdapter adapter, @SelectionMode int mode, @Nullable Selection initialSelection)128 MultiSelectManager( 129 SelectionEnvironment environment, 130 DocumentsAdapter adapter, 131 @SelectionMode int mode, 132 @Nullable Selection initialSelection) { 133 134 assert(environment != null); 135 assert(adapter != null); 136 137 mEnvironment = environment; 138 mAdapter = adapter; 139 140 mSingleSelect = mode == MODE_SINGLE; 141 if (initialSelection != null) { 142 mSelection.copyFrom(initialSelection); 143 } 144 145 mAdapter.registerAdapterDataObserver( 146 new RecyclerView.AdapterDataObserver() { 147 148 private List<String> mModelIds; 149 150 @Override 151 public void onChanged() { 152 mModelIds = mAdapter.getModelIds(); 153 154 // Update the selection to remove any disappeared IDs. 155 mSelection.cancelProvisionalSelection(); 156 mSelection.intersect(mModelIds); 157 158 if (mBandManager != null && mBandManager.isActive()) { 159 mBandManager.endBandSelect(); 160 } 161 } 162 163 @Override 164 public void onItemRangeChanged( 165 int startPosition, int itemCount, Object payload) { 166 // No change in position. Ignoring. 167 } 168 169 @Override 170 public void onItemRangeInserted(int startPosition, int itemCount) { 171 mSelection.cancelProvisionalSelection(); 172 } 173 174 @Override 175 public void onItemRangeRemoved(int startPosition, int itemCount) { 176 assert(startPosition >= 0); 177 assert(itemCount > 0); 178 179 mSelection.cancelProvisionalSelection(); 180 // Remove any disappeared IDs from the selection. 181 mSelection.intersect(mModelIds); 182 } 183 184 @Override 185 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 186 throw new UnsupportedOperationException(); 187 } 188 }); 189 } 190 191 /** 192 * Adds {@code callback} such that it will be notified when {@code MultiSelectManager} 193 * events occur. 194 * 195 * @param callback 196 */ addCallback(MultiSelectManager.Callback callback)197 public void addCallback(MultiSelectManager.Callback callback) { 198 mCallbacks.add(callback); 199 } 200 hasSelection()201 public boolean hasSelection() { 202 return !mSelection.isEmpty(); 203 } 204 205 /** 206 * Returns a Selection object that provides a live view 207 * on the current selection. 208 * 209 * @see #getSelection(Selection) on how to get a snapshot 210 * of the selection that will not reflect future changes 211 * to selection. 212 * 213 * @return The current selection. 214 */ getSelection()215 public Selection getSelection() { 216 return mSelection; 217 } 218 219 /** 220 * Updates {@code dest} to reflect the current selection. 221 * @param dest 222 * 223 * @return The Selection instance passed in, for convenience. 224 */ getSelection(Selection dest)225 public Selection getSelection(Selection dest) { 226 dest.copyFrom(mSelection); 227 return dest; 228 } 229 230 /** 231 * Updates selection to include items in {@code selection}. 232 */ updateSelection(Selection selection)233 public void updateSelection(Selection selection) { 234 setItemsSelected(selection.toList(), true); 235 } 236 237 /** 238 * Sets the selected state of the specified items. Note that the callback will NOT 239 * be consulted to see if an item can be selected. 240 * 241 * @param ids 242 * @param selected 243 * @return 244 */ setItemsSelected(Iterable<String> ids, boolean selected)245 public boolean setItemsSelected(Iterable<String> ids, boolean selected) { 246 boolean changed = false; 247 for (String id: ids) { 248 boolean itemChanged = selected ? mSelection.add(id) : mSelection.remove(id); 249 if (itemChanged) { 250 notifyItemStateChanged(id, selected); 251 } 252 changed |= itemChanged; 253 } 254 notifySelectionChanged(); 255 return changed; 256 } 257 258 /** 259 * Clears the selection and notifies (even if nothing changes). 260 */ clearSelection()261 public void clearSelection() { 262 clearSelectionQuietly(); 263 notifySelectionChanged(); 264 } 265 handleLayoutChanged()266 public void handleLayoutChanged() { 267 if (mBandManager != null) { 268 mBandManager.handleLayoutChanged(); 269 } 270 } 271 272 /** 273 * Clears the selection, without notifying selection listeners. UI elements still need to be 274 * notified about state changes so that they can update their appearance. 275 */ clearSelectionQuietly()276 private void clearSelectionQuietly() { 277 mRanger = null; 278 279 if (!hasSelection()) { 280 return; 281 } 282 283 Selection oldSelection = getSelection(new Selection()); 284 mSelection.clear(); 285 286 for (String id: oldSelection.getAll()) { 287 notifyItemStateChanged(id, false); 288 } 289 } 290 291 @VisibleForTesting onLongPress(InputEvent input)292 void onLongPress(InputEvent input) { 293 if (DEBUG) Log.d(TAG, "Handling long press event."); 294 295 if (!input.isOverItem()) { 296 if (DEBUG) Log.i(TAG, "Cannot handle tap. No adapter position available."); 297 } 298 299 handleAdapterEvent(input); 300 } 301 302 @VisibleForTesting onSingleTapUp(InputEvent input)303 boolean onSingleTapUp(InputEvent input) { 304 if (DEBUG) Log.d(TAG, "Processing tap event."); 305 if (!hasSelection()) { 306 // No selection active - do nothing. 307 return false; 308 } 309 310 if (!input.isOverItem()) { 311 if (DEBUG) Log.d(TAG, "Activity has no position. Canceling selection."); 312 clearSelection(); 313 return false; 314 } 315 316 handleAdapterEvent(input); 317 return true; 318 } 319 320 /** 321 * Handles a change caused by a click on the item with the given position. If the Shift key is 322 * held down, this performs a range select; otherwise, it simply toggles the item's selection 323 * state. 324 */ handleAdapterEvent(InputEvent input)325 private void handleAdapterEvent(InputEvent input) { 326 if (mRanger != null && input.isShiftKeyDown()) { 327 mRanger.snapSelection(input.getItemPosition()); 328 329 // We're being lazy here notifying even when something might not have changed. 330 // To make this more correct, we'd need to update the Ranger class to return 331 // information about what has changed. 332 notifySelectionChanged(); 333 } else { 334 int position = input.getItemPosition(); 335 toggleSelection(position); 336 setSelectionRangeBegin(position); 337 } 338 } 339 340 /** 341 * A convenience method for toggling selection by adapter position. 342 * 343 * @param position Adapter position to toggle. 344 */ toggleSelection(int position)345 private void toggleSelection(int position) { 346 // Position may be special "no position" during certain 347 // transitional phases. If so, skip handling of the event. 348 if (position == RecyclerView.NO_POSITION) { 349 if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position."); 350 return; 351 } 352 String id = mAdapter.getModelId(position); 353 if (id != null) { 354 toggleSelection(id); 355 } 356 } 357 358 /** 359 * Toggles selection on the item with the given model ID. 360 * 361 * @param modelId 362 */ toggleSelection(String modelId)363 public void toggleSelection(String modelId) { 364 assert(modelId != null); 365 366 boolean changed = false; 367 if (mSelection.contains(modelId)) { 368 changed = attemptDeselect(modelId); 369 } else { 370 changed = attemptSelect(modelId); 371 } 372 373 if (changed) { 374 notifySelectionChanged(); 375 } 376 } 377 378 /** 379 * Starts a range selection. If a range selection is already active, this will start a new range 380 * selection (which will reset the range anchor). 381 * 382 * @param pos The anchor position for the selection range. 383 */ startRangeSelection(int pos)384 void startRangeSelection(int pos) { 385 attemptSelect(mAdapter.getModelId(pos)); 386 setSelectionRangeBegin(pos); 387 } 388 389 /** 390 * Sets the end point for the current range selection, started by a call to 391 * {@link #startRangeSelection(int)}. This function should only be called when a range selection 392 * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be 393 * selected. 394 * 395 * @param pos The new end position for the selection range. 396 */ snapRangeSelection(int pos)397 void snapRangeSelection(int pos) { 398 assert(mRanger != null); 399 400 mRanger.snapSelection(pos); 401 notifySelectionChanged(); 402 } 403 404 /** 405 * Stops an in-progress range selection. 406 */ endRangeSelection()407 void endRangeSelection() { 408 mRanger = null; 409 } 410 411 /** 412 * @return Whether or not there is a current range selection active. 413 */ isRangeSelectionActive()414 boolean isRangeSelectionActive() { 415 return mRanger != null; 416 } 417 418 /** 419 * Sets the magic location at which a selection range begins (the selection anchor). This value 420 * is consulted when determining how to extend, and modify selection ranges. Calling this when a 421 * range selection is active will reset the range selection. 422 * 423 * @throws IllegalStateException if {@code position} is not already be selected 424 * @param position 425 */ setSelectionRangeBegin(int position)426 void setSelectionRangeBegin(int position) { 427 if (position == RecyclerView.NO_POSITION) { 428 return; 429 } 430 431 if (mSelection.contains(mAdapter.getModelId(position))) { 432 mRanger = new Range(position); 433 } 434 } 435 436 /** 437 * Try to set selection state for all elements in range. Not that callbacks can cancel selection 438 * of specific items, so some or even all items may not reflect the desired state after the 439 * update is complete. 440 * 441 * @param begin Adapter position for range start (inclusive). 442 * @param end Adapter position for range end (inclusive). 443 * @param selected New selection state. 444 */ updateRange(int begin, int end, boolean selected)445 private void updateRange(int begin, int end, boolean selected) { 446 assert(end >= begin); 447 for (int i = begin; i <= end; i++) { 448 String id = mAdapter.getModelId(i); 449 if (id == null) { 450 continue; 451 } 452 453 if (selected) { 454 boolean canSelect = notifyBeforeItemStateChange(id, true); 455 if (canSelect) { 456 if (mSingleSelect && hasSelection()) { 457 clearSelectionQuietly(); 458 } 459 selectAndNotify(id); 460 } 461 } else { 462 attemptDeselect(id); 463 } 464 } 465 } 466 467 /** 468 * @param modelId 469 * @return True if the update was applied. 470 */ selectAndNotify(String modelId)471 private boolean selectAndNotify(String modelId) { 472 boolean changed = mSelection.add(modelId); 473 if (changed) { 474 notifyItemStateChanged(modelId, true); 475 } 476 return changed; 477 } 478 479 /** 480 * @param id 481 * @return True if the update was applied. 482 */ attemptDeselect(String id)483 private boolean attemptDeselect(String id) { 484 assert(id != null); 485 if (notifyBeforeItemStateChange(id, false)) { 486 mSelection.remove(id); 487 notifyItemStateChanged(id, false); 488 if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection); 489 return true; 490 } else { 491 if (DEBUG) Log.d(TAG, "Select cancelled by listener."); 492 return false; 493 } 494 } 495 496 /** 497 * @param id 498 * @return True if the update was applied. 499 */ attemptSelect(String id)500 private boolean attemptSelect(String id) { 501 assert(id != null); 502 boolean canSelect = notifyBeforeItemStateChange(id, true); 503 if (!canSelect) { 504 return false; 505 } 506 if (mSingleSelect && hasSelection()) { 507 clearSelectionQuietly(); 508 } 509 510 selectAndNotify(id); 511 return true; 512 } 513 notifyBeforeItemStateChange(String id, boolean nextState)514 private boolean notifyBeforeItemStateChange(String id, boolean nextState) { 515 int lastListener = mCallbacks.size() - 1; 516 for (int i = lastListener; i > -1; i--) { 517 if (!mCallbacks.get(i).onBeforeItemStateChange(id, nextState)) { 518 return false; 519 } 520 } 521 return true; 522 } 523 524 /** 525 * Notifies registered listeners when the selection status of a single item 526 * (identified by {@code position}) changes. 527 */ notifyItemStateChanged(String id, boolean selected)528 private void notifyItemStateChanged(String id, boolean selected) { 529 assert(id != null); 530 int lastListener = mCallbacks.size() - 1; 531 for (int i = lastListener; i > -1; i--) { 532 mCallbacks.get(i).onItemStateChanged(id, selected); 533 } 534 mAdapter.onItemSelectionChanged(id); 535 } 536 537 /** 538 * Notifies registered listeners when the selection has changed. This 539 * notification should be sent only once a full series of changes 540 * is complete, e.g. clearingSelection, or updating the single 541 * selection from one item to another. 542 */ notifySelectionChanged()543 private void notifySelectionChanged() { 544 int lastListener = mCallbacks.size() - 1; 545 for (int i = lastListener; i > -1; i--) { 546 mCallbacks.get(i).onSelectionChanged(); 547 } 548 } 549 550 /** 551 * Class providing support for managing range selections. 552 */ 553 private final class Range { 554 private static final int UNDEFINED = -1; 555 556 final int mBegin; 557 int mEnd = UNDEFINED; 558 Range(int begin)559 public Range(int begin) { 560 if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin); 561 mBegin = begin; 562 } 563 snapSelection(int position)564 private void snapSelection(int position) { 565 assert(mRanger != null); 566 assert(position != RecyclerView.NO_POSITION); 567 568 if (mEnd == UNDEFINED || mEnd == mBegin) { 569 // Reset mEnd so it can be established in establishRange. 570 mEnd = UNDEFINED; 571 establishRange(position); 572 } else { 573 reviseRange(position); 574 } 575 } 576 establishRange(int position)577 private void establishRange(int position) { 578 assert(mRanger.mEnd == UNDEFINED); 579 580 if (position == mBegin) { 581 mEnd = position; 582 } 583 584 if (position > mBegin) { 585 updateRange(mBegin + 1, position, true); 586 } else if (position < mBegin) { 587 updateRange(position, mBegin - 1, true); 588 } 589 590 mEnd = position; 591 } 592 reviseRange(int position)593 private void reviseRange(int position) { 594 assert(mEnd != UNDEFINED); 595 assert(mBegin != mEnd); 596 597 if (position == mEnd) { 598 if (DEBUG) Log.i(TAG, "Skipping no-op revision click on mEndRange."); 599 } 600 601 if (mEnd > mBegin) { 602 reviseAscendingRange(position); 603 } else if (mEnd < mBegin) { 604 reviseDescendingRange(position); 605 } 606 // the "else" case is covered by checkState at beginning of method. 607 608 mEnd = position; 609 } 610 611 /** 612 * Updates an existing ascending seleciton. 613 * @param position 614 */ reviseAscendingRange(int position)615 private void reviseAscendingRange(int position) { 616 // Reducing or reversing the range.... 617 if (position < mEnd) { 618 if (position < mBegin) { 619 updateRange(mBegin + 1, mEnd, false); 620 updateRange(position, mBegin -1, true); 621 } else { 622 updateRange(position + 1, mEnd, false); 623 } 624 } 625 626 // Extending the range... 627 else if (position > mEnd) { 628 updateRange(mEnd + 1, position, true); 629 } 630 } 631 reviseDescendingRange(int position)632 private void reviseDescendingRange(int position) { 633 // Reducing or reversing the range.... 634 if (position > mEnd) { 635 if (position > mBegin) { 636 updateRange(mEnd, mBegin - 1, false); 637 updateRange(mBegin + 1, position, true); 638 } else { 639 updateRange(mEnd, position - 1, false); 640 } 641 } 642 643 // Extending the range... 644 else if (position < mEnd) { 645 updateRange(position, mEnd - 1, true); 646 } 647 } 648 } 649 650 /** 651 * Object representing the current selection. Provides read only access 652 * public access, and private write access. 653 */ 654 public static final class Selection implements Parcelable { 655 656 // This class tracks selected items by managing two sets: the saved selection, and the total 657 // selection. Saved selections are those which have been completed by tapping an item or by 658 // completing a band select operation. Provisional selections are selections which have been 659 // temporarily created by an in-progress band select operation (once the user releases the 660 // mouse button during a band select operation, the selected items become saved). The total 661 // selection is the combination of both the saved selection and the provisional 662 // selection. Tracking both separately is necessary to ensure that saved selections do not 663 // become deselected when they are removed from the provisional selection; for example, if 664 // item A is tapped (and selected), then an in-progress band select covers A then uncovers 665 // A, A should still be selected as it has been saved. To ensure this behavior, the saved 666 // selection must be tracked separately. 667 private final Set<String> mSelection; 668 private final Set<String> mProvisionalSelection; 669 private String mDirectoryKey; 670 Selection()671 public Selection() { 672 mSelection = new HashSet<String>(); 673 mProvisionalSelection = new HashSet<String>(); 674 } 675 676 /** 677 * Used by CREATOR. 678 */ Selection(String directoryKey, Set<String> selection)679 private Selection(String directoryKey, Set<String> selection) { 680 mDirectoryKey = directoryKey; 681 mSelection = selection; 682 mProvisionalSelection = new HashSet<String>(); 683 } 684 685 /** 686 * @param id 687 * @return true if the position is currently selected. 688 */ contains(@ullable String id)689 public boolean contains(@Nullable String id) { 690 return mSelection.contains(id) || mProvisionalSelection.contains(id); 691 } 692 693 /** 694 * Returns an unordered array of selected positions. 695 */ getAll()696 public String[] getAll() { 697 return toList().toArray(new String[0]); 698 } 699 700 /** 701 * Returns an unordered array of selected positions (including any 702 * provisional selections current in effect). 703 */ toList()704 public List<String> toList() { 705 ArrayList<String> selection = new ArrayList<String>(mSelection); 706 selection.addAll(mProvisionalSelection); 707 return selection; 708 } 709 710 /** 711 * @return size of the selection. 712 */ size()713 public int size() { 714 return mSelection.size() + mProvisionalSelection.size(); 715 } 716 717 /** 718 * @return true if the selection is empty. 719 */ isEmpty()720 public boolean isEmpty() { 721 return mSelection.isEmpty() && mProvisionalSelection.isEmpty(); 722 } 723 724 /** 725 * Sets the provisional selection, which is a temporary selection that can be saved, 726 * canceled, or adjusted at a later time. When a new provision selection is applied, the old 727 * one (if it exists) is abandoned. 728 * @return Map of ids added or removed. Added ids have a value of true, removed are false. 729 */ 730 @VisibleForTesting setProvisionalSelection(Set<String> newSelection)731 protected Map<String, Boolean> setProvisionalSelection(Set<String> newSelection) { 732 Map<String, Boolean> delta = new HashMap<>(); 733 734 for (String id: mProvisionalSelection) { 735 // Mark each item that used to be in the selection but is unsaved and not in the new 736 // provisional selection. 737 if (!newSelection.contains(id) && !mSelection.contains(id)) { 738 delta.put(id, false); 739 } 740 } 741 742 for (String id: mSelection) { 743 // Mark each item that used to be in the selection but is unsaved and not in the new 744 // provisional selection. 745 if (!newSelection.contains(id)) { 746 delta.put(id, false); 747 } 748 } 749 750 for (String id: newSelection) { 751 // Mark each item that was not previously in the selection but is in the new 752 // provisional selection. 753 if (!mSelection.contains(id) && !mProvisionalSelection.contains(id)) { 754 delta.put(id, true); 755 } 756 } 757 758 // Now, iterate through the changes and actually add/remove them to/from the current 759 // selection. This could not be done in the previous loops because changing the size of 760 // the selection mid-iteration changes iteration order erroneously. 761 for (Map.Entry<String, Boolean> entry: delta.entrySet()) { 762 String id = entry.getKey(); 763 if (entry.getValue()) { 764 mProvisionalSelection.add(id); 765 } else { 766 mProvisionalSelection.remove(id); 767 } 768 } 769 770 return delta; 771 } 772 773 /** 774 * Saves the existing provisional selection. Once the provisional selection is saved, 775 * subsequent provisional selections which are different from this existing one cannot 776 * cause items in this existing provisional selection to become deselected. 777 */ 778 @VisibleForTesting applyProvisionalSelection()779 protected void applyProvisionalSelection() { 780 mSelection.addAll(mProvisionalSelection); 781 mProvisionalSelection.clear(); 782 } 783 784 /** 785 * Abandons the existing provisional selection so that all items provisionally selected are 786 * now deselected. 787 */ 788 @VisibleForTesting cancelProvisionalSelection()789 void cancelProvisionalSelection() { 790 mProvisionalSelection.clear(); 791 } 792 793 /** @hide */ 794 @VisibleForTesting add(String id)795 boolean add(String id) { 796 if (!mSelection.contains(id)) { 797 mSelection.add(id); 798 return true; 799 } 800 return false; 801 } 802 803 /** @hide */ 804 @VisibleForTesting remove(String id)805 boolean remove(String id) { 806 if (mSelection.contains(id)) { 807 mSelection.remove(id); 808 return true; 809 } 810 return false; 811 } 812 clear()813 public void clear() { 814 mSelection.clear(); 815 } 816 817 /** 818 * Trims this selection to be the intersection of itself with the set of given IDs. 819 */ intersect(Collection<String> ids)820 public void intersect(Collection<String> ids) { 821 mSelection.retainAll(ids); 822 mProvisionalSelection.retainAll(ids); 823 } 824 825 @VisibleForTesting copyFrom(Selection source)826 void copyFrom(Selection source) { 827 mSelection.clear(); 828 mSelection.addAll(source.mSelection); 829 830 mProvisionalSelection.clear(); 831 mProvisionalSelection.addAll(source.mProvisionalSelection); 832 } 833 834 @Override toString()835 public String toString() { 836 if (size() <= 0) { 837 return "size=0, items=[]"; 838 } 839 840 StringBuilder buffer = new StringBuilder(size() * 28); 841 buffer.append("Selection{") 842 .append("applied{size=" + mSelection.size()) 843 .append(", entries=" + mSelection) 844 .append("}, provisional{size=" + mProvisionalSelection.size()) 845 .append(", entries=" + mProvisionalSelection) 846 .append("}}"); 847 return buffer.toString(); 848 } 849 850 @Override hashCode()851 public int hashCode() { 852 return mSelection.hashCode() ^ mProvisionalSelection.hashCode(); 853 } 854 855 @Override equals(Object that)856 public boolean equals(Object that) { 857 if (this == that) { 858 return true; 859 } 860 861 if (!(that instanceof Selection)) { 862 return false; 863 } 864 865 return mSelection.equals(((Selection) that).mSelection) && 866 mProvisionalSelection.equals(((Selection) that).mProvisionalSelection); 867 } 868 869 /** 870 * Sets the state key for this selection, which allows us to match selections 871 * to particular states (of DirectoryFragment). Basically this lets us avoid 872 * loading a persisted selection in the wrong directory. 873 */ setDirectoryKey(String key)874 public void setDirectoryKey(String key) { 875 mDirectoryKey = key; 876 } 877 878 /** 879 * Sets the state key for this selection, which allows us to match selections 880 * to particular states (of DirectoryFragment). Basically this lets us avoid 881 * loading a persisted selection in the wrong directory. 882 */ hasDirectoryKey(String key)883 public boolean hasDirectoryKey(String key) { 884 return key.equals(mDirectoryKey); 885 } 886 887 @Override describeContents()888 public int describeContents() { 889 return 0; 890 } 891 writeToParcel(Parcel dest, int flags)892 public void writeToParcel(Parcel dest, int flags) { 893 dest.writeString(mDirectoryKey); 894 dest.writeStringList(new ArrayList<>(mSelection)); 895 // We don't include provisional selection since it is 896 // typically coupled to some other runtime state (like a band). 897 } 898 899 public static final ClassLoaderCreator<Selection> CREATOR = 900 new ClassLoaderCreator<Selection>() { 901 @Override 902 public Selection createFromParcel(Parcel in) { 903 return createFromParcel(in, null); 904 } 905 906 @Override 907 public Selection createFromParcel(Parcel in, ClassLoader loader) { 908 String directoryKey = in.readString(); 909 910 ArrayList<String> selected = new ArrayList<>(); 911 in.readStringList(selected); 912 913 return new Selection(directoryKey, new HashSet<String>(selected)); 914 } 915 916 @Override 917 public Selection[] newArray(int size) { 918 return new Selection[size]; 919 } 920 }; 921 } 922 923 /** 924 * Provides functionality for BandController. Exists primarily to tests that are 925 * fully isolated from RecyclerView. 926 */ 927 interface SelectionEnvironment { showBand(Rect rect)928 void showBand(Rect rect); hideBand()929 void hideBand(); addOnScrollListener(RecyclerView.OnScrollListener listener)930 void addOnScrollListener(RecyclerView.OnScrollListener listener); removeOnScrollListener(RecyclerView.OnScrollListener listener)931 void removeOnScrollListener(RecyclerView.OnScrollListener listener); scrollBy(int dy)932 void scrollBy(int dy); getHeight()933 int getHeight(); invalidateView()934 void invalidateView(); runAtNextFrame(Runnable r)935 void runAtNextFrame(Runnable r); removeCallback(Runnable r)936 void removeCallback(Runnable r); createAbsolutePoint(Point relativePoint)937 Point createAbsolutePoint(Point relativePoint); getAbsoluteRectForChildViewAt(int index)938 Rect getAbsoluteRectForChildViewAt(int index); getAdapterPositionAt(int index)939 int getAdapterPositionAt(int index); getColumnCount()940 int getColumnCount(); getChildCount()941 int getChildCount(); getVisibleChildCount()942 int getVisibleChildCount(); 943 /** 944 * Layout items are excluded from the GridModel. 945 */ isLayoutItem(int adapterPosition)946 boolean isLayoutItem(int adapterPosition); 947 /** 948 * Items may be in the adapter, but without an attached view. 949 */ hasView(int adapterPosition)950 boolean hasView(int adapterPosition); 951 } 952 953 /** Recycler view facade implementation backed by good ol' RecyclerView. */ 954 private static final class RuntimeSelectionEnvironment implements SelectionEnvironment { 955 956 private final RecyclerView mView; 957 private final Drawable mBand; 958 959 private boolean mIsOverlayShown = false; 960 RuntimeSelectionEnvironment(RecyclerView view)961 RuntimeSelectionEnvironment(RecyclerView view) { 962 mView = view; 963 mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay); 964 } 965 966 @Override getAdapterPositionAt(int index)967 public int getAdapterPositionAt(int index) { 968 return mView.getChildAdapterPosition(mView.getChildAt(index)); 969 } 970 971 @Override addOnScrollListener(RecyclerView.OnScrollListener listener)972 public void addOnScrollListener(RecyclerView.OnScrollListener listener) { 973 mView.addOnScrollListener(listener); 974 } 975 976 @Override removeOnScrollListener(RecyclerView.OnScrollListener listener)977 public void removeOnScrollListener(RecyclerView.OnScrollListener listener) { 978 mView.removeOnScrollListener(listener); 979 } 980 981 @Override createAbsolutePoint(Point relativePoint)982 public Point createAbsolutePoint(Point relativePoint) { 983 return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(), 984 relativePoint.y + mView.computeVerticalScrollOffset()); 985 } 986 987 @Override getAbsoluteRectForChildViewAt(int index)988 public Rect getAbsoluteRectForChildViewAt(int index) { 989 final View child = mView.getChildAt(index); 990 final Rect childRect = new Rect(); 991 child.getHitRect(childRect); 992 childRect.left += mView.computeHorizontalScrollOffset(); 993 childRect.right += mView.computeHorizontalScrollOffset(); 994 childRect.top += mView.computeVerticalScrollOffset(); 995 childRect.bottom += mView.computeVerticalScrollOffset(); 996 return childRect; 997 } 998 999 @Override getChildCount()1000 public int getChildCount() { 1001 return mView.getAdapter().getItemCount(); 1002 } 1003 1004 @Override getVisibleChildCount()1005 public int getVisibleChildCount() { 1006 return mView.getChildCount(); 1007 } 1008 1009 @Override getColumnCount()1010 public int getColumnCount() { 1011 RecyclerView.LayoutManager layoutManager = mView.getLayoutManager(); 1012 if (layoutManager instanceof GridLayoutManager) { 1013 return ((GridLayoutManager) layoutManager).getSpanCount(); 1014 } 1015 1016 // Otherwise, it is a list with 1 column. 1017 return 1; 1018 } 1019 1020 @Override getHeight()1021 public int getHeight() { 1022 return mView.getHeight(); 1023 } 1024 1025 @Override invalidateView()1026 public void invalidateView() { 1027 mView.invalidate(); 1028 } 1029 1030 @Override runAtNextFrame(Runnable r)1031 public void runAtNextFrame(Runnable r) { 1032 mView.postOnAnimation(r); 1033 } 1034 1035 @Override removeCallback(Runnable r)1036 public void removeCallback(Runnable r) { 1037 mView.removeCallbacks(r); 1038 } 1039 1040 @Override scrollBy(int dy)1041 public void scrollBy(int dy) { 1042 mView.scrollBy(0, dy); 1043 } 1044 1045 @Override showBand(Rect rect)1046 public void showBand(Rect rect) { 1047 mBand.setBounds(rect); 1048 1049 if (!mIsOverlayShown) { 1050 mView.getOverlay().add(mBand); 1051 } 1052 } 1053 1054 @Override hideBand()1055 public void hideBand() { 1056 mView.getOverlay().remove(mBand); 1057 } 1058 1059 @Override isLayoutItem(int pos)1060 public boolean isLayoutItem(int pos) { 1061 // The band selection model only operates on documents and directories. Exclude other 1062 // types of adapter items (e.g. whitespace items like dividers). 1063 RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos); 1064 switch (vh.getItemViewType()) { 1065 case ITEM_TYPE_DOCUMENT: 1066 case ITEM_TYPE_DIRECTORY: 1067 return false; 1068 default: 1069 return true; 1070 } 1071 } 1072 1073 @Override hasView(int pos)1074 public boolean hasView(int pos) { 1075 return mView.findViewHolderForAdapterPosition(pos) != null; 1076 } 1077 } 1078 1079 public interface Callback { 1080 /** 1081 * Called when an item is selected or unselected while in selection mode. 1082 * 1083 * @param position Adapter position of the item that was checked or unchecked 1084 * @param selected <code>true</code> if the item is now selected, <code>false</code> 1085 * if the item is now unselected. 1086 */ onItemStateChanged(String id, boolean selected)1087 public void onItemStateChanged(String id, boolean selected); 1088 1089 /** 1090 * Called prior to an item changing state. Callbacks can cancel 1091 * the change at {@code position} by returning {@code false}. 1092 * 1093 * @param id Adapter position of the item that was checked or unchecked 1094 * @param selected <code>true</code> if the item is to be selected, <code>false</code> 1095 * if the item is to be unselected. 1096 */ onBeforeItemStateChange(String id, boolean selected)1097 public boolean onBeforeItemStateChange(String id, boolean selected); 1098 1099 /** 1100 * Called immediately after completion of any set of changes. 1101 */ onSelectionChanged()1102 public void onSelectionChanged(); 1103 } 1104 1105 /** 1106 * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView} 1107 * and {@link MultiSelectManager}. This class is responsible for rendering the band select 1108 * overlay and selecting overlaid items via MultiSelectManager. 1109 */ 1110 public class BandController extends RecyclerView.OnScrollListener 1111 implements GridModel.OnSelectionChangedListener { 1112 1113 private static final int NOT_SET = -1; 1114 1115 private final Runnable mModelBuilder; 1116 1117 @Nullable private Rect mBounds; 1118 @Nullable private Point mCurrentPosition; 1119 @Nullable private Point mOrigin; 1120 @Nullable private GridModel mModel; 1121 1122 // The time at which the current band selection-induced scroll began. If no scroll is in 1123 // progress, the value is NOT_SET. 1124 private long mScrollStartTime = NOT_SET; 1125 private final Runnable mViewScroller = new ViewScroller(); 1126 BandController()1127 public BandController() { 1128 mEnvironment.addOnScrollListener(this); 1129 1130 mModelBuilder = new Runnable() { 1131 @Override 1132 public void run() { 1133 mModel = new GridModel(mEnvironment, mAdapter); 1134 mModel.addOnSelectionChangedListener(BandController.this); 1135 } 1136 }; 1137 } 1138 handleEvent(MotionInputEvent e)1139 public boolean handleEvent(MotionInputEvent e) { 1140 // b/23793622 notes the fact that we *never* receive ACTION_DOWN 1141 // events in onTouchEvent. Where it not for this issue, we'd 1142 // push start handling down into handleInputEvent. 1143 if (mBandManager.shouldStart(e)) { 1144 // endBandSelect is handled in handleInputEvent. 1145 mBandManager.startBandSelect(e.getOrigin()); 1146 } else if (mBandManager.isActive() 1147 && e.isMouseEvent() 1148 && e.isActionUp()) { 1149 // Same issue here w b/23793622. The ACTION_UP event 1150 // is only evert dispatched to onTouchEvent when 1151 // there is some associated motion. If a user taps 1152 // mouse, but doesn't move, then band select gets 1153 // started BUT not ended. Causing phantom 1154 // bands to appear when the user later clicks to start 1155 // band select. 1156 mBandManager.processInputEvent(e); 1157 } 1158 1159 return isActive(); 1160 } 1161 isActive()1162 private boolean isActive() { 1163 return mModel != null; 1164 } 1165 1166 /** 1167 * Handle a change in layout by cleaning up and getting rid of the old model and creating 1168 * a new model which will track the new layout. 1169 */ handleLayoutChanged()1170 public void handleLayoutChanged() { 1171 if (mModel != null) { 1172 mModel.removeOnSelectionChangedListener(this); 1173 mModel.stopListening(); 1174 1175 // build a new model, all fresh and happy. 1176 mModelBuilder.run(); 1177 } 1178 } 1179 shouldStart(MotionInputEvent e)1180 boolean shouldStart(MotionInputEvent e) { 1181 return !isActive() 1182 && e.isMouseEvent() // a mouse 1183 && e.isActionDown() // the initial button press 1184 && mAdapter.getItemCount() > 0 1185 && e.getItemPosition() == RecyclerView.NO_ID; // in empty space 1186 } 1187 shouldStop(InputEvent input)1188 boolean shouldStop(InputEvent input) { 1189 return isActive() 1190 && input.isMouseEvent() 1191 && input.isActionUp(); 1192 } 1193 1194 /** 1195 * Processes a MotionEvent by starting, ending, or resizing the band select overlay. 1196 * @param input 1197 */ processInputEvent(InputEvent input)1198 private void processInputEvent(InputEvent input) { 1199 assert(input.isMouseEvent()); 1200 1201 if (shouldStop(input)) { 1202 endBandSelect(); 1203 return; 1204 } 1205 1206 // We shouldn't get any events in this method when band select is not active, 1207 // but it turns some guests show up late to the party. 1208 if (!isActive()) { 1209 return; 1210 } 1211 1212 mCurrentPosition = input.getOrigin(); 1213 mModel.resizeSelection(input.getOrigin()); 1214 scrollViewIfNecessary(); 1215 resizeBandSelectRectangle(); 1216 } 1217 1218 /** 1219 * Starts band select by adding the drawable to the RecyclerView's overlay. 1220 */ startBandSelect(Point origin)1221 private void startBandSelect(Point origin) { 1222 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin); 1223 1224 mOrigin = origin; 1225 mModelBuilder.run(); // Creates a new selection model. 1226 mModel.startSelection(mOrigin); 1227 } 1228 1229 /** 1230 * Scrolls the view if necessary. 1231 */ scrollViewIfNecessary()1232 private void scrollViewIfNecessary() { 1233 mEnvironment.removeCallback(mViewScroller); 1234 mViewScroller.run(); 1235 mEnvironment.invalidateView(); 1236 } 1237 1238 /** 1239 * Resizes the band select rectangle by using the origin and the current pointer position as 1240 * two opposite corners of the selection. 1241 */ resizeBandSelectRectangle()1242 private void resizeBandSelectRectangle() { 1243 mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x), 1244 Math.min(mOrigin.y, mCurrentPosition.y), 1245 Math.max(mOrigin.x, mCurrentPosition.x), 1246 Math.max(mOrigin.y, mCurrentPosition.y)); 1247 mEnvironment.showBand(mBounds); 1248 } 1249 1250 /** 1251 * Ends band select by removing the overlay. 1252 */ endBandSelect()1253 private void endBandSelect() { 1254 if (DEBUG) Log.d(TAG, "Ending band select."); 1255 1256 mEnvironment.hideBand(); 1257 mSelection.applyProvisionalSelection(); 1258 mModel.endSelection(); 1259 int firstSelected = mModel.getPositionNearestOrigin(); 1260 if (firstSelected != NOT_SET) { 1261 if (mSelection.contains(mAdapter.getModelId(firstSelected))) { 1262 // TODO: firstSelected should really be lastSelected, we want to anchor the item 1263 // where the mouse-up occurred. 1264 setSelectionRangeBegin(firstSelected); 1265 } else { 1266 // TODO: Check if this is really happening. 1267 Log.w(TAG, "First selected by band is NOT in selection!"); 1268 } 1269 } 1270 1271 mModel = null; 1272 mOrigin = null; 1273 } 1274 1275 @Override onSelectionChanged(Set<String> updatedSelection)1276 public void onSelectionChanged(Set<String> updatedSelection) { 1277 Map<String, Boolean> delta = mSelection.setProvisionalSelection(updatedSelection); 1278 for (Map.Entry<String, Boolean> entry: delta.entrySet()) { 1279 notifyItemStateChanged(entry.getKey(), entry.getValue()); 1280 } 1281 notifySelectionChanged(); 1282 } 1283 1284 @Override onBeforeItemStateChange(String id, boolean nextState)1285 public boolean onBeforeItemStateChange(String id, boolean nextState) { 1286 return notifyBeforeItemStateChange(id, nextState); 1287 } 1288 1289 private class ViewScroller implements Runnable { 1290 /** 1291 * The number of milliseconds of scrolling at which scroll speed continues to increase. 1292 * At first, the scroll starts slowly; then, the rate of scrolling increases until it 1293 * reaches its maximum value at after this many milliseconds. 1294 */ 1295 private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000; 1296 1297 @Override run()1298 public void run() { 1299 // Compute the number of pixels the pointer's y-coordinate is past the view. 1300 // Negative values mean the pointer is at or before the top of the view, and 1301 // positive values mean that the pointer is at or after the bottom of the view. Note 1302 // that one additional pixel is added here so that the view still scrolls when the 1303 // pointer is exactly at the top or bottom. 1304 int pixelsPastView = 0; 1305 if (mCurrentPosition.y <= 0) { 1306 pixelsPastView = mCurrentPosition.y - 1; 1307 } else if (mCurrentPosition.y >= mEnvironment.getHeight() - 1) { 1308 pixelsPastView = mCurrentPosition.y - mEnvironment.getHeight() + 1; 1309 } 1310 1311 if (!isActive() || pixelsPastView == 0) { 1312 // If band selection is inactive, or if it is active but not at the edge of the 1313 // view, no scrolling is necessary. 1314 mScrollStartTime = NOT_SET; 1315 return; 1316 } 1317 1318 if (mScrollStartTime == NOT_SET) { 1319 // If the pointer was previously not at the edge of the view but now is, set the 1320 // start time for the scroll. 1321 mScrollStartTime = System.currentTimeMillis(); 1322 } 1323 1324 // Compute the number of pixels to scroll, and scroll that many pixels. 1325 final int numPixels = computeScrollDistance( 1326 pixelsPastView, System.currentTimeMillis() - mScrollStartTime); 1327 mEnvironment.scrollBy(numPixels); 1328 1329 mEnvironment.removeCallback(mViewScroller); 1330 mEnvironment.runAtNextFrame(this); 1331 } 1332 1333 /** 1334 * Computes the number of pixels to scroll based on how far the pointer is past the end 1335 * of the view and how long it has been there. Roughly based on ItemTouchHelper's 1336 * algorithm for computing the number of pixels to scroll when an item is dragged to the 1337 * end of a {@link RecyclerView}. 1338 * @param pixelsPastView 1339 * @param scrollDuration 1340 * @return 1341 */ computeScrollDistance(int pixelsPastView, long scrollDuration)1342 private int computeScrollDistance(int pixelsPastView, long scrollDuration) { 1343 final int maxScrollStep = mEnvironment.getHeight(); 1344 final int direction = (int) Math.signum(pixelsPastView); 1345 final int absPastView = Math.abs(pixelsPastView); 1346 1347 // Calculate the ratio of how far out of the view the pointer currently resides to 1348 // the entire height of the view. 1349 final float outOfBoundsRatio = Math.min( 1350 1.0f, (float) absPastView / mEnvironment.getHeight()); 1351 // Interpolate this ratio and use it to compute the maximum scroll that should be 1352 // possible for this step. 1353 final float cappedScrollStep = 1354 direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio); 1355 1356 // Likewise, calculate the ratio of the time spent in the scroll to the limit. 1357 final float timeRatio = Math.min( 1358 1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS); 1359 // Interpolate this ratio and use it to compute the final number of pixels to 1360 // scroll. 1361 final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio)); 1362 1363 // If the final number of pixels to scroll ends up being 0, the view should still 1364 // scroll at least one pixel. 1365 return numPixels != 0 ? numPixels : direction; 1366 } 1367 1368 /** 1369 * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends 1370 * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that 1371 * drags that are at the edge or barely past the edge of the view still cause sufficient 1372 * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if 1373 * needed. 1374 * @param ratio A ratio which is in the range [0, 1]. 1375 * @return A "smoothed" value, also in the range [0, 1]. 1376 */ smoothOutOfBoundsRatio(float ratio)1377 private float smoothOutOfBoundsRatio(float ratio) { 1378 return (float) Math.pow(ratio - 1.0f, 5) + 1.0f; 1379 } 1380 1381 /** 1382 * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1) 1383 * and stays close to 0 for most input values except those very close to 1. This ensures 1384 * that scrolls start out very slowly but speed up drastically after the scroll has been 1385 * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used, 1386 * but this could also be tweaked if needed. 1387 * @param ratio A ratio which is in the range [0, 1]. 1388 * @return A "smoothed" value, also in the range [0, 1]. 1389 */ smoothTimeRatio(float ratio)1390 private float smoothTimeRatio(float ratio) { 1391 return (float) Math.pow(ratio, 5); 1392 } 1393 }; 1394 1395 @Override onScrolled(RecyclerView recyclerView, int dx, int dy)1396 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 1397 if (!isActive()) { 1398 return; 1399 } 1400 1401 // Adjust the y-coordinate of the origin the opposite number of pixels so that the 1402 // origin remains in the same place relative to the view's items. 1403 mOrigin.y -= dy; 1404 resizeBandSelectRectangle(); 1405 } 1406 } 1407 1408 /** 1409 * Provides a band selection item model for views within a RecyclerView. This class queries the 1410 * RecyclerView to determine where its items are placed; then, once band selection is underway, 1411 * it alerts listeners of which items are covered by the selections. 1412 */ 1413 public static final class GridModel extends RecyclerView.OnScrollListener { 1414 1415 public static final int NOT_SET = -1; 1416 1417 // Enum values used to determine the corner at which the origin is located within the 1418 private static final int UPPER = 0x00; 1419 private static final int LOWER = 0x01; 1420 private static final int LEFT = 0x00; 1421 private static final int RIGHT = 0x02; 1422 private static final int UPPER_LEFT = UPPER | LEFT; 1423 private static final int UPPER_RIGHT = UPPER | RIGHT; 1424 private static final int LOWER_LEFT = LOWER | LEFT; 1425 private static final int LOWER_RIGHT = LOWER | RIGHT; 1426 1427 private final SelectionEnvironment mHelper; 1428 private final DocumentsAdapter mAdapter; 1429 1430 private final List<OnSelectionChangedListener> mOnSelectionChangedListeners = 1431 new ArrayList<>(); 1432 1433 // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed 1434 // by their y-offset. For example, if the first column of the view starts at an x-value of 5, 1435 // mColumns.get(5) would return an array of positions in that column. Within that array, the 1436 // value for key y is the adapter position for the item whose y-offset is y. 1437 private final SparseArray<SparseIntArray> mColumns = new SparseArray<>(); 1438 1439 // List of limits along the x-axis (columns). 1440 // This list is sorted from furthest left to furthest right. 1441 private final List<Limits> mColumnBounds = new ArrayList<>(); 1442 1443 // List of limits along the y-axis (rows). Note that this list only contains items which 1444 // have been in the viewport. 1445 private final List<Limits> mRowBounds = new ArrayList<>(); 1446 1447 // The adapter positions which have been recorded so far. 1448 private final SparseBooleanArray mKnownPositions = new SparseBooleanArray(); 1449 1450 // Array passed to registered OnSelectionChangedListeners. One array is created and reused 1451 // throughout the lifetime of the object. 1452 private final Set<String> mSelection = new HashSet<>(); 1453 1454 // The current pointer (in absolute positioning from the top of the view). 1455 private Point mPointer = null; 1456 1457 // The bounds of the band selection. 1458 private RelativePoint mRelativeOrigin; 1459 private RelativePoint mRelativePointer; 1460 1461 private boolean mIsActive; 1462 1463 // Tracks where the band select originated from. This is used to determine where selections 1464 // should expand from when Shift+click is used. 1465 private int mPositionNearestOrigin = NOT_SET; 1466 GridModel(SelectionEnvironment helper, DocumentsAdapter adapter)1467 GridModel(SelectionEnvironment helper, DocumentsAdapter adapter) { 1468 mHelper = helper; 1469 mAdapter = adapter; 1470 mHelper.addOnScrollListener(this); 1471 } 1472 1473 /** 1474 * Stops listening to the view's scrolls. Call this function before discarding a 1475 * BandSelecModel object to prevent memory leaks. 1476 */ stopListening()1477 void stopListening() { 1478 mHelper.removeOnScrollListener(this); 1479 } 1480 1481 /** 1482 * Start a band select operation at the given point. 1483 * @param relativeOrigin The origin of the band select operation, relative to the viewport. 1484 * For example, if the view is scrolled to the bottom, the top-left of the viewport 1485 * would have a relative origin of (0, 0), even though its absolute point has a higher 1486 * y-value. 1487 */ startSelection(Point relativeOrigin)1488 void startSelection(Point relativeOrigin) { 1489 recordVisibleChildren(); 1490 if (isEmpty()) { 1491 // The selection band logic works only if there is at least one visible child. 1492 return; 1493 } 1494 1495 mIsActive = true; 1496 mPointer = mHelper.createAbsolutePoint(relativeOrigin); 1497 mRelativeOrigin = new RelativePoint(mPointer); 1498 mRelativePointer = new RelativePoint(mPointer); 1499 computeCurrentSelection(); 1500 notifyListeners(); 1501 } 1502 1503 /** 1504 * Resizes the selection by adjusting the pointer (i.e., the corner of the selection 1505 * opposite the origin. 1506 * @param relativePointer The pointer (opposite of the origin) of the band select operation, 1507 * relative to the viewport. For example, if the view is scrolled to the bottom, the 1508 * top-left of the viewport would have a relative origin of (0, 0), even though its 1509 * absolute point has a higher y-value. 1510 */ 1511 @VisibleForTesting resizeSelection(Point relativePointer)1512 void resizeSelection(Point relativePointer) { 1513 mPointer = mHelper.createAbsolutePoint(relativePointer); 1514 updateModel(); 1515 } 1516 1517 /** 1518 * Ends the band selection. 1519 */ endSelection()1520 void endSelection() { 1521 mIsActive = false; 1522 } 1523 1524 /** 1525 * @return The adapter position for the item nearest the origin corresponding to the latest 1526 * band select operation, or NOT_SET if the selection did not cover any items. 1527 */ getPositionNearestOrigin()1528 int getPositionNearestOrigin() { 1529 return mPositionNearestOrigin; 1530 } 1531 1532 @Override onScrolled(RecyclerView recyclerView, int dx, int dy)1533 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 1534 if (!mIsActive) { 1535 return; 1536 } 1537 1538 mPointer.x += dx; 1539 mPointer.y += dy; 1540 recordVisibleChildren(); 1541 updateModel(); 1542 } 1543 1544 /** 1545 * Queries the view for all children and records their location metadata. 1546 */ recordVisibleChildren()1547 private void recordVisibleChildren() { 1548 for (int i = 0; i < mHelper.getVisibleChildCount(); i++) { 1549 int adapterPosition = mHelper.getAdapterPositionAt(i); 1550 // Sometimes the view is not attached, as we notify the multi selection manager 1551 // synchronously, while views are attached asynchronously. As a result items which 1552 // are in the adapter may not actually have a corresponding view (yet). 1553 if (mHelper.hasView(adapterPosition) && 1554 !mHelper.isLayoutItem(adapterPosition) && 1555 !mKnownPositions.get(adapterPosition)) { 1556 mKnownPositions.put(adapterPosition, true); 1557 recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition); 1558 } 1559 } 1560 } 1561 1562 /** 1563 * Checks if there are any recorded children. 1564 */ isEmpty()1565 private boolean isEmpty() { 1566 return mColumnBounds.size() == 0 || mRowBounds.size() == 0; 1567 } 1568 1569 /** 1570 * Updates the limits lists and column map with the given item metadata. 1571 * @param absoluteChildRect The absolute rectangle for the child view being processed. 1572 * @param adapterPosition The position of the child view being processed. 1573 */ recordItemData(Rect absoluteChildRect, int adapterPosition)1574 private void recordItemData(Rect absoluteChildRect, int adapterPosition) { 1575 if (mColumnBounds.size() != mHelper.getColumnCount()) { 1576 // If not all x-limits have been recorded, record this one. 1577 recordLimits( 1578 mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right)); 1579 } 1580 1581 recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom)); 1582 1583 SparseIntArray columnList = mColumns.get(absoluteChildRect.left); 1584 if (columnList == null) { 1585 columnList = new SparseIntArray(); 1586 mColumns.put(absoluteChildRect.left, columnList); 1587 } 1588 columnList.put(absoluteChildRect.top, adapterPosition); 1589 } 1590 1591 /** 1592 * Ensures limits exists within the sorted list limitsList, and adds it to the list if it 1593 * does not exist. 1594 */ recordLimits(List<Limits> limitsList, Limits limits)1595 private void recordLimits(List<Limits> limitsList, Limits limits) { 1596 int index = Collections.binarySearch(limitsList, limits); 1597 if (index < 0) { 1598 limitsList.add(~index, limits); 1599 } 1600 } 1601 1602 /** 1603 * Handles a moved pointer; this function determines whether the pointer movement resulted 1604 * in a selection change and, if it has, notifies listeners of this change. 1605 */ updateModel()1606 private void updateModel() { 1607 RelativePoint old = mRelativePointer; 1608 mRelativePointer = new RelativePoint(mPointer); 1609 if (old != null && mRelativePointer.equals(old)) { 1610 return; 1611 } 1612 1613 computeCurrentSelection(); 1614 notifyListeners(); 1615 } 1616 1617 /** 1618 * Computes the currently-selected items. 1619 */ computeCurrentSelection()1620 private void computeCurrentSelection() { 1621 if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) { 1622 updateSelection(computeBounds()); 1623 } else { 1624 mSelection.clear(); 1625 mPositionNearestOrigin = NOT_SET; 1626 } 1627 } 1628 1629 /** 1630 * Notifies all listeners of a selection change. Note that this function simply passes 1631 * mSelection, so computeCurrentSelection() should be called before this 1632 * function. 1633 */ notifyListeners()1634 private void notifyListeners() { 1635 for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) { 1636 listener.onSelectionChanged(mSelection); 1637 } 1638 } 1639 1640 /** 1641 * @param rect Rectangle including all covered items. 1642 */ updateSelection(Rect rect)1643 private void updateSelection(Rect rect) { 1644 int columnStart = 1645 Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left)); 1646 assert(columnStart >= 0); 1647 int columnEnd = columnStart; 1648 1649 for (int i = columnStart; i < mColumnBounds.size() 1650 && mColumnBounds.get(i).lowerLimit <= rect.right; i++) { 1651 columnEnd = i; 1652 } 1653 1654 int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top)); 1655 if (rowStart < 0) { 1656 mPositionNearestOrigin = NOT_SET; 1657 return; 1658 } 1659 1660 int rowEnd = rowStart; 1661 for (int i = rowStart; i < mRowBounds.size() 1662 && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) { 1663 rowEnd = i; 1664 } 1665 1666 updateSelection(columnStart, columnEnd, rowStart, rowEnd); 1667 } 1668 1669 /** 1670 * Computes the selection given the previously-computed start- and end-indices for each 1671 * row and column. 1672 */ updateSelection( int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex)1673 private void updateSelection( 1674 int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) { 1675 if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d", 1676 columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex)); 1677 1678 mSelection.clear(); 1679 for (int column = columnStartIndex; column <= columnEndIndex; column++) { 1680 SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit); 1681 for (int row = rowStartIndex; row <= rowEndIndex; row++) { 1682 // The default return value for SparseIntArray.get is 0, which is a valid 1683 // position. Use a sentry value to prevent erroneously selecting item 0. 1684 final int rowKey = mRowBounds.get(row).lowerLimit; 1685 int position = items.get(rowKey, NOT_SET); 1686 if (position != NOT_SET) { 1687 String id = mAdapter.getModelId(position); 1688 if (id != null) { 1689 // The adapter inserts items for UI layout purposes that aren't associated 1690 // with files. Those will have a null model ID. Don't select them. 1691 if (canSelect(id)) { 1692 mSelection.add(id); 1693 } 1694 } 1695 if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex, 1696 row, rowStartIndex, rowEndIndex)) { 1697 // If this is the position nearest the origin, record it now so that it 1698 // can be returned by endSelection() later. 1699 mPositionNearestOrigin = position; 1700 } 1701 } 1702 } 1703 } 1704 } 1705 1706 /** 1707 * @return True if the item is selectable. 1708 */ canSelect(String id)1709 private boolean canSelect(String id) { 1710 // TODO: Simplify the logic, so the check whether we can select is done in one place. 1711 // Consider injecting FragmentTuner, or move the checks from MultiSelectManager to 1712 // Selection. 1713 for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) { 1714 if (!listener.onBeforeItemStateChange(id, true)) { 1715 return false; 1716 } 1717 } 1718 return true; 1719 } 1720 1721 /** 1722 * @return Returns true if the position is the nearest to the origin, or, in the case of the 1723 * lower-right corner, whether it is possible that the position is the nearest to the 1724 * origin. See comment below for reasoning for this special case. 1725 */ isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex)1726 private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, 1727 int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) { 1728 int corner = computeCornerNearestOrigin(); 1729 switch (corner) { 1730 case UPPER_LEFT: 1731 return columnIndex == columnStartIndex && rowIndex == rowStartIndex; 1732 case UPPER_RIGHT: 1733 return columnIndex == columnEndIndex && rowIndex == rowStartIndex; 1734 case LOWER_LEFT: 1735 return columnIndex == columnStartIndex && rowIndex == rowEndIndex; 1736 case LOWER_RIGHT: 1737 // Note that in some cases, the last row will not have as many items as there 1738 // are columns (e.g., if there are 4 items and 3 columns, the second row will 1739 // only have one item in the first column). This function is invoked for each 1740 // position from left to right, so return true for any position in the bottom 1741 // row and only the right-most position in the bottom row will be recorded. 1742 return rowIndex == rowEndIndex; 1743 default: 1744 throw new RuntimeException("Invalid corner type."); 1745 } 1746 } 1747 1748 /** 1749 * Listener for changes in which items have been band selected. 1750 */ 1751 static interface OnSelectionChangedListener { onSelectionChanged(Set<String> updatedSelection)1752 public void onSelectionChanged(Set<String> updatedSelection); onBeforeItemStateChange(String id, boolean nextState)1753 public boolean onBeforeItemStateChange(String id, boolean nextState); 1754 } 1755 addOnSelectionChangedListener(OnSelectionChangedListener listener)1756 void addOnSelectionChangedListener(OnSelectionChangedListener listener) { 1757 mOnSelectionChangedListeners.add(listener); 1758 } 1759 removeOnSelectionChangedListener(OnSelectionChangedListener listener)1760 void removeOnSelectionChangedListener(OnSelectionChangedListener listener) { 1761 mOnSelectionChangedListeners.remove(listener); 1762 } 1763 1764 /** 1765 * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side 1766 * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides 1767 * of item columns and the top- and bottom sides of item rows so that it can be determined 1768 * whether the pointer is located within the bounds of an item. 1769 */ 1770 private static class Limits implements Comparable<Limits> { 1771 int lowerLimit; 1772 int upperLimit; 1773 Limits(int lowerLimit, int upperLimit)1774 Limits(int lowerLimit, int upperLimit) { 1775 this.lowerLimit = lowerLimit; 1776 this.upperLimit = upperLimit; 1777 } 1778 1779 @Override compareTo(Limits other)1780 public int compareTo(Limits other) { 1781 return lowerLimit - other.lowerLimit; 1782 } 1783 1784 @Override equals(Object other)1785 public boolean equals(Object other) { 1786 if (!(other instanceof Limits)) { 1787 return false; 1788 } 1789 1790 return ((Limits) other).lowerLimit == lowerLimit && 1791 ((Limits) other).upperLimit == upperLimit; 1792 } 1793 1794 @Override toString()1795 public String toString() { 1796 return "(" + lowerLimit + ", " + upperLimit + ")"; 1797 } 1798 } 1799 1800 /** 1801 * The location of a coordinate relative to items. This class represents a general area of the 1802 * view as it relates to band selection rather than an explicit point. For example, two 1803 * different points within an item are considered to have the same "location" because band 1804 * selection originating within the item would select the same items no matter which point 1805 * was used. Same goes for points between items as well as those at the very beginning or end 1806 * of the view. 1807 * 1808 * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the 1809 * advantage of tying the value to the Limits of items along that axis. This allows easy 1810 * selection of items within those Limits as opposed to a search through every item to see if a 1811 * given coordinate value falls within those Limits. 1812 */ 1813 private static class RelativeCoordinate 1814 implements Comparable<RelativeCoordinate> { 1815 /** 1816 * Location describing points after the last known item. 1817 */ 1818 static final int AFTER_LAST_ITEM = 0; 1819 1820 /** 1821 * Location describing points before the first known item. 1822 */ 1823 static final int BEFORE_FIRST_ITEM = 1; 1824 1825 /** 1826 * Location describing points between two items. 1827 */ 1828 static final int BETWEEN_TWO_ITEMS = 2; 1829 1830 /** 1831 * Location describing points within the limits of one item. 1832 */ 1833 static final int WITHIN_LIMITS = 3; 1834 1835 /** 1836 * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM, 1837 * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS. 1838 */ 1839 final int type; 1840 1841 /** 1842 * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type == 1843 * BETWEEN_TWO_ITEMS. 1844 */ 1845 Limits limitsBeforeCoordinate; 1846 1847 /** 1848 * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS. 1849 */ 1850 Limits limitsAfterCoordinate; 1851 1852 // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM. 1853 Limits mFirstKnownItem; 1854 // Limits of the last known item; only populated when type == AFTER_LAST_ITEM. 1855 Limits mLastKnownItem; 1856 1857 /** 1858 * @param limitsList The sorted limits list for the coordinate type. If this 1859 * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise, 1860 * mYLimitsList should be pased. 1861 * @param value The coordinate value. 1862 */ RelativeCoordinate(List<Limits> limitsList, int value)1863 RelativeCoordinate(List<Limits> limitsList, int value) { 1864 int index = Collections.binarySearch(limitsList, new Limits(value, value)); 1865 1866 if (index >= 0) { 1867 this.type = WITHIN_LIMITS; 1868 this.limitsBeforeCoordinate = limitsList.get(index); 1869 } else if (~index == 0) { 1870 this.type = BEFORE_FIRST_ITEM; 1871 this.mFirstKnownItem = limitsList.get(0); 1872 } else if (~index == limitsList.size()) { 1873 Limits lastLimits = limitsList.get(limitsList.size() - 1); 1874 if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) { 1875 this.type = WITHIN_LIMITS; 1876 this.limitsBeforeCoordinate = lastLimits; 1877 } else { 1878 this.type = AFTER_LAST_ITEM; 1879 this.mLastKnownItem = lastLimits; 1880 } 1881 } else { 1882 Limits limitsBeforeIndex = limitsList.get(~index - 1); 1883 if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) { 1884 this.type = WITHIN_LIMITS; 1885 this.limitsBeforeCoordinate = limitsList.get(~index - 1); 1886 } else { 1887 this.type = BETWEEN_TWO_ITEMS; 1888 this.limitsBeforeCoordinate = limitsList.get(~index - 1); 1889 this.limitsAfterCoordinate = limitsList.get(~index); 1890 } 1891 } 1892 } 1893 toComparisonValue()1894 int toComparisonValue() { 1895 if (type == BEFORE_FIRST_ITEM) { 1896 return mFirstKnownItem.lowerLimit - 1; 1897 } else if (type == AFTER_LAST_ITEM) { 1898 return mLastKnownItem.upperLimit + 1; 1899 } else if (type == BETWEEN_TWO_ITEMS) { 1900 return limitsBeforeCoordinate.upperLimit + 1; 1901 } else { 1902 return limitsBeforeCoordinate.lowerLimit; 1903 } 1904 } 1905 1906 @Override equals(Object other)1907 public boolean equals(Object other) { 1908 if (!(other instanceof RelativeCoordinate)) { 1909 return false; 1910 } 1911 1912 RelativeCoordinate otherCoordinate = (RelativeCoordinate) other; 1913 return toComparisonValue() == otherCoordinate.toComparisonValue(); 1914 } 1915 1916 @Override compareTo(RelativeCoordinate other)1917 public int compareTo(RelativeCoordinate other) { 1918 return toComparisonValue() - other.toComparisonValue(); 1919 } 1920 } 1921 1922 /** 1923 * The location of a point relative to the Limits of nearby items; consists of both an x- and 1924 * y-RelativeCoordinateLocation. 1925 */ 1926 private class RelativePoint { 1927 final RelativeCoordinate xLocation; 1928 final RelativeCoordinate yLocation; 1929 RelativePoint(Point point)1930 RelativePoint(Point point) { 1931 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x); 1932 this.yLocation = new RelativeCoordinate(mRowBounds, point.y); 1933 } 1934 1935 @Override equals(Object other)1936 public boolean equals(Object other) { 1937 if (!(other instanceof RelativePoint)) { 1938 return false; 1939 } 1940 1941 RelativePoint otherPoint = (RelativePoint) other; 1942 return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation); 1943 } 1944 } 1945 1946 /** 1947 * Generates a rectangle which contains the items selected by the pointer and origin. 1948 * @return The rectangle, or null if no items were selected. 1949 */ computeBounds()1950 private Rect computeBounds() { 1951 Rect rect = new Rect(); 1952 rect.left = getCoordinateValue( 1953 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation), 1954 mColumnBounds, 1955 true); 1956 rect.right = getCoordinateValue( 1957 max(mRelativeOrigin.xLocation, mRelativePointer.xLocation), 1958 mColumnBounds, 1959 false); 1960 rect.top = getCoordinateValue( 1961 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation), 1962 mRowBounds, 1963 true); 1964 rect.bottom = getCoordinateValue( 1965 max(mRelativeOrigin.yLocation, mRelativePointer.yLocation), 1966 mRowBounds, 1967 false); 1968 return rect; 1969 } 1970 1971 /** 1972 * Computes the corner of the selection nearest the origin. 1973 * @return 1974 */ computeCornerNearestOrigin()1975 private int computeCornerNearestOrigin() { 1976 int cornerValue = 0; 1977 1978 if (mRelativeOrigin.yLocation == 1979 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) { 1980 cornerValue |= UPPER; 1981 } else { 1982 cornerValue |= LOWER; 1983 } 1984 1985 if (mRelativeOrigin.xLocation == 1986 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) { 1987 cornerValue |= LEFT; 1988 } else { 1989 cornerValue |= RIGHT; 1990 } 1991 1992 return cornerValue; 1993 } 1994 min(RelativeCoordinate first, RelativeCoordinate second)1995 private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) { 1996 return first.compareTo(second) < 0 ? first : second; 1997 } 1998 max(RelativeCoordinate first, RelativeCoordinate second)1999 private RelativeCoordinate max(RelativeCoordinate first, RelativeCoordinate second) { 2000 return first.compareTo(second) > 0 ? first : second; 2001 } 2002 2003 /** 2004 * @return The absolute coordinate (i.e., the x- or y-value) of the given relative 2005 * coordinate. 2006 */ getCoordinateValue(RelativeCoordinate coordinate, List<Limits> limitsList, boolean isStartOfRange)2007 private int getCoordinateValue(RelativeCoordinate coordinate, 2008 List<Limits> limitsList, boolean isStartOfRange) { 2009 switch (coordinate.type) { 2010 case RelativeCoordinate.BEFORE_FIRST_ITEM: 2011 return limitsList.get(0).lowerLimit; 2012 case RelativeCoordinate.AFTER_LAST_ITEM: 2013 return limitsList.get(limitsList.size() - 1).upperLimit; 2014 case RelativeCoordinate.BETWEEN_TWO_ITEMS: 2015 if (isStartOfRange) { 2016 return coordinate.limitsAfterCoordinate.lowerLimit; 2017 } else { 2018 return coordinate.limitsBeforeCoordinate.upperLimit; 2019 } 2020 case RelativeCoordinate.WITHIN_LIMITS: 2021 return coordinate.limitsBeforeCoordinate.lowerLimit; 2022 } 2023 2024 throw new RuntimeException("Invalid coordinate value."); 2025 } 2026 areItemsCoveredByBand( RelativePoint first, RelativePoint second)2027 private boolean areItemsCoveredByBand( 2028 RelativePoint first, RelativePoint second) { 2029 return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) && 2030 doesCoordinateLocationCoverItems(first.yLocation, second.yLocation); 2031 } 2032 doesCoordinateLocationCoverItems( RelativeCoordinate pointerCoordinate, RelativeCoordinate originCoordinate)2033 private boolean doesCoordinateLocationCoverItems( 2034 RelativeCoordinate pointerCoordinate, 2035 RelativeCoordinate originCoordinate) { 2036 if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM && 2037 originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) { 2038 return false; 2039 } 2040 2041 if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM && 2042 originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) { 2043 return false; 2044 } 2045 2046 if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && 2047 originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS && 2048 pointerCoordinate.limitsBeforeCoordinate.equals( 2049 originCoordinate.limitsBeforeCoordinate) && 2050 pointerCoordinate.limitsAfterCoordinate.equals( 2051 originCoordinate.limitsAfterCoordinate)) { 2052 return false; 2053 } 2054 2055 return true; 2056 } 2057 } 2058 } 2059