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