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