blob: 9c3aedfb8834e325cea24045f36a27759b11dcb5 [file] [log] [blame]
/*
* 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.dirlist;
import static com.android.documentsui.base.Shared.DEBUG;
import android.annotation.IntDef;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.VisibleForTesting;
import android.support.v7.widget.RecyclerView;
import android.util.Log;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.Nullable;
/**
* MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
* Additionally it can be configured to restrict selection to a single element, @see
* #setSelectMode.
*/
public final class MultiSelectManager {
@IntDef(flag = true, value = {
MODE_MULTIPLE,
MODE_SINGLE
})
@Retention(RetentionPolicy.SOURCE)
public @interface SelectionMode {}
public static final int MODE_MULTIPLE = 0;
public static final int MODE_SINGLE = 1;
@IntDef({
RANGE_REGULAR,
RANGE_PROVISIONAL
})
@Retention(RetentionPolicy.SOURCE)
public @interface RangeType {}
public static final int RANGE_REGULAR = 0;
public static final int RANGE_PROVISIONAL = 1;
private static final String TAG = "MultiSelectManager";
private final Selection mSelection = new Selection();
private final DocumentsAdapter mAdapter;
private final List<Callback> mCallbacks = new ArrayList<>(1);
private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1);
private @Nullable Range mRanger;
private boolean mSingleSelect;
private final SelectionPredicate mCanSetState;
public MultiSelectManager(
DocumentsAdapter adapter,
@SelectionMode int mode,
SelectionPredicate canSetState) {
assert(adapter != null);
mAdapter = adapter;
mCanSetState = canSetState;
mSingleSelect = mode == MODE_SINGLE;
mAdapter.registerAdapterDataObserver(
new RecyclerView.AdapterDataObserver() {
private List<String> mModelIds;
@Override
public void onChanged() {
mModelIds = mAdapter.getModelIds();
// Update the selection to remove any disappeared IDs.
mSelection.cancelProvisionalSelection();
mSelection.intersect(mModelIds);
}
@Override
public void onItemRangeChanged(
int startPosition, int itemCount, Object payload) {
// No change in position. Ignoring.
}
@Override
public void onItemRangeInserted(int startPosition, int itemCount) {
mSelection.cancelProvisionalSelection();
}
@Override
public void onItemRangeRemoved(int startPosition, int itemCount) {
assert(startPosition >= 0);
assert(itemCount > 0);
mSelection.cancelProvisionalSelection();
// Remove any disappeared IDs from the selection.
mSelection.intersect(mModelIds);
}
@Override
public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
throw new UnsupportedOperationException();
}
});
}
void bindContoller(BandController controller) {
// Provides BandController with access to private mSelection state.
controller.bindSelection(mSelection);
}
/**
* Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
* events occur.
*
* @param callback
*/
public void addCallback(Callback callback) {
assert(callback != null);
mCallbacks.add(callback);
}
public void addItemCallback(ItemCallback itemCallback) {
assert(itemCallback != null);
mItemCallbacks.add(itemCallback);
}
public boolean hasSelection() {
return !mSelection.isEmpty();
}
/**
* Returns a Selection object that provides a live view
* on the current selection.
*
* @see #getSelection(Selection) on how to get a snapshot
* of the selection that will not reflect future changes
* to selection.
*
* @return The current selection.
*/
public Selection getSelection() {
return mSelection;
}
/**
* Updates {@code dest} to reflect the current selection.
* @param dest
*
* @return The Selection instance passed in, for convenience.
*/
public Selection getSelection(Selection dest) {
dest.copyFrom(mSelection);
return dest;
}
public void replaceSelection(Iterable<String> ids) {
clearSelection();
setItemsSelected(ids, true);
}
/**
* Restores the selected state of specified items. Used in cases such as restore the selection
* after rotation etc.
*/
public void restoreSelection(Selection other) {
setItemsSelectedQuietly(other.mSelection, true);
// NOTE: We intentionally don't restore provisional selection. It's provisional.
notifySelectionRestored();
}
/**
* Sets the selected state of the specified items. Note that the callback will NOT
* be consulted to see if an item can be selected.
*
* @param ids
* @param selected
* @return
*/
public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
final boolean changed = setItemsSelectedQuietly(ids, selected);
notifySelectionChanged();
return changed;
}
private boolean setItemsSelectedQuietly(Iterable<String> ids, boolean selected) {
boolean changed = false;
for (String id: ids) {
final boolean itemChanged =
selected
? canSetState(id, true) && mSelection.add(id)
: canSetState(id, false) && mSelection.remove(id);
if (itemChanged) {
notifyItemStateChanged(id, selected);
}
changed |= itemChanged;
}
return changed;
}
/**
* Clears the selection and notifies (even if nothing changes).
*/
public void clearSelection() {
clearSelectionQuietly();
notifySelectionChanged();
}
/**
* Clears the selection, without notifying selection listeners. UI elements still need to be
* notified about state changes so that they can update their appearance.
*/
private void clearSelectionQuietly() {
mRanger = null;
if (!hasSelection()) {
return;
}
Selection oldSelection = getSelection(new Selection());
mSelection.clear();
for (String id: oldSelection.mSelection) {
notifyItemStateChanged(id, false);
}
for (String id: oldSelection.mProvisionalSelection) {
notifyItemStateChanged(id, false);
}
}
/**
* Toggles selection on the item with the given model ID.
*
* @param modelId
*/
public void toggleSelection(String modelId) {
assert(modelId != null);
final boolean changed =
mSelection.contains(modelId)
? attemptDeselect(modelId)
: attemptSelect(modelId);
if (changed) {
notifySelectionChanged();
}
}
/**
* Starts a range selection. If a range selection is already active, this will start a new range
* selection (which will reset the range anchor).
*
* @param pos The anchor position for the selection range.
*/
void startRangeSelection(int pos) {
attemptSelect(mAdapter.getModelId(pos));
setSelectionRangeBegin(pos);
}
void snapRangeSelection(int pos) {
snapRangeSelection(pos, RANGE_REGULAR);
}
void snapProvisionalRangeSelection(int pos) {
snapRangeSelection(pos, RANGE_PROVISIONAL);
}
/**
* Sets the end point for the current range selection, started by a call to
* {@link #startRangeSelection(int)}. This function should only be called when a range selection
* is active (see {@link #isRangeSelectionActive()}. 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 select, one should do {@link Selection#applyProvisionalSelection()} at some point
* before calling on {@link #endRangeSelection()}.
*
* @param pos The new end position for the selection range.
* @param type The type of selection the range should utilize.
*/
private void snapRangeSelection(int pos, @RangeType int type) {
if (!isRangeSelectionActive()) {
throw new IllegalStateException("Range start point not set.");
}
mRanger.snapSelection(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();
}
/**
* Stops an in-progress range selection. All selection done with
* {@link #snapRangeSelection(int, int)} with type RANGE_PROVISIONAL will be lost if
* {@link Selection#applyProvisionalSelection()} is not called beforehand.
*/
void endRangeSelection() {
mRanger = null;
// Clean up in case there was any leftover provisional selection
mSelection.cancelProvisionalSelection();
}
/**
* @return Whether or not there is a current range selection active.
*/
boolean isRangeSelectionActive() {
return mRanger != null;
}
/**
* Sets the magic location at which a selection range begins (the selection anchor). This value
* is consulted when determining how to extend, and modify selection ranges. Calling this when a
* range selection is active will reset the range selection.
*/
void setSelectionRangeBegin(int position) {
if (position == RecyclerView.NO_POSITION) {
return;
}
if (mSelection.contains(mAdapter.getModelId(position))) {
mRanger = new Range(this::updateForRange, position);
}
}
/**
* @param modelId
* @return True if the update was applied.
*/
private boolean selectAndNotify(String modelId) {
boolean changed = mSelection.add(modelId);
if (changed) {
notifyItemStateChanged(modelId, true);
}
return changed;
}
/**
* @param id
* @return True if the update was applied.
*/
private boolean attemptDeselect(String id) {
assert(id != null);
if (canSetState(id, false)) {
mSelection.remove(id);
notifyItemStateChanged(id, false);
if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
return true;
} else {
if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
return false;
}
}
/**
* @param id
* @return True if the update was applied.
*/
private boolean attemptSelect(String id) {
assert(id != null);
boolean canSelect = canSetState(id, true);
if (!canSelect) {
return false;
}
if (mSingleSelect && hasSelection()) {
clearSelectionQuietly();
}
selectAndNotify(id);
return true;
}
boolean canSetState(String id, boolean nextState) {
return mCanSetState.test(id, nextState);
}
/**
* Notifies registered listeners when the selection status of a single item
* (identified by {@code position}) changes.
*/
void notifyItemStateChanged(String id, boolean selected) {
assert(id != null);
int lastListener = mItemCallbacks.size() - 1;
for (int i = lastListener; i >= 0; i--) {
mItemCallbacks.get(i).onItemStateChanged(id, selected);
}
mAdapter.onItemSelectionChanged(id);
}
/**
* 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.
*/
void notifySelectionChanged() {
int lastListener = mCallbacks.size() - 1;
for (int i = lastListener; i > -1; i--) {
mCallbacks.get(i).onSelectionChanged();
}
}
private void notifySelectionRestored() {
int lastListener = mCallbacks.size() - 1;
for (int i = lastListener; i > -1; i--) {
mCallbacks.get(i).onSelectionRestored();
}
}
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) {
assert(end >= begin);
for (int i = begin; i <= end; i++) {
String id = mAdapter.getModelId(i);
if (id == null) {
continue;
}
if (selected) {
boolean canSelect = canSetState(id, true);
if (canSelect) {
if (mSingleSelect && hasSelection()) {
clearSelectionQuietly();
}
selectAndNotify(id);
}
} else {
attemptDeselect(id);
}
}
}
private void updateForProvisionalRange(int begin, int end, boolean selected) {
assert (end >= begin);
for (int i = begin; i <= end; i++) {
String id = mAdapter.getModelId(i);
if (id == null) {
continue;
}
if (selected) {
boolean canSelect = canSetState(id, true);
if (canSelect) {
mSelection.mProvisionalSelection.add(id);
}
} else {
mSelection.mProvisionalSelection.remove(id);
}
notifyItemStateChanged(id, selected);
}
notifySelectionChanged();
}
/**
* Class providing support for managing range selections.
*/
private static final class Range {
private static final int UNDEFINED = -1;
private final RangeUpdater mUpdater;
private final int mBegin;
private int mEnd = UNDEFINED;
public Range(RangeUpdater updater, int begin) {
if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin);
mUpdater = updater;
mBegin = begin;
}
private void snapSelection(int position, @RangeType int type) {
assert(position != RecyclerView.NO_POSITION);
if (mEnd == UNDEFINED || mEnd == mBegin) {
// Reset mEnd so it can be established in establishRange.
mEnd = UNDEFINED;
establishRange(position, type);
} else {
reviseRange(position, type);
}
}
private void establishRange(int position, @RangeType int type) {
assert(mEnd == UNDEFINED);
if (position == mBegin) {
mEnd = position;
}
if (position > mBegin) {
updateRange(mBegin + 1, position, true, type);
} else if (position < mBegin) {
updateRange(position, mBegin - 1, true, type);
}
mEnd = position;
}
private void reviseRange(int position, @RangeType int type) {
assert(mEnd != UNDEFINED);
assert(mBegin != mEnd);
if (position == mEnd) {
if (DEBUG) Log.v(TAG, "Ignoring no-op revision for range: " + this);
}
if (mEnd > mBegin) {
reviseAscendingRange(position, type);
} else if (mEnd < mBegin) {
reviseDescendingRange(position, type);
}
// the "else" case is covered by checkState at beginning of method.
mEnd = position;
}
/**
* Updates an existing ascending seleciton.
* @param position
*/
private void reviseAscendingRange(int position, @RangeType int type) {
// Reducing or reversing the range....
if (position < mEnd) {
if (position < mBegin) {
updateRange(mBegin + 1, mEnd, false, type);
updateRange(position, mBegin -1, true, type);
} else {
updateRange(position + 1, mEnd, false, type);
}
}
// Extending the range...
else if (position > mEnd) {
updateRange(mEnd + 1, position, true, type);
}
}
private void reviseDescendingRange(int position, @RangeType int type) {
// Reducing or reversing the range....
if (position > mEnd) {
if (position > mBegin) {
updateRange(mEnd, mBegin - 1, false, type);
updateRange(mBegin + 1, position, true, type);
} else {
updateRange(mEnd, position - 1, false, type);
}
}
// Extending the range...
else if (position < mEnd) {
updateRange(position, mEnd - 1, true, type);
}
}
/**
* Try to set selection state for all elements in range. Not that callbacks can cancel
* selection of specific items, so some or even all items may not reflect the desired state
* after the update is complete.
*
* @param begin Adapter position for range start (inclusive).
* @param end Adapter position for range end (inclusive).
* @param selected New selection state.
*/
private void updateRange(int begin, int end, boolean selected, @RangeType int type) {
mUpdater.updateForRange(begin, end, selected, type);
}
@Override
public String toString() {
return "Range{begin=" + mBegin + ", end=" + mEnd + "}";
}
/*
* @see {@link MultiSelectManager#updateForRegularRange(int, int , boolean)} and {@link
* MultiSelectManager#updateForProvisionalRange(int, int, boolean)}
*/
@FunctionalInterface
private interface RangeUpdater {
void updateForRange(int begin, int end, boolean selected, @RangeType int type);
}
}
/**
* Object representing the current selection. Provides read only access
* public access, and private write access.
*/
public static final class Selection implements Iterable<String>, Parcelable {
// This class tracks selected items by managing two sets: the saved selection, and the total
// selection. Saved selections are those which have been completed by tapping an item or by
// completing a band select operation. Provisional selections are selections which have been
// temporarily created by an in-progress band select operation (once the user releases the
// mouse button during a band select operation, the selected items become saved). The total
// selection is the combination of both the saved selection and the provisional
// selection. Tracking both separately is necessary to ensure that saved selections do not
// become deselected when they are removed from the provisional selection; for example, if
// item A is tapped (and selected), then an in-progress band select covers A then uncovers
// A, A should still be selected as it has been saved. To ensure this behavior, the saved
// selection must be tracked separately.
private final Set<String> mSelection;
private final Set<String> mProvisionalSelection;
public Selection() {
mSelection = new HashSet<String>();
mProvisionalSelection = new HashSet<String>();
}
/**
* Used by CREATOR.
*/
private Selection(Set<String> selection) {
mSelection = selection;
mProvisionalSelection = new HashSet<String>();
}
/**
* @param id
* @return true if the position is currently selected.
*/
public boolean contains(@Nullable String id) {
return mSelection.contains(id) || mProvisionalSelection.contains(id);
}
/**
* Returns an {@link Iterator} that iterators over the selection, *excluding*
* any provisional selection.
*
* {@inheritDoc}
*/
@Override
public Iterator<String> iterator() {
return mSelection.iterator();
}
/**
* @return size of the selection including both final and provisional selected items.
*/
public int size() {
return mSelection.size() + mProvisionalSelection.size();
}
/**
* @return true if the selection is empty.
*/
public boolean isEmpty() {
return mSelection.isEmpty() && mProvisionalSelection.isEmpty();
}
/**
* Sets the provisional selection, which is a temporary selection that can be saved,
* canceled, or adjusted at a later time. When a new provision selection is applied, the old
* one (if it exists) is abandoned.
* @return Map of ids added or removed. Added ids have a value of true, removed are false.
*/
@VisibleForTesting
protected Map<String, Boolean> setProvisionalSelection(Set<String> newSelection) {
Map<String, Boolean> delta = new HashMap<>();
for (String id: mProvisionalSelection) {
// Mark each item that used to be in the selection but is unsaved and not in the new
// provisional selection.
if (!newSelection.contains(id) && !mSelection.contains(id)) {
delta.put(id, false);
}
}
for (String id: mSelection) {
// Mark each item that used to be in the selection but is unsaved and not in the new
// provisional selection.
if (!newSelection.contains(id)) {
delta.put(id, false);
}
}
for (String id: newSelection) {
// Mark each item that was not previously in the selection but is in the new
// provisional selection.
if (!mSelection.contains(id) && !mProvisionalSelection.contains(id)) {
delta.put(id, true);
}
}
// Now, iterate through the changes and actually add/remove them to/from the current
// selection. This could not be done in the previous loops because changing the size of
// the selection mid-iteration changes iteration order erroneously.
for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
String id = entry.getKey();
if (entry.getValue()) {
mProvisionalSelection.add(id);
} else {
mProvisionalSelection.remove(id);
}
}
return delta;
}
/**
* Saves the existing provisional selection. Once the provisional selection is saved,
* subsequent provisional selections which are different from this existing one cannot
* cause items in this existing provisional selection to become deselected.
*/
@VisibleForTesting
protected void applyProvisionalSelection() {
mSelection.addAll(mProvisionalSelection);
mProvisionalSelection.clear();
}
/**
* Abandons the existing provisional selection so that all items provisionally selected are
* now deselected.
*/
@VisibleForTesting
void cancelProvisionalSelection() {
mProvisionalSelection.clear();
}
/** @hide */
@VisibleForTesting
boolean add(String id) {
if (!mSelection.contains(id)) {
mSelection.add(id);
return true;
}
return false;
}
/** @hide */
@VisibleForTesting
boolean remove(String id) {
if (mSelection.contains(id)) {
mSelection.remove(id);
return true;
}
return false;
}
public void clear() {
mSelection.clear();
}
/**
* Trims this selection to be the intersection of itself with the set of given IDs.
*/
public void intersect(Collection<String> ids) {
mSelection.retainAll(ids);
mProvisionalSelection.retainAll(ids);
}
@VisibleForTesting
void copyFrom(Selection source) {
mSelection.clear();
mSelection.addAll(source.mSelection);
mProvisionalSelection.clear();
mProvisionalSelection.addAll(source.mProvisionalSelection);
}
@Override
public String toString() {
if (size() <= 0) {
return "size=0, items=[]";
}
StringBuilder buffer = new StringBuilder(size() * 28);
buffer.append("Selection{")
.append("applied{size=" + mSelection.size())
.append(", entries=" + mSelection)
.append("}, provisional{size=" + mProvisionalSelection.size())
.append(", entries=" + mProvisionalSelection)
.append("}}");
return buffer.toString();
}
@Override
public int hashCode() {
return mSelection.hashCode() ^ mProvisionalSelection.hashCode();
}
@Override
public boolean equals(Object that) {
if (this == that) {
return true;
}
if (!(that instanceof Selection)) {
return false;
}
return mSelection.equals(((Selection) that).mSelection) &&
mProvisionalSelection.equals(((Selection) that).mProvisionalSelection);
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeStringList(new ArrayList<>(mSelection));
// We don't include provisional selection since it is
// typically coupled to some other runtime state (like a band).
}
public static final ClassLoaderCreator<Selection> CREATOR =
new ClassLoaderCreator<Selection>() {
@Override
public Selection createFromParcel(Parcel in) {
return createFromParcel(in, null);
}
@Override
public Selection createFromParcel(Parcel in, ClassLoader loader) {
ArrayList<String> selected = new ArrayList<>();
in.readStringList(selected);
return new Selection(new HashSet<String>(selected));
}
@Override
public Selection[] newArray(int size) {
return new Selection[size];
}
};
}
public interface ItemCallback {
void onItemStateChanged(String id, boolean selected);
}
public interface Callback {
/**
* Called immediately after completion of any set of changes.
*/
void onSelectionChanged();
/**
* Called immediately after selection is restored.
*/
void onSelectionRestored();
}
@FunctionalInterface
public interface SelectionPredicate {
boolean test(String id, boolean nextState);
}
}