/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.documentsui.selection; import static android.support.v4.util.Preconditions.checkArgument; import static android.support.v4.util.Preconditions.checkState; import static com.android.documentsui.selection.Shared.DEBUG; import static com.android.documentsui.selection.Shared.TAG; import android.support.annotation.IntDef; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.RecyclerView.Adapter; import android.util.Log; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import javax.annotation.Nullable; /** * {@link SelectionHelper} providing support traditional multi-item selection on top * of {@link RecyclerView}. * *

The class supports running in a single-select mode, which can be enabled * by passing {@colde #MODE_SINGLE} to the constructor. */ public final class DefaultSelectionHelper extends SelectionHelper { public static final int MODE_MULTIPLE = 0; public static final int MODE_SINGLE = 1; @IntDef(flag = true, value = { MODE_MULTIPLE, MODE_SINGLE }) @Retention(RetentionPolicy.SOURCE) public @interface SelectionMode {} private static final int RANGE_REGULAR = 0; /** * "Provisional" selection represents a overlay on the primary selection. A provisional * selection maybe be eventually added to the primary selection, or it may be abandoned. * *

E.g. BandController creates a provisional selection while a user is actively selecting * items with the band. Provisionally selected items are considered to be selected in * {@link Selection#contains(String)} and related methods. A provisional may be abandoned or * applied by selection components (like * {@link com.android.documentsui.selection.BandSelectionHelper}). * *

A provisional selection may intersect the primary selection, however clearing the * provisional selection will not affect the primary selection where the two may intersect. */ private static final int RANGE_PROVISIONAL = 1; @IntDef({ RANGE_REGULAR, RANGE_PROVISIONAL }) @Retention(RetentionPolicy.SOURCE) @interface RangeType {} private final Selection mSelection = new Selection(); private final List mObservers = new ArrayList<>(1); private final RecyclerView.Adapter mAdapter; private final StableIdProvider mStableIds; private final SelectionPredicate mSelectionPredicate; private final RecyclerView.AdapterDataObserver mAdapterObserver; private final RangeCallbacks mRangeCallbacks; private final boolean mSingleSelect; private @Nullable Range mRange; /** * Creates a new instance. * * @param mode single or multiple selection mode. In single selection mode * users can only select a single item. * @param adapter {@link Adapter} for the RecyclerView this instance is coupled with. * @param stableIds client supplied class providing access to stable ids. * @param selectionPredicate A predicate allowing the client to disallow selection * of individual elements. */ public DefaultSelectionHelper( @SelectionMode int mode, RecyclerView.Adapter adapter, StableIdProvider stableIds, SelectionPredicate selectionPredicate) { checkArgument(mode == MODE_SINGLE || mode == MODE_MULTIPLE); checkArgument(adapter != null); checkArgument(stableIds != null); checkArgument(selectionPredicate != null); mAdapter = adapter; mStableIds = stableIds; mSelectionPredicate = selectionPredicate; mAdapterObserver = new AdapterObserver(); mRangeCallbacks = new RangeCallbacks(); mSingleSelect = mode == MODE_SINGLE; mAdapter.registerAdapterDataObserver(mAdapterObserver); } @Override public void addObserver(SelectionObserver callback) { checkArgument(callback != null); mObservers.add(callback); } @Override public boolean hasSelection() { return !mSelection.isEmpty(); } @Override public Selection getSelection() { return mSelection; } @Override public void copySelection(Selection dest) { dest.copyFrom(mSelection); } @Override public boolean isSelected(String id) { return mSelection.contains(id); } @Override public void restoreSelection(Selection other) { setItemsSelectedQuietly(other.mSelection, true); // NOTE: We intentionally don't restore provisional selection. It's provisional. notifySelectionRestored(); } @Override public boolean setItemsSelected(Iterable ids, boolean selected) { boolean changed = setItemsSelectedQuietly(ids, selected); notifySelectionChanged(); return changed; } private boolean setItemsSelectedQuietly(Iterable ids, boolean selected) { boolean changed = false; for (String id: ids) { boolean itemChanged = selected ? canSetState(id, true) && mSelection.add(id) : canSetState(id, false) && mSelection.remove(id); if (itemChanged) { notifyItemStateChanged(id, selected); } changed |= itemChanged; } return changed; } @Override public void clearSelection() { if (!hasSelection()) { return; } Selection prev = clearSelectionQuietly(); notifySelectionCleared(prev); notifySelectionChanged(); } /** * Clears the selection, without notifying selection listeners. * Returns items in previous selection. Callers are responsible for notifying * listeners about changes. */ private Selection clearSelectionQuietly() { mRange = null; Selection prevSelection = new Selection(); if (hasSelection()) { copySelection(prevSelection); mSelection.clear(); } return prevSelection; } @Override public boolean select(String id) { checkArgument(id != null); if (!mSelection.contains(id)) { if (!canSetState(id, true)) { if (DEBUG) Log.d(TAG, "Select cancelled by selection predicate test."); return false; } // Enforce single selection policy. if (mSingleSelect && hasSelection()) { Selection prev = clearSelectionQuietly(); notifySelectionCleared(prev); } mSelection.add(id); notifyItemStateChanged(id, true); notifySelectionChanged(); return true; } return false; } @Override public boolean deselect(String id) { checkArgument(id != null); if (mSelection.contains(id)) { if (!canSetState(id, false)) { if (DEBUG) Log.d(TAG, "Deselect cancelled by selection predicate test."); return false; } mSelection.remove(id); notifyItemStateChanged(id, false); notifySelectionChanged(); if (mSelection.isEmpty() && isRangeActive()) { // if there's nothing in the selection and there is an active ranger it results // in unexpected behavior when the user tries to start range selection: the item // which the ranger 'thinks' is the already selected anchor becomes unselectable endRange(); } return true; } return false; } @Override public void startRange(int pos) { select(mStableIds.getStableId(pos)); anchorRange(pos); } @Override public void extendRange(int pos) { extendRange(pos, RANGE_REGULAR); } @Override public void endRange() { mRange = null; // Clean up in case there was any leftover provisional selection clearProvisionalSelection(); } @Override public void anchorRange(int position) { checkArgument(position != RecyclerView.NO_POSITION); // TODO: I'm not a fan of silently ignoring calls. // Determine if there are any cases where method can be called // w/o item already being selected. Else, tighten up the ship // and make this conditional guard into a proper precondition check. if (mSelection.contains(mStableIds.getStableId(position))) { mRange = new Range(mRangeCallbacks, position); } } @Override public void extendProvisionalRange(int pos) { extendRange(pos, RANGE_PROVISIONAL); } /** * Sets the end point for the current range selection, started by a call to * {@link #startRange(int)}. This function should only be called when a range selection * is active (see {@link #isRangeActive()}. Items in the range [anchor, end] will be * selected or in provisional select, depending on the type supplied. Note that if the type is * provisional selection, one should do {@link #mergeProvisionalSelection()} at some * point before calling on {@link #endRange()}. * * @param pos The new end position for the selection range. * @param type The type of selection the range should utilize. */ private void extendRange(int pos, @RangeType int type) { checkState(isRangeActive(), "Range start point not set."); mRange.extendSelection(pos, type); // We're being lazy here notifying even when something might not have changed. // To make this more correct, we'd need to update the Ranger class to return // information about what has changed. notifySelectionChanged(); } @Override public void setProvisionalSelection(Set newSelection) { Map delta = mSelection.setProvisionalSelection(newSelection); for (Map.Entry entry: delta.entrySet()) { notifyItemStateChanged(entry.getKey(), entry.getValue()); } notifySelectionChanged(); } @Override public void mergeProvisionalSelection() { mSelection.mergeProvisionalSelection(); } @Override public void clearProvisionalSelection() { for (String id : mSelection.mProvisionalSelection) { notifyItemStateChanged(id, false); } mSelection.clearProvisionalSelection(); } @Override public boolean isRangeActive() { return mRange != null; } private boolean canSetState(String id, boolean nextState) { return mSelectionPredicate.canSetStateForId(id, nextState); } private void onDataSetChanged() { // Update the selection to remove any disappeared IDs. mSelection.clearProvisionalSelection(); mSelection.intersect(mStableIds.getStableIds()); notifySelectionReset(); for (String id : mSelection) { // If the underlying data set has changed, before restoring // selection we must re-verify that it can be selected. // Why? Because if the dataset has changed, then maybe the // selectability of an item has changed. if (!canSetState(id, true)) { deselect(id); } else { int lastListener = mObservers.size() - 1; for (int i = lastListener; i >= 0; i--) { mObservers.get(i).onItemStateChanged(id, true); } } } notifySelectionChanged(); } private void onDataSetItemRangeInserted(int startPosition, int itemCount) { mSelection.clearProvisionalSelection(); } private void onDataSetItemRangeRemoved(int startPosition, int itemCount) { checkArgument(startPosition >= 0); checkArgument(itemCount > 0); mSelection.clearProvisionalSelection(); // Remove any disappeared IDs from the selection. // // Ideally there could be a cheaper approach, checking // each position individually, but since the source of // truth for stable ids (StableIdProvider) probably // it-self no-longer knows about the positions in question // we fall back to the sledge hammer approach. mSelection.intersect(mStableIds.getStableIds()); } /** * Notifies registered listeners when the selection status of a single item * (identified by {@code position}) changes. */ private void notifyItemStateChanged(String id, boolean selected) { checkArgument(id != null); int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onItemStateChanged(id, selected); } int position = mStableIds.getPosition(id); if (DEBUG) Log.d(TAG, "ITEM " + id + " CHANGED at pos: " + position); if (position >= 0) { mAdapter.notifyItemChanged(position, SelectionHelper.SELECTION_CHANGED_MARKER); } else { Log.w(TAG, "Item change notification received for unknown item: " + id); } } private void notifySelectionCleared(Selection selection) { for (String id: selection.mSelection) { notifyItemStateChanged(id, false); } for (String id: selection.mProvisionalSelection) { notifyItemStateChanged(id, false); } } /** * Notifies registered listeners when the selection has changed. This * notification should be sent only once a full series of changes * is complete, e.g. clearingSelection, or updating the single * selection from one item to another. */ private void notifySelectionChanged() { int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onSelectionChanged(); } } private void notifySelectionRestored() { int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onSelectionRestored(); } } private void notifySelectionReset() { int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onSelectionReset(); } } private void updateForRange(int begin, int end, boolean selected, @RangeType int type) { switch (type) { case RANGE_REGULAR: updateForRegularRange(begin, end, selected); break; case RANGE_PROVISIONAL: updateForProvisionalRange(begin, end, selected); break; default: throw new IllegalArgumentException("Invalid range type: " + type); } } private void updateForRegularRange(int begin, int end, boolean selected) { checkArgument(end >= begin); for (int i = begin; i <= end; i++) { String id = mStableIds.getStableId(i); if (id == null) { continue; } if (selected) { select(id); } else { deselect(id); } } } private void updateForProvisionalRange(int begin, int end, boolean selected) { checkArgument(end >= begin); for (int i = begin; i <= end; i++) { String id = mStableIds.getStableId(i); if (id == null) { continue; } boolean changedState = false; if (selected) { boolean canSelect = canSetState(id, true); if (canSelect && !mSelection.mSelection.contains(id)) { mSelection.mProvisionalSelection.add(id); changedState = true; } } else { mSelection.mProvisionalSelection.remove(id); changedState = true; } // Only notify item callbacks when something's state is actually changed in provisional // selection. if (changedState) { notifyItemStateChanged(id, selected); } } notifySelectionChanged(); } private final class AdapterObserver extends RecyclerView.AdapterDataObserver { @Override public void onChanged() { onDataSetChanged(); } @Override public void onItemRangeChanged( int startPosition, int itemCount, Object payload) { // No change in position. Ignore, since we assume // selection is a user driven activity. So changes // in properties of items shouldn't result in a // change of selection. } @Override public void onItemRangeInserted(int startPosition, int itemCount) { onDataSetItemRangeInserted(startPosition, itemCount); } @Override public void onItemRangeRemoved(int startPosition, int itemCount) { onDataSetItemRangeRemoved(startPosition, itemCount); } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { throw new UnsupportedOperationException(); } } private final class RangeCallbacks extends Range.Callbacks { @Override void updateForRange(int begin, int end, boolean selected, int type) { switch (type) { case RANGE_REGULAR: updateForRegularRange(begin, end, selected); break; case RANGE_PROVISIONAL: updateForProvisionalRange(begin, end, selected); break; default: throw new IllegalArgumentException( "Invalid range type: " + type); } } } }