/* * 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.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 mCallbacks = new ArrayList<>(1); private final List 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 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; } public void replaceSelection(Iterable 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 ids, boolean selected) { final boolean changed = setItemsSelectedQuietly(ids, selected); notifySelectionChanged(); return changed; } private boolean setItemsSelectedQuietly(Iterable 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 (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); } }