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