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 21 import android.annotation.IntDef; 22 import android.support.annotation.VisibleForTesting; 23 import android.support.v7.widget.RecyclerView; 24 import android.util.Log; 25 26 import com.android.documentsui.dirlist.DocumentsAdapter; 27 28 import java.lang.annotation.Retention; 29 import java.lang.annotation.RetentionPolicy; 30 import java.util.ArrayList; 31 import java.util.List; 32 33 import javax.annotation.Nullable; 34 35 /** 36 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView. 37 * Additionally it can be configured to restrict selection to a single element, @see 38 * #setSelectMode. 39 */ 40 public final class SelectionManager { 41 42 @IntDef(flag = true, value = { 43 MODE_MULTIPLE, 44 MODE_SINGLE 45 }) 46 @Retention(RetentionPolicy.SOURCE) 47 public @interface SelectionMode {} 48 public static final int MODE_MULTIPLE = 0; 49 public static final int MODE_SINGLE = 1; 50 51 @IntDef({ 52 RANGE_REGULAR, 53 RANGE_PROVISIONAL 54 }) 55 @Retention(RetentionPolicy.SOURCE) 56 public @interface RangeType {} 57 public static final int RANGE_REGULAR = 0; 58 public static final int RANGE_PROVISIONAL = 1; 59 60 static final String TAG = "SelectionManager"; 61 62 private final Selection mSelection = new Selection(); 63 64 private final List<Callback> mCallbacks = new ArrayList<>(1); 65 private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1); 66 67 private @Nullable DocumentsAdapter mAdapter; 68 private @Nullable Range mRanger; 69 private boolean mSingleSelect; 70 71 private RecyclerView.AdapterDataObserver mAdapterObserver; 72 private SelectionPredicate mCanSetState; 73 74 public SelectionManager(@SelectionMode int mode) { 75 mSingleSelect = mode == MODE_SINGLE; 76 } 77 78 public SelectionManager reset(DocumentsAdapter adapter, SelectionPredicate canSetState) { 79 80 mCallbacks.clear(); 81 mItemCallbacks.clear(); 82 if (mAdapter != null && mAdapterObserver != null) { 83 mAdapter.unregisterAdapterDataObserver(mAdapterObserver); 84 } 85 86 clearSelectionQuietly(); 87 88 assert(adapter != null); 89 assert(canSetState != null); 90 91 mAdapter = adapter; 92 mCanSetState = canSetState; 93 94 mAdapterObserver = new RecyclerView.AdapterDataObserver() { 95 96 private List<String> mModelIds; 97 98 @Override 99 public void onChanged() { 100 mModelIds = mAdapter.getModelIds(); 101 102 // Update the selection to remove any disappeared IDs. 103 mSelection.cancelProvisionalSelection(); 104 mSelection.intersect(mModelIds); 105 106 notifyDataChanged(); 107 } 108 109 @Override 110 public void onItemRangeChanged( 111 int startPosition, int itemCount, Object payload) { 112 // No change in position. Ignoring. 113 } 114 115 @Override 116 public void onItemRangeInserted(int startPosition, int itemCount) { 117 mSelection.cancelProvisionalSelection(); 118 } 119 120 @Override 121 public void onItemRangeRemoved(int startPosition, int itemCount) { 122 assert(startPosition >= 0); 123 assert(itemCount > 0); 124 125 mSelection.cancelProvisionalSelection(); 126 // Remove any disappeared IDs from the selection. 127 mSelection.intersect(mModelIds); 128 } 129 130 @Override 131 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 132 throw new UnsupportedOperationException(); 133 } 134 }; 135 136 mAdapter.registerAdapterDataObserver(mAdapterObserver); 137 return this; 138 } 139 140 void bindContoller(BandController controller) { 141 // Provides BandController with access to private mSelection state. 142 controller.bindSelection(mSelection); 143 } 144 145 /** 146 * Adds {@code callback} such that it will be notified when {@code MultiSelectManager} 147 * events occur. 148 * 149 * @param callback 150 */ 151 public void addCallback(Callback callback) { 152 assert(callback != null); 153 mCallbacks.add(callback); 154 } 155 156 public void addItemCallback(ItemCallback itemCallback) { 157 assert(itemCallback != null); 158 mItemCallbacks.add(itemCallback); 159 } 160 161 public boolean hasSelection() { 162 return !mSelection.isEmpty(); 163 } 164 165 /** 166 * Returns a Selection object that provides a live view 167 * on the current selection. 168 * 169 * @see #getSelection(Selection) on how to get a snapshot 170 * of the selection that will not reflect future changes 171 * to selection. 172 * 173 * @return The current selection. 174 */ 175 public Selection getSelection() { 176 return mSelection; 177 } 178 179 /** 180 * Updates {@code dest} to reflect the current selection. 181 * @param dest 182 * 183 * @return The Selection instance passed in, for convenience. 184 */ 185 public Selection getSelection(Selection dest) { 186 dest.copyFrom(mSelection); 187 return dest; 188 } 189 190 @VisibleForTesting 191 public void replaceSelection(Iterable<String> ids) { 192 clearSelection(); 193 setItemsSelected(ids, true); 194 } 195 196 /** 197 * Restores the selected state of specified items. Used in cases such as restore the selection 198 * after rotation etc. 199 */ 200 public void restoreSelection(Selection other) { 201 setItemsSelectedQuietly(other.mSelection, true); 202 // NOTE: We intentionally don't restore provisional selection. It's provisional. 203 notifySelectionRestored(); 204 } 205 206 /** 207 * Sets the selected state of the specified items. Note that the callback will NOT 208 * be consulted to see if an item can be selected. 209 * 210 * @param ids 211 * @param selected 212 * @return 213 */ 214 public boolean setItemsSelected(Iterable<String> ids, boolean selected) { 215 final boolean changed = setItemsSelectedQuietly(ids, selected); 216 notifySelectionChanged(); 217 return changed; 218 } 219 220 private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) { 221 boolean changed = false; 222 for (String id: ids) { 223 final boolean itemChanged = 224 selected 225 ? canSetState(id, true) && mSelection.add(id) 226 : canSetState(id, false) && mSelection.remove(id); 227 if (itemChanged) { 228 notifyItemStateChanged(id, selected); 229 } 230 changed |= itemChanged; 231 } 232 return changed; 233 } 234 235 /** 236 * Clears the selection and notifies (if something changes). 237 */ 238 public void clearSelection() { 239 if (!hasSelection()) { 240 return; 241 } 242 243 clearSelectionQuietly(); 244 notifySelectionChanged(); 245 } 246 247 /** 248 * Clears the selection, without notifying selection listeners. UI elements still need to be 249 * notified about state changes so that they can update their appearance. 250 */ 251 private void clearSelectionQuietly() { 252 mRanger = null; 253 254 if (!hasSelection()) { 255 return; 256 } 257 258 Selection oldSelection = getSelection(new Selection()); 259 mSelection.clear(); 260 261 for (String id: oldSelection.mSelection) { 262 notifyItemStateChanged(id, false); 263 } 264 for (String id: oldSelection.mProvisionalSelection) { 265 notifyItemStateChanged(id, false); 266 } 267 } 268 269 /** 270 * Toggles selection on the item with the given model ID. 271 * 272 * @param modelId 273 */ 274 public void toggleSelection(String modelId) { 275 assert(modelId != null); 276 277 final boolean changed = mSelection.contains(modelId) 278 ? attemptDeselect(modelId) 279 : attemptSelect(modelId); 280 281 if (changed) { 282 notifySelectionChanged(); 283 } 284 } 285 286 /** 287 * Starts a range selection. If a range selection is already active, this will start a new range 288 * selection (which will reset the range anchor). 289 * 290 * @param pos The anchor position for the selection range. 291 */ 292 public void startRangeSelection(int pos) { 293 attemptSelect(mAdapter.getModelId(pos)); 294 setSelectionRangeBegin(pos); 295 } 296 297 public void snapRangeSelection(int pos) { 298 snapRangeSelection(pos, RANGE_REGULAR); 299 } 300 301 void snapProvisionalRangeSelection(int pos) { 302 snapRangeSelection(pos, RANGE_PROVISIONAL); 303 } 304 305 /* 306 * Starts and extends range selection in one go. This assumes item at startPos is not selected 307 * beforehand. 308 */ 309 public void formNewSelectionRange(int startPos, int endPos) { 310 assert(!mSelection.contains(mAdapter.getModelId(startPos))); 311 startRangeSelection(startPos); 312 snapRangeSelection(endPos); 313 } 314 315 /** 316 * Sets the end point for the current range selection, started by a call to 317 * {@link #startRangeSelection(int)}. This function should only be called when a range selection 318 * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be 319 * selected or in provisional select, depending on the type supplied. Note that if the type is 320 * provisional select, one should do {@link Selection#applyProvisionalSelection()} at some point 321 * before calling on {@link #endRangeSelection()}. 322 * 323 * @param pos The new end position for the selection range. 324 * @param type The type of selection the range should utilize. 325 */ 326 private void snapRangeSelection(int pos, @RangeType int type) { 327 if (!isRangeSelectionActive()) { 328 throw new IllegalStateException("Range start point not set."); 329 } 330 331 mRanger.snapSelection(pos, type); 332 333 // We're being lazy here notifying even when something might not have changed. 334 // To make this more correct, we'd need to update the Ranger class to return 335 // information about what has changed. 336 notifySelectionChanged(); 337 } 338 339 void cancelProvisionalSelection() { 340 for (String id : mSelection.mProvisionalSelection) { 341 notifyItemStateChanged(id, false); 342 } 343 mSelection.cancelProvisionalSelection(); 344 } 345 346 /** 347 * Stops an in-progress range selection. All selection done with 348 * {@link #snapRangeSelection(int, int)} with type RANGE_PROVISIONAL will be lost if 349 * {@link Selection#applyProvisionalSelection()} is not called beforehand. 350 */ 351 public void endRangeSelection() { 352 mRanger = null; 353 // Clean up in case there was any leftover provisional selection 354 cancelProvisionalSelection(); 355 } 356 357 /** 358 * @return Whether or not there is a current range selection active. 359 */ 360 public boolean isRangeSelectionActive() { 361 return mRanger != null; 362 } 363 364 /** 365 * Sets the magic location at which a selection range begins (the selection anchor). This value 366 * is consulted when determining how to extend, and modify selection ranges. Calling this when a 367 * range selection is active will reset the range selection. 368 */ 369 public void setSelectionRangeBegin(int position) { 370 if (position == RecyclerView.NO_POSITION) { 371 return; 372 } 373 374 if (mSelection.contains(mAdapter.getModelId(position))) { 375 mRanger = new Range(this::updateForRange, position); 376 } 377 } 378 379 /** 380 * @param modelId 381 * @return True if the update was applied. 382 */ 383 private boolean selectAndNotify(String modelId) { 384 boolean changed = mSelection.add(modelId); 385 if (changed) { 386 notifyItemStateChanged(modelId, true); 387 } 388 return changed; 389 } 390 391 /** 392 * @param id 393 * @return True if the update was applied. 394 */ 395 private boolean attemptDeselect(String id) { 396 assert(id != null); 397 if (canSetState(id, false)) { 398 mSelection.remove(id); 399 notifyItemStateChanged(id, false); 400 401 // if there's nothing in the selection and there is an active ranger it results 402 // in unexpected behavior when the user tries to start range selection: the item 403 // which the ranger 'thinks' is the already selected anchor becomes unselectable 404 if (mSelection.isEmpty() && isRangeSelectionActive()) { 405 endRangeSelection(); 406 } 407 if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection); 408 return true; 409 } else { 410 if (DEBUG) Log.d(TAG, "Select cancelled by listener."); 411 return false; 412 } 413 } 414 415 /** 416 * @param id 417 * @return True if the update was applied. 418 */ 419 private boolean attemptSelect(String id) { 420 assert(id != null); 421 boolean canSelect = canSetState(id, true); 422 if (!canSelect) { 423 return false; 424 } 425 if (mSingleSelect && hasSelection()) { 426 clearSelectionQuietly(); 427 } 428 429 selectAndNotify(id); 430 return true; 431 } 432 433 boolean canSetState(String id, boolean nextState) { 434 return mCanSetState.test(id, nextState); 435 } 436 437 private void notifyDataChanged() { 438 final int lastListener = mItemCallbacks.size() - 1; 439 440 for (int i = lastListener; i >= 0; i--) { 441 mItemCallbacks.get(i).onSelectionReset(); 442 } 443 444 for (String id : mSelection) { 445 if (!canSetState(id, true)) { 446 attemptDeselect(id); 447 } else { 448 for (int i = lastListener; i >= 0; i--) { 449 mItemCallbacks.get(i).onItemStateChanged(id, true); 450 } 451 } 452 } 453 } 454 455 /** 456 * Notifies registered listeners when the selection status of a single item 457 * (identified by {@code position}) changes. 458 */ 459 void notifyItemStateChanged(String id, boolean selected) { 460 assert(id != null); 461 int lastListener = mItemCallbacks.size() - 1; 462 for (int i = lastListener; i >= 0; i--) { 463 mItemCallbacks.get(i).onItemStateChanged(id, selected); 464 } 465 mAdapter.onItemSelectionChanged(id); 466 } 467 468 /** 469 * Notifies registered listeners when the selection has changed. This 470 * notification should be sent only once a full series of changes 471 * is complete, e.g. clearingSelection, or updating the single 472 * selection from one item to another. 473 */ 474 void notifySelectionChanged() { 475 int lastListener = mCallbacks.size() - 1; 476 for (int i = lastListener; i > -1; i--) { 477 mCallbacks.get(i).onSelectionChanged(); 478 } 479 } 480 481 private void notifySelectionRestored() { 482 int lastListener = mCallbacks.size() - 1; 483 for (int i = lastListener; i > -1; i--) { 484 mCallbacks.get(i).onSelectionRestored(); 485 } 486 } 487 488 void updateForRange(int begin, int end, boolean selected, @RangeType int type) { 489 switch (type) { 490 case RANGE_REGULAR: 491 updateForRegularRange(begin, end, selected); 492 break; 493 case RANGE_PROVISIONAL: 494 updateForProvisionalRange(begin, end, selected); 495 break; 496 default: 497 throw new IllegalArgumentException("Invalid range type: " + type); 498 } 499 } 500 501 private void updateForRegularRange(int begin, int end, boolean selected) { 502 assert(end >= begin); 503 for (int i = begin; i <= end; i++) { 504 String id = mAdapter.getModelId(i); 505 if (id == null) { 506 continue; 507 } 508 509 if (selected) { 510 boolean canSelect = canSetState(id, true); 511 if (canSelect) { 512 if (mSingleSelect && hasSelection()) { 513 clearSelectionQuietly(); 514 } 515 selectAndNotify(id); 516 } 517 } else { 518 attemptDeselect(id); 519 } 520 } 521 } 522 523 private void updateForProvisionalRange(int begin, int end, boolean selected) { 524 assert (end >= begin); 525 for (int i = begin; i <= end; i++) { 526 String id = mAdapter.getModelId(i); 527 if (id == null) { 528 continue; 529 } 530 531 boolean changedState = false; 532 if (selected) { 533 boolean canSelect = canSetState(id, true); 534 if (canSelect && !mSelection.mSelection.contains(id)) { 535 mSelection.mProvisionalSelection.add(id); 536 changedState = true; 537 } 538 } else { 539 mSelection.mProvisionalSelection.remove(id); 540 changedState = true; 541 } 542 543 // Only notify item callbacks when something's state is actually changed in provisional 544 // selection. 545 if (changedState) { 546 notifyItemStateChanged(id, selected); 547 } 548 } 549 notifySelectionChanged(); 550 } 551 552 public interface ItemCallback { 553 void onItemStateChanged(String id, boolean selected); 554 555 void onSelectionReset(); 556 } 557 558 public interface Callback { 559 /** 560 * Called immediately after completion of any set of changes. 561 */ 562 void onSelectionChanged(); 563 564 /** 565 * Called immediately after selection is restored. 566 */ 567 void onSelectionRestored(); 568 } 569 570 @FunctionalInterface 571 public interface SelectionPredicate { 572 boolean test(String id, boolean nextState); 573 } 574 } 575