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.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 
     73     public SelectionManager(@SelectionMode int mode) {
     74         mSingleSelect = mode == MODE_SINGLE;
     75     }
     76 
     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 
    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      */
    150     public void addCallback(Callback callback) {
    151         assert(callback != null);
    152         mCallbacks.add(callback);
    153     }
    154 
    155     public void addItemCallback(ItemCallback itemCallback) {
    156         assert(itemCallback != null);
    157         mItemCallbacks.add(itemCallback);
    158     }
    159 
    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      */
    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      */
    184     public Selection getSelection(Selection dest) {
    185         dest.copyFrom(mSelection);
    186         return dest;
    187     }
    188 
    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      */
    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      */
    212     public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
    213         final boolean changed = setItemsSelectedQuietly(ids, selected);
    214         notifySelectionChanged();
    215         return changed;
    216     }
    217 
    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      */
    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      */
    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      */
    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      */
    290     public void startRangeSelection(int pos) {
    291         attemptSelect(mAdapter.getModelId(pos));
    292         setSelectionRangeBegin(pos);
    293     }
    294 
    295     public void snapRangeSelection(int pos) {
    296         snapRangeSelection(pos, RANGE_REGULAR);
    297     }
    298 
    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      */
    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      */
    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 
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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 
    424     boolean canSetState(String id, boolean nextState) {
    425         return mCanSetState.test(id, nextState);
    426     }
    427 
    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      */
    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      */
    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 
    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 
    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 
    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 
    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 {
    544         void onItemStateChanged(String id, boolean selected);
    545 
    546         void onSelectionReset();
    547     }
    548 
    549     public interface Callback {
    550         /**
    551          * Called immediately after completion of any set of changes.
    552          */
    553         void onSelectionChanged();
    554 
    555         /**
    556          * Called immediately after selection is restored.
    557          */
    558         void onSelectionRestored();
    559     }
    560 
    561     @FunctionalInterface
    562     public interface SelectionPredicate {
    563         boolean test(String id, boolean nextState);
    564     }
    565 }
    566