1 /* 2 * Copyright 2017 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 androidx.recyclerview.selection; 18 19 import static androidx.annotation.RestrictTo.Scope.LIBRARY; 20 import static androidx.core.util.Preconditions.checkArgument; 21 import static androidx.core.util.Preconditions.checkState; 22 import static androidx.recyclerview.selection.Shared.DEBUG; 23 24 import android.os.Bundle; 25 import android.util.Log; 26 27 import androidx.annotation.RestrictTo; 28 import androidx.annotation.VisibleForTesting; 29 import androidx.recyclerview.selection.Range.RangeType; 30 import androidx.recyclerview.widget.RecyclerView; 31 import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; 32 33 import org.jspecify.annotations.NonNull; 34 import org.jspecify.annotations.Nullable; 35 36 import java.util.ArrayList; 37 import java.util.List; 38 import java.util.Map; 39 import java.util.Set; 40 41 /** 42 * {@link SelectionTracker} providing support for traditional multi-item selection on top 43 * of {@link RecyclerView}. 44 * 45 * <p> 46 * The class supports running in a single-select mode, which can be enabled using 47 * {@link SelectionPredicate#canSelectMultiple()}. 48 * 49 * @param <K> Selection key type. @see {@link StorageStrategy} for supported types. 50 */ 51 @RestrictTo(LIBRARY) 52 @SuppressWarnings("unchecked") 53 public class DefaultSelectionTracker<K> extends SelectionTracker<K> implements Resettable { 54 55 private static final String TAG = "DefaultSelectionTracker"; 56 private static final String EXTRA_SELECTION_PREFIX = "androidx.recyclerview.selection"; 57 58 private final Selection<K> mSelection = new Selection<>(); 59 private final List<SelectionObserver<K>> mObservers = new ArrayList<>(1); 60 private final ItemKeyProvider<K> mKeyProvider; 61 private final SelectionPredicate<K> mSelectionPredicate; 62 private final StorageStrategy<K> mStorage; 63 private final RangeCallbacks mRangeCallbacks; 64 private final AdapterObserver mAdapterObserver; 65 private final boolean mSingleSelect; 66 private final String mSelectionId; 67 68 private @Nullable Range mRange; 69 70 /** 71 * Creates a new instance. 72 * 73 * @param selectionId A unique string identifying this selection in the context 74 * of the activity or fragment. 75 * @param keyProvider client supplied class providing access to stable ids. 76 * @param selectionPredicate A predicate allowing the client to disallow selection 77 * @param storage Strategy for storing typed selection in bundle. 78 */ DefaultSelectionTracker( @onNull String selectionId, @NonNull ItemKeyProvider<K> keyProvider, @NonNull SelectionPredicate<K> selectionPredicate, @NonNull StorageStrategy<K> storage)79 public DefaultSelectionTracker( 80 @NonNull String selectionId, 81 @NonNull ItemKeyProvider<K> keyProvider, 82 @NonNull SelectionPredicate<K> selectionPredicate, 83 @NonNull StorageStrategy<K> storage) { 84 85 checkArgument(selectionId != null); 86 checkArgument(!selectionId.trim().isEmpty()); 87 checkArgument(keyProvider != null); 88 checkArgument(selectionPredicate != null); 89 checkArgument(storage != null); 90 91 mSelectionId = selectionId; 92 mKeyProvider = keyProvider; 93 mSelectionPredicate = selectionPredicate; 94 mStorage = storage; 95 96 mRangeCallbacks = new RangeCallbacks(); 97 98 mSingleSelect = !selectionPredicate.canSelectMultiple(); 99 100 mAdapterObserver = new AdapterObserver(this); 101 } 102 103 @Override addObserver(@onNull SelectionObserver<K> callback)104 public void addObserver(@NonNull SelectionObserver<K> callback) { 105 checkArgument(callback != null); 106 mObservers.add(callback); 107 } 108 109 /** 110 * @return true if there is a primary or previsional selection. 111 */ 112 @Override hasSelection()113 public boolean hasSelection() { 114 return !mSelection.isEmpty(); 115 } 116 117 @Override getSelection()118 public @NonNull Selection<K> getSelection() { 119 return mSelection; 120 } 121 122 @Override copySelection(@onNull MutableSelection<K> dest)123 public void copySelection(@NonNull MutableSelection<K> dest) { 124 dest.copyFrom(mSelection); 125 } 126 127 @Override isSelected(@ullable K key)128 public boolean isSelected(@Nullable K key) { 129 return mSelection.contains(key); 130 } 131 132 @Override restoreSelection(@onNull Selection<K> other)133 protected void restoreSelection(@NonNull Selection<K> other) { 134 checkArgument(other != null); 135 setItemsSelectedQuietly(other.mSelection, true); 136 // NOTE: We intentionally don't restore provisional selection. It's provisional. 137 notifySelectionRestored(); 138 } 139 140 @Override setItemsSelected(@onNull Iterable<K> keys, boolean selected)141 public boolean setItemsSelected(@NonNull Iterable<K> keys, boolean selected) { 142 boolean changed = setItemsSelectedQuietly(keys, selected); 143 notifySelectionChanged(); 144 return changed; 145 } 146 setItemsSelectedQuietly(@onNull Iterable<K> keys, boolean selected)147 private boolean setItemsSelectedQuietly(@NonNull Iterable<K> keys, boolean selected) { 148 boolean changed = false; 149 for (K key : keys) { 150 boolean itemChanged = selected 151 ? canSetState(key, true) && mSelection.add(key) 152 : canSetState(key, false) && mSelection.remove(key); 153 if (itemChanged) { 154 notifyItemStateChanged(key, selected); 155 } 156 changed |= itemChanged; 157 } 158 return changed; 159 } 160 161 @Override clearSelection()162 public boolean clearSelection() { 163 if (!hasSelection()) { 164 if (DEBUG) Log.d(TAG, "Ignoring clearSelection request. No selection."); 165 return false; 166 } 167 if (DEBUG) Log.d(TAG, "Handling clearSelection request."); 168 169 clearProvisionalSelection(); 170 clearPrimarySelection(); 171 notifySelectionCleared(); 172 173 return true; 174 } 175 clearPrimarySelection()176 private void clearPrimarySelection() { 177 if (!hasSelection()) { 178 return; 179 } 180 181 Selection<K> prev = clearSelectionQuietly(); 182 notifySelectionCleared(prev); 183 notifySelectionChanged(); 184 } 185 186 /** 187 * Clears the selection, without notifying selection listeners. 188 * Returns items in previous selection. Callers are responsible for notifying 189 * listeners about changes. 190 */ clearSelectionQuietly()191 private Selection<K> clearSelectionQuietly() { 192 mRange = null; 193 194 MutableSelection<K> prevSelection = new MutableSelection<>(); 195 if (hasSelection()) { 196 copySelection(prevSelection); 197 mSelection.clear(); 198 } 199 200 return prevSelection; 201 } 202 203 @Override reset()204 public void reset() { 205 if (DEBUG) Log.d(TAG, "Received reset request."); 206 clearSelection(); 207 mRange = null; 208 } 209 210 @Override isResetRequired()211 public boolean isResetRequired() { 212 return hasSelection() || isRangeActive(); 213 } 214 215 @Override select(@onNull K key)216 public boolean select(@NonNull K key) { 217 checkArgument(key != null); 218 219 if (mSelection.contains(key)) { 220 return false; 221 } 222 223 if (!canSetState(key, true)) { 224 if (DEBUG) Log.d(TAG, "Select cancelled by selection predicate test."); 225 return false; 226 } 227 228 // Enforce single selection policy. 229 if (mSingleSelect && hasSelection()) { 230 Selection<K> prev = clearSelectionQuietly(); 231 notifySelectionCleared(prev); 232 } 233 234 mSelection.add(key); 235 notifyItemStateChanged(key, true); 236 notifySelectionChanged(); 237 238 return true; 239 } 240 241 @Override deselect(@onNull K key)242 public boolean deselect(@NonNull K key) { 243 checkArgument(key != null); 244 245 if (mSelection.contains(key)) { 246 if (!canSetState(key, false)) { 247 if (DEBUG) Log.d(TAG, "Deselect cancelled by selection predicate test."); 248 return false; 249 } 250 mSelection.remove(key); 251 notifyItemStateChanged(key, false); 252 notifySelectionChanged(); 253 if (mSelection.isEmpty() && isRangeActive()) { 254 // if there's nothing in the selection and there is an active ranger it results 255 // in unexpected behavior when the user tries to start range selection: the item 256 // which the ranger 'thinks' is the already selected anchor becomes unselectable 257 endRange(); 258 } 259 return true; 260 } 261 262 return false; 263 } 264 265 @Override startRange(int position)266 public void startRange(int position) { 267 if (mSelection.contains(mKeyProvider.getKey(position)) 268 || select(mKeyProvider.getKey(position))) { 269 anchorRange(position); 270 } 271 } 272 273 @Override extendRange(int position)274 public void extendRange(int position) { 275 extendRange(position, Range.TYPE_PRIMARY); 276 } 277 278 @Override endRange()279 public void endRange() { 280 mRange = null; 281 // Clean up in case there was any leftover provisional selection 282 clearProvisionalSelection(); 283 } 284 285 @Override anchorRange(int position)286 public void anchorRange(int position) { 287 checkArgument(position != RecyclerView.NO_POSITION); 288 checkArgument(mSelection.contains(mKeyProvider.getKey(position))); 289 290 mRange = new Range(position, mRangeCallbacks); 291 } 292 293 @Override extendProvisionalRange(int position)294 public void extendProvisionalRange(int position) { 295 if (mSingleSelect) { 296 return; 297 } 298 299 if (DEBUG) { 300 Log.i(TAG, "Extending provision range to position: " + position); 301 checkState(isRangeActive(), "Range start point not set."); 302 } 303 extendRange(position, Range.TYPE_PROVISIONAL); 304 } 305 306 /** 307 * Sets the end point for the current range selection, started by a call to 308 * {@link #startRange(int)}. This function should only be called when a range selection 309 * is active (see {@link #isRangeActive()}. Items in the range [anchor, end] will be 310 * selected or in provisional select, depending on the type supplied. Note that if the type is 311 * provisional selection, one should do {@link #mergeProvisionalSelection()} at some 312 * point before calling on {@link #endRange()}. 313 * 314 * @param position The new end position for the selection range. 315 * @param type The type of selection the range should utilize. 316 */ extendRange(int position, @RangeType int type)317 private void extendRange(int position, @RangeType int type) { 318 if (!isRangeActive()) { 319 Log.e(TAG, "Ignoring attempt to extend unestablished range. Ignoring."); 320 if (DEBUG) { 321 throw new IllegalStateException("Attempted to extend unestablished range."); 322 } 323 return; 324 } 325 326 if (position == RecyclerView.NO_POSITION) { 327 Log.w(TAG, "Ignoring attempt to extend range to invalid position: " + position); 328 if (DEBUG) { 329 throw new IllegalStateException( 330 "Attempting to extend range to invalid position: " + position); 331 } 332 return; 333 } 334 335 mRange.extendRange(position, type); 336 337 // We're being lazy here notifying even when something might not have changed. 338 // To make this more correct, we'd need to update the Ranger class to return 339 // information about what has changed. 340 notifySelectionChanged(); 341 } 342 343 @Override setProvisionalSelection(@onNull Set<K> newSelection)344 public void setProvisionalSelection(@NonNull Set<K> newSelection) { 345 if (mSingleSelect) { 346 return; 347 } 348 349 Map<K, Boolean> delta = mSelection.setProvisionalSelection(newSelection); 350 for (Map.Entry<K, Boolean> entry : delta.entrySet()) { 351 notifyItemStateChanged(entry.getKey(), entry.getValue()); 352 } 353 354 notifySelectionChanged(); 355 } 356 357 @Override mergeProvisionalSelection()358 public void mergeProvisionalSelection() { 359 mSelection.mergeProvisionalSelection(); 360 361 // Note, that for almost all functional purposes, merging a provisional selection 362 // into a the primary selection doesn't change the selection, just an internal 363 // representation of it. But there are some nuanced areas cases where 364 // that isn't true. equality for 1. So, we notify regardless. 365 366 notifySelectionChanged(); 367 } 368 369 @Override clearProvisionalSelection()370 public void clearProvisionalSelection() { 371 for (K key : mSelection.mProvisionalSelection) { 372 notifyItemStateChanged(key, false); 373 } 374 mSelection.clearProvisionalSelection(); 375 } 376 377 @Override isRangeActive()378 public boolean isRangeActive() { 379 return mRange != null; 380 } 381 canSetState(@onNull K key, boolean nextState)382 private boolean canSetState(@NonNull K key, boolean nextState) { 383 return mSelectionPredicate.canSetStateForKey(key, nextState); 384 } 385 386 @Override getAdapterDataObserver()387 protected @NonNull AdapterDataObserver getAdapterDataObserver() { 388 return mAdapterObserver; 389 } 390 391 @SuppressWarnings({"WeakerAccess", "unchecked"}) /* synthetic access */ onDataSetChanged()392 void onDataSetChanged() { 393 if (mSelection.isEmpty()) { 394 Log.d(TAG, "Ignoring onDataSetChange. No active selection."); 395 return; 396 } 397 398 mSelection.clearProvisionalSelection(); 399 400 notifySelectionRefresh(); 401 402 List<K> toRemove = null; 403 for (K key : mSelection) { 404 // If the underlying data set has changed, before restoring 405 // selection we must re-verify that the items are present 406 // and if so, can still be selected. 407 // Why? Because if the dataset has changed, then maybe the 408 // selectability of an item has changed, or item disappeared. 409 if (mKeyProvider.getPosition(key) == RecyclerView.NO_POSITION 410 || !canSetState(key, true)) { 411 if (toRemove == null) { 412 toRemove = new ArrayList<>(); 413 } 414 toRemove.add(key); 415 } else { 416 int lastListener = mObservers.size() - 1; 417 for (int i = lastListener; i >= 0; i--) { 418 mObservers.get(i).onItemStateChanged(key, true); 419 } 420 } 421 422 } 423 424 if (toRemove != null) { 425 for (K key : toRemove) { 426 // TODO(b/163840879): Calling deselect fires onSelectionChanged 427 // once per call. Meaning we're firing it n+1 times when deselecting. 428 deselect(key); 429 } 430 } 431 432 // TODO: Send onSelectionCleared if empty in 2.0 release. 433 notifySelectionChanged(); 434 } 435 436 /** 437 * Notifies registered listeners when the selection status of a single item 438 * (identified by {@code position}) changes. 439 */ notifyItemStateChanged(@onNull K key, boolean selected)440 private void notifyItemStateChanged(@NonNull K key, boolean selected) { 441 checkArgument(key != null); 442 443 int lastListenerIndex = mObservers.size() - 1; 444 for (int i = lastListenerIndex; i >= 0; i--) { 445 mObservers.get(i).onItemStateChanged(key, selected); 446 } 447 } 448 notifySelectionCleared()449 private void notifySelectionCleared() { 450 for (SelectionObserver<K> observer : mObservers) { 451 observer.onSelectionCleared(); 452 } 453 } 454 notifySelectionCleared(@onNull Selection<K> selection)455 private void notifySelectionCleared(@NonNull Selection<K> selection) { 456 for (K key : selection.mSelection) { 457 notifyItemStateChanged(key, false); 458 } 459 for (K key : selection.mProvisionalSelection) { 460 notifyItemStateChanged(key, false); 461 } 462 } 463 464 /** 465 * Notifies registered listeners when the selection has changed. This 466 * notification should be sent only once a full series of changes 467 * is complete, e.g. clearingSelection, or updating the single 468 * selection from one item to another. 469 */ notifySelectionChanged()470 private void notifySelectionChanged() { 471 int lastListenerIndex = mObservers.size() - 1; 472 for (int i = lastListenerIndex; i >= 0; i--) { 473 mObservers.get(i).onSelectionChanged(); 474 } 475 } 476 notifySelectionRestored()477 private void notifySelectionRestored() { 478 int lastListenerIndex = mObservers.size() - 1; 479 for (int i = lastListenerIndex; i >= 0; i--) { 480 mObservers.get(i).onSelectionRestored(); 481 } 482 } 483 notifySelectionRefresh()484 private void notifySelectionRefresh() { 485 int lastListenerIndex = mObservers.size() - 1; 486 for (int i = lastListenerIndex; i >= 0; i--) { 487 mObservers.get(i).onSelectionRefresh(); 488 } 489 } 490 491 @SuppressWarnings("WeakerAccess") /* synthetic access */ updateForRegularRange(int begin, int end, boolean selected)492 void updateForRegularRange(int begin, int end, boolean selected) { 493 checkArgument(end >= begin); 494 495 for (int i = begin; i <= end; i++) { 496 K key = mKeyProvider.getKey(i); 497 if (key == null) { 498 continue; 499 } 500 501 if (selected) { 502 select(key); 503 } else { 504 deselect(key); 505 } 506 } 507 } 508 509 @SuppressWarnings("WeakerAccess") /* synthetic access */ updateForProvisionalRange(int begin, int end, boolean selected)510 void updateForProvisionalRange(int begin, int end, boolean selected) { 511 checkArgument(end >= begin); 512 513 for (int i = begin; i <= end; i++) { 514 K key = mKeyProvider.getKey(i); 515 if (key == null) { 516 continue; 517 } 518 519 boolean changedState = false; 520 if (selected) { 521 boolean canSelect = canSetState(key, true); 522 if (canSelect && !mSelection.mSelection.contains(key)) { 523 mSelection.mProvisionalSelection.add(key); 524 changedState = true; 525 } 526 } else { 527 mSelection.mProvisionalSelection.remove(key); 528 changedState = true; 529 } 530 531 // Only notify item callbacks when something's state is actually changed in provisional 532 // selection. 533 if (changedState) { 534 notifyItemStateChanged(key, selected); 535 } 536 } 537 538 notifySelectionChanged(); 539 } 540 541 @VisibleForTesting getInstanceStateKey()542 String getInstanceStateKey() { 543 return EXTRA_SELECTION_PREFIX + ":" + mSelectionId; 544 } 545 546 @Override onSaveInstanceState(@onNull Bundle state)547 public final void onSaveInstanceState(@NonNull Bundle state) { 548 if (mSelection.isEmpty()) { 549 return; 550 } 551 552 state.putBundle(getInstanceStateKey(), mStorage.asBundle(mSelection)); 553 } 554 555 @Override onRestoreInstanceState(@ullable Bundle state)556 public final void onRestoreInstanceState(@Nullable Bundle state) { 557 if (state == null) { 558 return; 559 } 560 561 Bundle selectionState = state.getBundle(getInstanceStateKey()); 562 if (selectionState == null) { 563 return; 564 } 565 566 Selection<K> selection = mStorage.asSelection(selectionState); 567 if (selection != null && !selection.isEmpty()) { 568 restoreSelection(selection); 569 } 570 } 571 572 private final class RangeCallbacks extends Range.Callbacks { RangeCallbacks()573 RangeCallbacks() { 574 } 575 576 @Override updateForRange(int begin, int end, boolean selected, int type)577 void updateForRange(int begin, int end, boolean selected, int type) { 578 switch (type) { 579 case Range.TYPE_PRIMARY: 580 updateForRegularRange(begin, end, selected); 581 break; 582 case Range.TYPE_PROVISIONAL: 583 updateForProvisionalRange(begin, end, selected); 584 break; 585 default: 586 throw new IllegalArgumentException("Invalid range type: " + type); 587 } 588 } 589 } 590 591 private static final class AdapterObserver extends AdapterDataObserver { 592 593 private final DefaultSelectionTracker<?> mSelectionTracker; 594 AdapterObserver(@onNull DefaultSelectionTracker<?> selectionTracker)595 AdapterObserver(@NonNull DefaultSelectionTracker<?> selectionTracker) { 596 checkArgument(selectionTracker != null); 597 mSelectionTracker = selectionTracker; 598 } 599 600 @Override onChanged()601 public void onChanged() { 602 mSelectionTracker.onDataSetChanged(); 603 } 604 605 @Override onItemRangeChanged(int startPosition, int itemCount, @Nullable Object payload)606 public void onItemRangeChanged(int startPosition, int itemCount, @Nullable Object payload) { 607 if (!SelectionTracker.SELECTION_CHANGED_MARKER.equals(payload)) { 608 mSelectionTracker.onDataSetChanged(); 609 } 610 } 611 612 @Override onItemRangeInserted(int startPosition, int itemCount)613 public void onItemRangeInserted(int startPosition, int itemCount) { 614 mSelectionTracker.endRange(); 615 } 616 617 @Override onItemRangeRemoved(int startPosition, int itemCount)618 public void onItemRangeRemoved(int startPosition, int itemCount) { 619 mSelectionTracker.endRange(); 620 // Since SelectionTracker deals in keys, not positions, we turn 621 // to the `onDataSetChanged` sledge hammer. 622 // DefaultSelectionTracker will validate and update it's selection. 623 mSelectionTracker.onDataSetChanged(); 624 } 625 626 @Override onItemRangeMoved(int fromPosition, int toPosition, int itemCount)627 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { 628 mSelectionTracker.endRange(); 629 } 630 } 631 } 632