• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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      */
DefaultSelectionHelper( @electionMode int mode, RecyclerView.Adapter<?> adapter, StableIdProvider stableIds, SelectionPredicate selectionPredicate)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
addObserver(SelectionObserver callback)124     public void addObserver(SelectionObserver callback) {
125         checkArgument(callback != null);
126         mObservers.add(callback);
127     }
128 
129     @Override
hasSelection()130     public boolean hasSelection() {
131         return !mSelection.isEmpty();
132     }
133 
134     @Override
getSelection()135     public Selection getSelection() {
136         return mSelection;
137     }
138 
139     @Override
copySelection(Selection dest)140     public void copySelection(Selection dest) {
141         dest.copyFrom(mSelection);
142     }
143 
144     @Override
isSelected(String id)145     public boolean isSelected(String id) {
146         return mSelection.contains(id);
147     }
148 
149     @Override
restoreSelection(Selection other)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
setItemsSelected(Iterable<String> ids, boolean selected)157     public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
158         boolean changed = setItemsSelectedQuietly(ids, selected);
159         notifySelectionChanged();
160         return changed;
161     }
162 
setItemsSelectedQuietly(Iterable<String> ids, boolean selected)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
clearSelection()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      */
clearSelectionQuietly()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
select(String id)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
deselect(String id)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
startRange(int pos)256     public void startRange(int pos) {
257         select(mStableIds.getStableId(pos));
258         anchorRange(pos);
259     }
260 
261     @Override
extendRange(int pos)262     public void extendRange(int pos) {
263         extendRange(pos, RANGE_REGULAR);
264     }
265 
266     @Override
endRange()267     public void endRange() {
268         mRange = null;
269         // Clean up in case there was any leftover provisional selection
270         clearProvisionalSelection();
271     }
272 
273     @Override
anchorRange(int position)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
extendProvisionalRange(int pos)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      */
extendRange(int pos, @RangeType int type)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
setProvisionalSelection(Set<String> newSelection)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
mergeProvisionalSelection()324     public void mergeProvisionalSelection() {
325         mSelection.mergeProvisionalSelection();
326     }
327 
328     @Override
clearProvisionalSelection()329     public void clearProvisionalSelection() {
330         for (String id : mSelection.mProvisionalSelection) {
331             notifyItemStateChanged(id, false);
332         }
333         mSelection.clearProvisionalSelection();
334     }
335 
336     @Override
isRangeActive()337     public boolean isRangeActive() {
338         return mRange != null;
339     }
340 
canSetState(String id, boolean nextState)341     private boolean canSetState(String id, boolean nextState) {
342         return mSelectionPredicate.canSetStateForId(id, nextState);
343     }
344 
onDataSetChanged()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 
onDataSetItemRangeInserted(int startPosition, int itemCount)368     private void onDataSetItemRangeInserted(int startPosition, int itemCount) {
369         mSelection.clearProvisionalSelection();
370     }
371 
onDataSetItemRangeRemoved(int startPosition, int itemCount)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      */
notifyItemStateChanged(String id, boolean selected)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 
notifySelectionCleared(Selection selection)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      */
notifySelectionChanged()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 
notifySelectionRestored()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 
notifySelectionReset()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 
updateForRange(int begin, int end, boolean selected, @RangeType int type)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 
updateForRegularRange(int begin, int end, boolean selected)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 
updateForProvisionalRange(int begin, int end, boolean selected)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
onChanged()509         public void onChanged() {
510             onDataSetChanged();
511         }
512 
513         @Override
onItemRangeChanged( int startPosition, int itemCount, Object payload)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
onItemRangeInserted(int startPosition, int itemCount)523         public void onItemRangeInserted(int startPosition, int itemCount) {
524             onDataSetItemRangeInserted(startPosition, itemCount);
525         }
526 
527         @Override
onItemRangeRemoved(int startPosition, int itemCount)528         public void onItemRangeRemoved(int startPosition, int itemCount) {
529             onDataSetItemRangeRemoved(startPosition, itemCount);
530         }
531 
532         @Override
onItemRangeMoved(int fromPosition, int toPosition, int itemCount)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
updateForRange(int begin, int end, boolean selected, int type)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