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