Home | History | Annotate | Download | only in selection
      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.annotation.VisibleForTesting;
     23 import android.support.v7.widget.RecyclerView;
     24 import android.util.Log;
     25 
     26 import com.android.documentsui.dirlist.DocumentsAdapter;
     27 
     28 import java.lang.annotation.Retention;
     29 import java.lang.annotation.RetentionPolicy;
     30 import java.util.ArrayList;
     31 import java.util.List;
     32 
     33 import javax.annotation.Nullable;
     34 
     35 /**
     36  * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
     37  * Additionally it can be configured to restrict selection to a single element, @see
     38  * #setSelectMode.
     39  */
     40 public final class SelectionManager {
     41 
     42     @IntDef(flag = true, value = {
     43             MODE_MULTIPLE,
     44             MODE_SINGLE
     45     })
     46     @Retention(RetentionPolicy.SOURCE)
     47     public @interface SelectionMode {}
     48     public static final int MODE_MULTIPLE = 0;
     49     public static final int MODE_SINGLE = 1;
     50 
     51     @IntDef({
     52             RANGE_REGULAR,
     53             RANGE_PROVISIONAL
     54     })
     55     @Retention(RetentionPolicy.SOURCE)
     56     public @interface RangeType {}
     57     public static final int RANGE_REGULAR = 0;
     58     public static final int RANGE_PROVISIONAL = 1;
     59 
     60     static final String TAG = "SelectionManager";
     61 
     62     private final Selection mSelection = new Selection();
     63 
     64     private final List<Callback> mCallbacks = new ArrayList<>(1);
     65     private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1);
     66 
     67     private @Nullable DocumentsAdapter mAdapter;
     68     private @Nullable Range mRanger;
     69     private boolean mSingleSelect;
     70 
     71     private RecyclerView.AdapterDataObserver mAdapterObserver;
     72     private SelectionPredicate mCanSetState;
     73 
     74     public SelectionManager(@SelectionMode int mode) {
     75         mSingleSelect = mode == MODE_SINGLE;
     76     }
     77 
     78     public SelectionManager reset(DocumentsAdapter adapter, SelectionPredicate canSetState) {
     79 
     80         mCallbacks.clear();
     81         mItemCallbacks.clear();
     82         if (mAdapter != null && mAdapterObserver != null) {
     83             mAdapter.unregisterAdapterDataObserver(mAdapterObserver);
     84         }
     85 
     86         clearSelectionQuietly();
     87 
     88         assert(adapter != null);
     89         assert(canSetState != null);
     90 
     91         mAdapter = adapter;
     92         mCanSetState = canSetState;
     93 
     94         mAdapterObserver = new RecyclerView.AdapterDataObserver() {
     95 
     96             private List<String> mModelIds;
     97 
     98             @Override
     99             public void onChanged() {
    100                 mModelIds = mAdapter.getModelIds();
    101 
    102                 // Update the selection to remove any disappeared IDs.
    103                 mSelection.cancelProvisionalSelection();
    104                 mSelection.intersect(mModelIds);
    105 
    106                 notifyDataChanged();
    107             }
    108 
    109             @Override
    110             public void onItemRangeChanged(
    111                     int startPosition, int itemCount, Object payload) {
    112                 // No change in position. Ignoring.
    113             }
    114 
    115             @Override
    116             public void onItemRangeInserted(int startPosition, int itemCount) {
    117                 mSelection.cancelProvisionalSelection();
    118             }
    119 
    120             @Override
    121             public void onItemRangeRemoved(int startPosition, int itemCount) {
    122                 assert(startPosition >= 0);
    123                 assert(itemCount > 0);
    124 
    125                 mSelection.cancelProvisionalSelection();
    126                 // Remove any disappeared IDs from the selection.
    127                 mSelection.intersect(mModelIds);
    128             }
    129 
    130             @Override
    131             public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
    132                 throw new UnsupportedOperationException();
    133             }
    134         };
    135 
    136         mAdapter.registerAdapterDataObserver(mAdapterObserver);
    137         return this;
    138     }
    139 
    140     void bindContoller(BandController controller) {
    141         // Provides BandController with access to private mSelection state.
    142         controller.bindSelection(mSelection);
    143     }
    144 
    145     /**
    146      * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
    147      * events occur.
    148      *
    149      * @param callback
    150      */
    151     public void addCallback(Callback callback) {
    152         assert(callback != null);
    153         mCallbacks.add(callback);
    154     }
    155 
    156     public void addItemCallback(ItemCallback itemCallback) {
    157         assert(itemCallback != null);
    158         mItemCallbacks.add(itemCallback);
    159     }
    160 
    161     public boolean hasSelection() {
    162         return !mSelection.isEmpty();
    163     }
    164 
    165     /**
    166      * Returns a Selection object that provides a live view
    167      * on the current selection.
    168      *
    169      * @see #getSelection(Selection) on how to get a snapshot
    170      *     of the selection that will not reflect future changes
    171      *     to selection.
    172      *
    173      * @return The current selection.
    174      */
    175     public Selection getSelection() {
    176         return mSelection;
    177     }
    178 
    179     /**
    180      * Updates {@code dest} to reflect the current selection.
    181      * @param dest
    182      *
    183      * @return The Selection instance passed in, for convenience.
    184      */
    185     public Selection getSelection(Selection dest) {
    186         dest.copyFrom(mSelection);
    187         return dest;
    188     }
    189 
    190     @VisibleForTesting
    191     public void replaceSelection(Iterable<String> ids) {
    192         clearSelection();
    193         setItemsSelected(ids, true);
    194     }
    195 
    196     /**
    197      * Restores the selected state of specified items. Used in cases such as restore the selection
    198      * after rotation etc.
    199      */
    200     public void restoreSelection(Selection other) {
    201         setItemsSelectedQuietly(other.mSelection, true);
    202         // NOTE: We intentionally don't restore provisional selection. It's provisional.
    203         notifySelectionRestored();
    204     }
    205 
    206     /**
    207      * Sets the selected state of the specified items. Note that the callback will NOT
    208      * be consulted to see if an item can be selected.
    209      *
    210      * @param ids
    211      * @param selected
    212      * @return
    213      */
    214     public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
    215         final boolean changed = setItemsSelectedQuietly(ids, selected);
    216         notifySelectionChanged();
    217         return changed;
    218     }
    219 
    220     private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) {
    221         boolean changed = false;
    222         for (String id: ids) {
    223             final boolean itemChanged =
    224                     selected
    225                     ? canSetState(id, true) && mSelection.add(id)
    226                     : canSetState(id, false) && mSelection.remove(id);
    227             if (itemChanged) {
    228                 notifyItemStateChanged(id, selected);
    229             }
    230             changed |= itemChanged;
    231         }
    232         return changed;
    233     }
    234 
    235     /**
    236      * Clears the selection and notifies (if something changes).
    237      */
    238     public void clearSelection() {
    239         if (!hasSelection()) {
    240             return;
    241         }
    242 
    243         clearSelectionQuietly();
    244         notifySelectionChanged();
    245     }
    246 
    247     /**
    248      * Clears the selection, without notifying selection listeners. UI elements still need to be
    249      * notified about state changes so that they can update their appearance.
    250      */
    251     private void clearSelectionQuietly() {
    252         mRanger = null;
    253 
    254         if (!hasSelection()) {
    255             return;
    256         }
    257 
    258         Selection oldSelection = getSelection(new Selection());
    259         mSelection.clear();
    260 
    261         for (String id: oldSelection.mSelection) {
    262             notifyItemStateChanged(id, false);
    263         }
    264         for (String id: oldSelection.mProvisionalSelection) {
    265             notifyItemStateChanged(id, false);
    266         }
    267     }
    268 
    269     /**
    270      * Toggles selection on the item with the given model ID.
    271      *
    272      * @param modelId
    273      */
    274     public void toggleSelection(String modelId) {
    275         assert(modelId != null);
    276 
    277         final boolean changed = mSelection.contains(modelId)
    278                 ? attemptDeselect(modelId)
    279                 : attemptSelect(modelId);
    280 
    281         if (changed) {
    282             notifySelectionChanged();
    283         }
    284     }
    285 
    286     /**
    287      * Starts a range selection. If a range selection is already active, this will start a new range
    288      * selection (which will reset the range anchor).
    289      *
    290      * @param pos The anchor position for the selection range.
    291      */
    292     public void startRangeSelection(int pos) {
    293         attemptSelect(mAdapter.getModelId(pos));
    294         setSelectionRangeBegin(pos);
    295     }
    296 
    297     public void snapRangeSelection(int pos) {
    298         snapRangeSelection(pos, RANGE_REGULAR);
    299     }
    300 
    301     void snapProvisionalRangeSelection(int pos) {
    302         snapRangeSelection(pos, RANGE_PROVISIONAL);
    303     }
    304 
    305     /*
    306      * Starts and extends range selection in one go. This assumes item at startPos is not selected
    307      * beforehand.
    308      */
    309     public void formNewSelectionRange(int startPos, int endPos) {
    310         assert(!mSelection.contains(mAdapter.getModelId(startPos)));
    311         startRangeSelection(startPos);
    312         snapRangeSelection(endPos);
    313     }
    314 
    315     /**
    316      * Sets the end point for the current range selection, started by a call to
    317      * {@link #startRangeSelection(int)}. This function should only be called when a range selection
    318      * is active (see {@link #isRangeSelectionActive()}. Items in the range [anchor, end] will be
    319      * selected or in provisional select, depending on the type supplied. Note that if the type is
    320      * provisional select, one should do {@link Selection#applyProvisionalSelection()} at some point
    321      * before calling on {@link #endRangeSelection()}.
    322      *
    323      * @param pos The new end position for the selection range.
    324      * @param type The type of selection the range should utilize.
    325      */
    326     private void snapRangeSelection(int pos, @RangeType int type) {
    327         if (!isRangeSelectionActive()) {
    328             throw new IllegalStateException("Range start point not set.");
    329         }
    330 
    331         mRanger.snapSelection(pos, type);
    332 
    333         // We're being lazy here notifying even when something might not have changed.
    334         // To make this more correct, we'd need to update the Ranger class to return
    335         // information about what has changed.
    336         notifySelectionChanged();
    337     }
    338 
    339     void cancelProvisionalSelection() {
    340         for (String id : mSelection.mProvisionalSelection) {
    341             notifyItemStateChanged(id, false);
    342         }
    343         mSelection.cancelProvisionalSelection();
    344     }
    345 
    346     /**
    347      * Stops an in-progress range selection. All selection done with
    348      * {@link #snapRangeSelection(int, int)} with type RANGE_PROVISIONAL will be lost if
    349      * {@link Selection#applyProvisionalSelection()} is not called beforehand.
    350      */
    351     public void endRangeSelection() {
    352         mRanger = null;
    353         // Clean up in case there was any leftover provisional selection
    354         cancelProvisionalSelection();
    355     }
    356 
    357     /**
    358      * @return Whether or not there is a current range selection active.
    359      */
    360     public boolean isRangeSelectionActive() {
    361         return mRanger != null;
    362     }
    363 
    364     /**
    365      * Sets the magic location at which a selection range begins (the selection anchor). This value
    366      * is consulted when determining how to extend, and modify selection ranges. Calling this when a
    367      * range selection is active will reset the range selection.
    368      */
    369     public void setSelectionRangeBegin(int position) {
    370         if (position == RecyclerView.NO_POSITION) {
    371             return;
    372         }
    373 
    374         if (mSelection.contains(mAdapter.getModelId(position))) {
    375             mRanger = new Range(this::updateForRange, position);
    376         }
    377     }
    378 
    379     /**
    380      * @param modelId
    381      * @return True if the update was applied.
    382      */
    383     private boolean selectAndNotify(String modelId) {
    384         boolean changed = mSelection.add(modelId);
    385         if (changed) {
    386             notifyItemStateChanged(modelId, true);
    387         }
    388         return changed;
    389     }
    390 
    391     /**
    392      * @param id
    393      * @return True if the update was applied.
    394      */
    395     private boolean attemptDeselect(String id) {
    396         assert(id != null);
    397         if (canSetState(id, false)) {
    398             mSelection.remove(id);
    399             notifyItemStateChanged(id, false);
    400 
    401             // if there's nothing in the selection and there is an active ranger it results
    402             // in unexpected behavior when the user tries to start range selection: the item
    403             // which the ranger 'thinks' is the already selected anchor becomes unselectable
    404             if (mSelection.isEmpty() && isRangeSelectionActive()) {
    405                 endRangeSelection();
    406             }
    407             if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
    408             return true;
    409         } else {
    410             if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
    411             return false;
    412         }
    413     }
    414 
    415     /**
    416      * @param id
    417      * @return True if the update was applied.
    418      */
    419     private boolean attemptSelect(String id) {
    420         assert(id != null);
    421         boolean canSelect = canSetState(id, true);
    422         if (!canSelect) {
    423             return false;
    424         }
    425         if (mSingleSelect && hasSelection()) {
    426             clearSelectionQuietly();
    427         }
    428 
    429         selectAndNotify(id);
    430         return true;
    431     }
    432 
    433     boolean canSetState(String id, boolean nextState) {
    434         return mCanSetState.test(id, nextState);
    435     }
    436 
    437     private void notifyDataChanged() {
    438         final int lastListener = mItemCallbacks.size() - 1;
    439 
    440         for (int i = lastListener; i >= 0; i--) {
    441             mItemCallbacks.get(i).onSelectionReset();
    442         }
    443 
    444         for (String id : mSelection) {
    445             if (!canSetState(id, true)) {
    446                 attemptDeselect(id);
    447             } else {
    448                 for (int i = lastListener; i >= 0; i--) {
    449                     mItemCallbacks.get(i).onItemStateChanged(id, true);
    450                 }
    451             }
    452         }
    453     }
    454 
    455     /**
    456      * Notifies registered listeners when the selection status of a single item
    457      * (identified by {@code position}) changes.
    458      */
    459     void notifyItemStateChanged(String id, boolean selected) {
    460         assert(id != null);
    461         int lastListener = mItemCallbacks.size() - 1;
    462         for (int i = lastListener; i >= 0; i--) {
    463             mItemCallbacks.get(i).onItemStateChanged(id, selected);
    464         }
    465         mAdapter.onItemSelectionChanged(id);
    466     }
    467 
    468     /**
    469      * Notifies registered listeners when the selection has changed. This
    470      * notification should be sent only once a full series of changes
    471      * is complete, e.g. clearingSelection, or updating the single
    472      * selection from one item to another.
    473      */
    474     void notifySelectionChanged() {
    475         int lastListener = mCallbacks.size() - 1;
    476         for (int i = lastListener; i > -1; i--) {
    477             mCallbacks.get(i).onSelectionChanged();
    478         }
    479     }
    480 
    481     private void notifySelectionRestored() {
    482         int lastListener = mCallbacks.size() - 1;
    483         for (int i = lastListener; i > -1; i--) {
    484             mCallbacks.get(i).onSelectionRestored();
    485         }
    486     }
    487 
    488     void updateForRange(int begin, int end, boolean selected, @RangeType int type) {
    489         switch (type) {
    490             case RANGE_REGULAR:
    491                 updateForRegularRange(begin, end, selected);
    492                 break;
    493             case RANGE_PROVISIONAL:
    494                 updateForProvisionalRange(begin, end, selected);
    495                 break;
    496             default:
    497                 throw new IllegalArgumentException("Invalid range type: " + type);
    498         }
    499     }
    500 
    501     private void updateForRegularRange(int begin, int end, boolean selected) {
    502         assert(end >= begin);
    503         for (int i = begin; i <= end; i++) {
    504             String id = mAdapter.getModelId(i);
    505             if (id == null) {
    506                 continue;
    507             }
    508 
    509             if (selected) {
    510                 boolean canSelect = canSetState(id, true);
    511                 if (canSelect) {
    512                     if (mSingleSelect && hasSelection()) {
    513                         clearSelectionQuietly();
    514                     }
    515                     selectAndNotify(id);
    516                 }
    517             } else {
    518                 attemptDeselect(id);
    519             }
    520         }
    521     }
    522 
    523     private void updateForProvisionalRange(int begin, int end, boolean selected) {
    524         assert (end >= begin);
    525         for (int i = begin; i <= end; i++) {
    526             String id = mAdapter.getModelId(i);
    527             if (id == null) {
    528                 continue;
    529             }
    530 
    531             boolean changedState = false;
    532             if (selected) {
    533                 boolean canSelect = canSetState(id, true);
    534                 if (canSelect && !mSelection.mSelection.contains(id)) {
    535                     mSelection.mProvisionalSelection.add(id);
    536                     changedState = true;
    537                 }
    538             } else {
    539                 mSelection.mProvisionalSelection.remove(id);
    540                 changedState = true;
    541             }
    542 
    543             // Only notify item callbacks when something's state is actually changed in provisional
    544             // selection.
    545             if (changedState) {
    546                 notifyItemStateChanged(id, selected);
    547             }
    548         }
    549         notifySelectionChanged();
    550     }
    551 
    552     public interface ItemCallback {
    553         void onItemStateChanged(String id, boolean selected);
    554 
    555         void onSelectionReset();
    556     }
    557 
    558     public interface Callback {
    559         /**
    560          * Called immediately after completion of any set of changes.
    561          */
    562         void onSelectionChanged();
    563 
    564         /**
    565          * Called immediately after selection is restored.
    566          */
    567         void onSelectionRestored();
    568     }
    569 
    570     @FunctionalInterface
    571     public interface SelectionPredicate {
    572         boolean test(String id, boolean nextState);
    573     }
    574 }
    575