1 /* 2 * Copyright (C) 2015 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.documentsui.selection; 18 19 import static android.support.v4.util.Preconditions.checkArgument; 20 import static android.support.v4.util.Preconditions.checkState; 21 import static com.android.documentsui.selection.Shared.DEBUG; 22 import static com.android.documentsui.selection.Shared.TAG; 23 24 import android.support.annotation.IntDef; 25 import android.support.v7.widget.RecyclerView; 26 import android.support.v7.widget.RecyclerView.Adapter; 27 import android.util.Log; 28 29 import java.lang.annotation.Retention; 30 import java.lang.annotation.RetentionPolicy; 31 import java.util.ArrayList; 32 import java.util.List; 33 import java.util.Map; 34 import java.util.Set; 35 36 import javax.annotation.Nullable; 37 38 /** 39 * {@link SelectionHelper} providing support traditional multi-item selection on top 40 * of {@link RecyclerView}. 41 * 42 * <p>The class supports running in a single-select mode, which can be enabled 43 * by passing {@colde #MODE_SINGLE} to the constructor. 44 */ 45 public final class DefaultSelectionHelper extends SelectionHelper { 46 47 public static final int MODE_MULTIPLE = 0; 48 public static final int MODE_SINGLE = 1; 49 50 @IntDef(flag = true, value = { 51 MODE_MULTIPLE, 52 MODE_SINGLE 53 }) 54 @Retention(RetentionPolicy.SOURCE) 55 public @interface SelectionMode {} 56 57 private static final int RANGE_REGULAR = 0; 58 59 /** 60 * "Provisional" selection represents a overlay on the primary selection. A provisional 61 * selection maybe be eventually added to the primary selection, or it may be abandoned. 62 * 63 * <p>E.g. BandController creates a provisional selection while a user is actively selecting 64 * items with the band. Provisionally selected items are considered to be selected in 65 * {@link Selection#contains(String)} and related methods. A provisional may be abandoned or 66 * applied by selection components (like 67 * {@link com.android.documentsui.selection.BandSelectionHelper}). 68 * 69 * <p>A provisional selection may intersect the primary selection, however clearing the 70 * provisional selection will not affect the primary selection where the two may intersect. 71 */ 72 private static final int RANGE_PROVISIONAL = 1; 73 @IntDef({ 74 RANGE_REGULAR, 75 RANGE_PROVISIONAL 76 }) 77 @Retention(RetentionPolicy.SOURCE) 78 @interface RangeType {} 79 80 private final Selection mSelection = new Selection(); 81 private final List<SelectionObserver> mObservers = new ArrayList<>(1); 82 private final RecyclerView.Adapter<?> mAdapter; 83 private final StableIdProvider mStableIds; 84 private final SelectionPredicate mSelectionPredicate; 85 private final RecyclerView.AdapterDataObserver mAdapterObserver; 86 private final RangeCallbacks mRangeCallbacks; 87 private final boolean mSingleSelect; 88 89 private @Nullable Range mRange; 90 91 /** 92 * Creates a new instance. 93 * 94 * @param mode single or multiple selection mode. In single selection mode 95 * users can only select a single item. 96 * @param adapter {@link Adapter} for the RecyclerView this instance is coupled with. 97 * @param stableIds client supplied class providing access to stable ids. 98 * @param selectionPredicate A predicate allowing the client to disallow selection 99 * of individual elements. 100 */ 101 public DefaultSelectionHelper( 102 @SelectionMode int mode, 103 RecyclerView.Adapter<?> adapter, 104 StableIdProvider stableIds, 105 SelectionPredicate selectionPredicate) { 106 107 checkArgument(mode == MODE_SINGLE || mode == MODE_MULTIPLE); 108 checkArgument(adapter != null); 109 checkArgument(stableIds != null); 110 checkArgument(selectionPredicate != null); 111 112 mAdapter = adapter; 113 mStableIds = stableIds; 114 mSelectionPredicate = selectionPredicate; 115 mAdapterObserver = new AdapterObserver(); 116 mRangeCallbacks = new RangeCallbacks(); 117 118 mSingleSelect = mode == MODE_SINGLE; 119 120 mAdapter.registerAdapterDataObserver(mAdapterObserver); 121 } 122 123 @Override 124 public void addObserver(SelectionObserver callback) { 125 checkArgument(callback != null); 126 mObservers.add(callback); 127 } 128 129 @Override 130 public boolean hasSelection() { 131 return !mSelection.isEmpty(); 132 } 133 134 @Override 135 public Selection getSelection() { 136 return mSelection; 137 } 138 139 @Override 140 public void copySelection(Selection dest) { 141 dest.copyFrom(mSelection); 142 } 143 144 @Override 145 public boolean isSelected(String id) { 146 return mSelection.contains(id); 147 } 148 149 @Override 150 public void restoreSelection(Selection other) { 151 setItemsSelectedQuietly(other.mSelection, true); 152 // NOTE: We intentionally don't restore provisional selection. It's provisional. 153 notifySelectionRestored(); 154 } 155 156 @Override 157 public boolean setItemsSelected(Iterable<String> ids, boolean selected) { 158 boolean changed = setItemsSelectedQuietly(ids, selected); 159 notifySelectionChanged(); 160 return changed; 161 } 162 163 private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) { 164 boolean changed = false; 165 for (String id: ids) { 166 boolean itemChanged = selected 167 ? canSetState(id, true) && mSelection.add(id) 168 : canSetState(id, false) && mSelection.remove(id); 169 if (itemChanged) { 170 notifyItemStateChanged(id, selected); 171 } 172 changed |= itemChanged; 173 } 174 return changed; 175 } 176 177 @Override 178 public void clearSelection() { 179 if (!hasSelection()) { 180 return; 181 } 182 183 Selection prev = clearSelectionQuietly(); 184 notifySelectionCleared(prev); 185 notifySelectionChanged(); 186 } 187 188 /** 189 * Clears the selection, without notifying selection listeners. 190 * Returns items in previous selection. Callers are responsible for notifying 191 * listeners about changes. 192 */ 193 private Selection clearSelectionQuietly() { 194 mRange = null; 195 196 Selection prevSelection = new Selection(); 197 if (hasSelection()) { 198 copySelection(prevSelection); 199 mSelection.clear(); 200 } 201 202 return prevSelection; 203 } 204 205 @Override 206 public boolean select(String id) { 207 checkArgument(id != null); 208 209 if (!mSelection.contains(id)) { 210 if (!canSetState(id, true)) { 211 if (DEBUG) Log.d(TAG, "Select cancelled by selection predicate test."); 212 return false; 213 } 214 215 // Enforce single selection policy. 216 if (mSingleSelect && hasSelection()) { 217 Selection prev = clearSelectionQuietly(); 218 notifySelectionCleared(prev); 219 } 220 221 mSelection.add(id); 222 notifyItemStateChanged(id, true); 223 notifySelectionChanged(); 224 225 return true; 226 } 227 228 return false; 229 } 230 231 @Override 232 public boolean deselect(String id) { 233 checkArgument(id != null); 234 235 if (mSelection.contains(id)) { 236 if (!canSetState(id, false)) { 237 if (DEBUG) Log.d(TAG, "Deselect cancelled by selection predicate test."); 238 return false; 239 } 240 mSelection.remove(id); 241 notifyItemStateChanged(id, false); 242 notifySelectionChanged(); 243 if (mSelection.isEmpty() && isRangeActive()) { 244 // if there's nothing in the selection and there is an active ranger it results 245 // in unexpected behavior when the user tries to start range selection: the item 246 // which the ranger 'thinks' is the already selected anchor becomes unselectable 247 endRange(); 248 } 249 return true; 250 } 251 252 return false; 253 } 254 255 @Override 256 public void startRange(int pos) { 257 select(mStableIds.getStableId(pos)); 258 anchorRange(pos); 259 } 260 261 @Override 262 public void extendRange(int pos) { 263 extendRange(pos, RANGE_REGULAR); 264 } 265 266 @Override 267 public void endRange() { 268 mRange = null; 269 // Clean up in case there was any leftover provisional selection 270 clearProvisionalSelection(); 271 } 272 273 @Override 274 public void anchorRange(int position) { 275 checkArgument(position != RecyclerView.NO_POSITION); 276 277 // TODO: I'm not a fan of silently ignoring calls. 278 // Determine if there are any cases where method can be called 279 // w/o item already being selected. Else, tighten up the ship 280 // and make this conditional guard into a proper precondition check. 281 if (mSelection.contains(mStableIds.getStableId(position))) { 282 mRange = new Range(mRangeCallbacks, position); 283 } 284 } 285 286 @Override 287 public void extendProvisionalRange(int pos) { 288 extendRange(pos, RANGE_PROVISIONAL); 289 } 290 291 /** 292 * Sets the end point for the current range selection, started by a call to 293 * {@link #startRange(int)}. This function should only be called when a range selection 294 * is active (see {@link #isRangeActive()}. Items in the range [anchor, end] will be 295 * selected or in provisional select, depending on the type supplied. Note that if the type is 296 * provisional selection, one should do {@link #mergeProvisionalSelection()} at some 297 * point before calling on {@link #endRange()}. 298 * 299 * @param pos The new end position for the selection range. 300 * @param type The type of selection the range should utilize. 301 */ 302 private void extendRange(int pos, @RangeType int type) { 303 checkState(isRangeActive(), "Range start point not set."); 304 305 mRange.extendSelection(pos, type); 306 307 // We're being lazy here notifying even when something might not have changed. 308 // To make this more correct, we'd need to update the Ranger class to return 309 // information about what has changed. 310 notifySelectionChanged(); 311 } 312 313 @Override 314 public void setProvisionalSelection(Set<String> newSelection) { 315 Map<String, Boolean> delta = mSelection.setProvisionalSelection(newSelection); 316 for (Map.Entry<String, Boolean> entry: delta.entrySet()) { 317 notifyItemStateChanged(entry.getKey(), entry.getValue()); 318 } 319 320 notifySelectionChanged(); 321 } 322 323 @Override 324 public void mergeProvisionalSelection() { 325 mSelection.mergeProvisionalSelection(); 326 } 327 328 @Override 329 public void clearProvisionalSelection() { 330 for (String id : mSelection.mProvisionalSelection) { 331 notifyItemStateChanged(id, false); 332 } 333 mSelection.clearProvisionalSelection(); 334 } 335 336 @Override 337 public boolean isRangeActive() { 338 return mRange != null; 339 } 340 341 private boolean canSetState(String id, boolean nextState) { 342 return mSelectionPredicate.canSetStateForId(id, nextState); 343 } 344 345 private void onDataSetChanged() { 346 // Update the selection to remove any disappeared IDs. 347 mSelection.clearProvisionalSelection(); 348 mSelection.intersect(mStableIds.getStableIds()); 349 notifySelectionReset(); 350 351 for (String id : mSelection) { 352 // If the underlying data set has changed, before restoring 353 // selection we must re-verify that it can be selected. 354 // Why? Because if the dataset has changed, then maybe the 355 // selectability of an item has changed. 356 if (!canSetState(id, true)) { 357 deselect(id); 358 } else { 359 int lastListener = mObservers.size() - 1; 360 for (int i = lastListener; i >= 0; i--) { 361 mObservers.get(i).onItemStateChanged(id, true); 362 } 363 } 364 } 365 notifySelectionChanged(); 366 } 367 368 private void onDataSetItemRangeInserted(int startPosition, int itemCount) { 369 mSelection.clearProvisionalSelection(); 370 } 371 372 private void onDataSetItemRangeRemoved(int startPosition, int itemCount) { 373 checkArgument(startPosition >= 0); 374 checkArgument(itemCount > 0); 375 376 mSelection.clearProvisionalSelection(); 377 378 // Remove any disappeared IDs from the selection. 379 // 380 // Ideally there could be a cheaper approach, checking 381 // each position individually, but since the source of 382 // truth for stable ids (StableIdProvider) probably 383 // it-self no-longer knows about the positions in question 384 // we fall back to the sledge hammer approach. 385 mSelection.intersect(mStableIds.getStableIds()); 386 } 387 388 /** 389 * Notifies registered listeners when the selection status of a single item 390 * (identified by {@code position}) changes. 391 */ 392 private void notifyItemStateChanged(String id, boolean selected) { 393 checkArgument(id != null); 394 395 int lastListenerIndex = mObservers.size() - 1; 396 for (int i = lastListenerIndex; i >= 0; i--) { 397 mObservers.get(i).onItemStateChanged(id, selected); 398 } 399 400 int position = mStableIds.getPosition(id); 401 if (DEBUG) Log.d(TAG, "ITEM " + id + " CHANGED at pos: " + position); 402 403 if (position >= 0) { 404 mAdapter.notifyItemChanged(position, SelectionHelper.SELECTION_CHANGED_MARKER); 405 } else { 406 Log.w(TAG, "Item change notification received for unknown item: " + id); 407 } 408 } 409 410 private void notifySelectionCleared(Selection selection) { 411 for (String id: selection.mSelection) { 412 notifyItemStateChanged(id, false); 413 } 414 for (String id: selection.mProvisionalSelection) { 415 notifyItemStateChanged(id, false); 416 } 417 } 418 419 /** 420 * Notifies registered listeners when the selection has changed. This 421 * notification should be sent only once a full series of changes 422 * is complete, e.g. clearingSelection, or updating the single 423 * selection from one item to another. 424 */ 425 private void notifySelectionChanged() { 426 int lastListenerIndex = mObservers.size() - 1; 427 for (int i = lastListenerIndex; i >= 0; i--) { 428 mObservers.get(i).onSelectionChanged(); 429 } 430 } 431 432 private void notifySelectionRestored() { 433 int lastListenerIndex = mObservers.size() - 1; 434 for (int i = lastListenerIndex; i >= 0; i--) { 435 mObservers.get(i).onSelectionRestored(); 436 } 437 } 438 439 private void notifySelectionReset() { 440 int lastListenerIndex = mObservers.size() - 1; 441 for (int i = lastListenerIndex; i >= 0; i--) { 442 mObservers.get(i).onSelectionReset(); 443 } 444 } 445 446 private void updateForRange(int begin, int end, boolean selected, @RangeType int type) { 447 switch (type) { 448 case RANGE_REGULAR: 449 updateForRegularRange(begin, end, selected); 450 break; 451 case RANGE_PROVISIONAL: 452 updateForProvisionalRange(begin, end, selected); 453 break; 454 default: 455 throw new IllegalArgumentException("Invalid range type: " + type); 456 } 457 } 458 459 private void updateForRegularRange(int begin, int end, boolean selected) { 460 checkArgument(end >= begin); 461 462 for (int i = begin; i <= end; i++) { 463 String id = mStableIds.getStableId(i); 464 if (id == null) { 465 continue; 466 } 467 468 if (selected) { 469 select(id); 470 } else { 471 deselect(id); 472 } 473 } 474 } 475 476 private void updateForProvisionalRange(int begin, int end, boolean selected) { 477 checkArgument(end >= begin); 478 479 for (int i = begin; i <= end; i++) { 480 String id = mStableIds.getStableId(i); 481 if (id == null) { 482 continue; 483 } 484 485 boolean changedState = false; 486 if (selected) { 487 boolean canSelect = canSetState(id, true); 488 if (canSelect && !mSelection.mSelection.contains(id)) { 489 mSelection.mProvisionalSelection.add(id); 490 changedState = true; 491 } 492 } else { 493 mSelection.mProvisionalSelection.remove(id); 494 changedState = true; 495 } 496 497 // Only notify item callbacks when something's state is actually changed in provisional 498 // selection. 499 if (changedState) { 500 notifyItemStateChanged(id, selected); 501 } 502 } 503 504 notifySelectionChanged(); 505 } 506 507 private final class AdapterObserver extends RecyclerView.AdapterDataObserver { 508 @Override 509 public void onChanged() { 510 onDataSetChanged(); 511 } 512 513 @Override 514 public void onItemRangeChanged( 515 int startPosition, int itemCount, Object payload) { 516 // No change in position. Ignore, since we assume 517 // selection is a user driven activity. So changes 518 // in properties of items shouldn't result in a 519 // change of selection. 520 } 521 522 @Override 523 public void onItemRangeInserted(int startPosition, int itemCount) { 524 onDataSetItemRangeInserted(startPosition, itemCount); 525 } 526 527 @Override 528 public void onItemRangeRemoved(int startPosition, int itemCount) { 529 onDataSetItemRangeRemoved(startPosition, itemCount); 530 } 531 532 @Override 533 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 534 throw new UnsupportedOperationException(); 535 } 536 } 537 538 private final class RangeCallbacks extends Range.Callbacks { 539 @Override 540 void updateForRange(int begin, int end, boolean selected, int type) { 541 switch (type) { 542 case RANGE_REGULAR: 543 updateForRegularRange(begin, end, selected); 544 break; 545 case RANGE_PROVISIONAL: 546 updateForProvisionalRange(begin, end, selected); 547 break; 548 default: 549 throw new IllegalArgumentException( 550 "Invalid range type: " + type); 551 } 552 } 553 } 554 } 555