blob: af77cfaadab6f4b6da4809df43302658fc8aba01 [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.selection;
import static com.android.documentsui.base.Shared.DEBUG;
import android.annotation.IntDef;
import android.support.annotation.VisibleForTesting;
import android.support.v7.widget.RecyclerView;
import android.util.Log;
import com.android.documentsui.dirlist.DocumentsAdapter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
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 SelectionManager {
@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;
static final String TAG = "SelectionManager";
private final Selection mSelection = new Selection();
private final List<Callback> mCallbacks = new ArrayList<>(1);
private final List<ItemCallback> mItemCallbacks = new ArrayList<>(1);
private @Nullable DocumentsAdapter mAdapter;
private @Nullable Range mRanger;
private boolean mSingleSelect;
private RecyclerView.AdapterDataObserver mAdapterObserver;
private SelectionPredicate mCanSetState;
public SelectionManager(@SelectionMode int mode) {
mSingleSelect = mode == MODE_SINGLE;
}
public SelectionManager reset(DocumentsAdapter adapter, SelectionPredicate canSetState) {
mCallbacks.clear();
mItemCallbacks.clear();
if (mAdapter != null && mAdapterObserver != null) {
mAdapter.unregisterAdapterDataObserver(mAdapterObserver);
}
clearSelectionQuietly();
assert(adapter != null);
assert(canSetState != null);
mAdapter = adapter;
mCanSetState = canSetState;
mAdapterObserver = 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);
notifyDataChanged();
}
@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();
}
};
mAdapter.registerAdapterDataObserver(mAdapterObserver);
return this;
}
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;
}
@VisibleForTesting
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 (if something changes).
*/
public void clearSelection() {
if (!hasSelection()) {
return;
}
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.
*/
public void startRangeSelection(int pos) {
attemptSelect(mAdapter.getModelId(pos));
setSelectionRangeBegin(pos);
}
public void snapRangeSelection(int pos) {
snapRangeSelection(pos, RANGE_REGULAR);
}
void snapProvisionalRangeSelection(int pos) {
snapRangeSelection(pos, RANGE_PROVISIONAL);
}
/*
* Starts and extends range selection in one go. This assumes item at startPos is not selected
* beforehand.
*/
public void formNewSelectionRange(int startPos, int endPos) {
assert(!mSelection.contains(mAdapter.getModelId(startPos)));
startRangeSelection(startPos);
snapRangeSelection(endPos);
}
/**
* 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();
}
void cancelProvisionalSelection() {
for (String id : mSelection.mProvisionalSelection) {
notifyItemStateChanged(id, false);
}
mSelection.cancelProvisionalSelection();
}
/**
* 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.
*/
public void endRangeSelection() {
mRanger = null;
// Clean up in case there was any leftover provisional selection
cancelProvisionalSelection();
}
/**
* @return Whether or not there is a current range selection active.
*/
public 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.
*/
public 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 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
if (mSelection.isEmpty() && isRangeSelectionActive()) {
endRangeSelection();
}
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);
}
private void notifyDataChanged() {
final int lastListener = mItemCallbacks.size() - 1;
for (int i = lastListener; i >= 0; i--) {
mItemCallbacks.get(i).onSelectionReset();
}
for (String id : mSelection) {
if (!canSetState(id, true)) {
attemptDeselect(id);
} else {
for (int i = lastListener; i >= 0; i--) {
mItemCallbacks.get(i).onItemStateChanged(id, true);
}
}
}
}
/**
* 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();
}
}
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;
}
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();
}
public interface ItemCallback {
void onItemStateChanged(String id, boolean selected);
void onSelectionReset();
}
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);
}
}