/* * Copyright 2017 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 androidx.recyclerview.selection; import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; import static androidx.core.util.Preconditions.checkArgument; import static androidx.core.util.Preconditions.checkState; import static androidx.recyclerview.selection.Shared.DEBUG; import android.os.Bundle; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; import androidx.annotation.VisibleForTesting; import androidx.recyclerview.selection.Range.RangeType; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; /** * {@link SelectionTracker} providing support for traditional multi-item selection on top * of {@link RecyclerView}. * *

* The class supports running in a single-select mode, which can be enabled using * {@link SelectionPredicate#canSelectMultiple()}. * * @param Selection key type. @see {@link StorageStrategy} for supported types. * * @hide */ @RestrictTo(LIBRARY_GROUP) public class DefaultSelectionTracker extends SelectionTracker { private static final String TAG = "DefaultSelectionTracker"; private static final String EXTRA_SELECTION_PREFIX = "androidx.recyclerview.selection"; private final Selection mSelection = new Selection<>(); private final List mObservers = new ArrayList<>(1); private final ItemKeyProvider mKeyProvider; private final SelectionPredicate mSelectionPredicate; private final StorageStrategy mStorage; private final RangeCallbacks mRangeCallbacks; private final AdapterObserver mAdapterObserver; private final boolean mSingleSelect; private final String mSelectionId; private @Nullable Range mRange; /** * Creates a new instance. * * @param selectionId A unique string identifying this selection in the context * of the activity or fragment. * @param keyProvider client supplied class providing access to stable ids. * @param selectionPredicate A predicate allowing the client to disallow selection * @param storage Strategy for storing typed selection in bundle. */ public DefaultSelectionTracker( @NonNull String selectionId, @NonNull ItemKeyProvider keyProvider, @NonNull SelectionPredicate selectionPredicate, @NonNull StorageStrategy storage) { checkArgument(selectionId != null); checkArgument(!selectionId.trim().isEmpty()); checkArgument(keyProvider != null); checkArgument(selectionPredicate != null); checkArgument(storage != null); mSelectionId = selectionId; mKeyProvider = keyProvider; mSelectionPredicate = selectionPredicate; mStorage = storage; mRangeCallbacks = new RangeCallbacks(); mSingleSelect = !selectionPredicate.canSelectMultiple(); mAdapterObserver = new AdapterObserver(this); } @Override public void addObserver(@NonNull 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(@NonNull MutableSelection dest) { dest.copyFrom(mSelection); } @Override public boolean isSelected(@Nullable K key) { return mSelection.contains(key); } @Override protected void restoreSelection(@NonNull Selection other) { checkArgument(other != null); setItemsSelectedQuietly(other.mSelection, true); // NOTE: We intentionally don't restore provisional selection. It's provisional. notifySelectionRestored(); } @Override public boolean setItemsSelected(@NonNull Iterable keys, boolean selected) { boolean changed = setItemsSelectedQuietly(keys, selected); notifySelectionChanged(); return changed; } private boolean setItemsSelectedQuietly(@NonNull Iterable keys, boolean selected) { boolean changed = false; for (K key: keys) { boolean itemChanged = selected ? canSetState(key, true) && mSelection.add(key) : canSetState(key, false) && mSelection.remove(key); if (itemChanged) { notifyItemStateChanged(key, selected); } changed |= itemChanged; } return changed; } @Override public boolean clearSelection() { if (!hasSelection()) { return false; } clearProvisionalSelection(); clearPrimarySelection(); return true; } private void clearPrimarySelection() { 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; MutableSelection prevSelection = new MutableSelection(); if (hasSelection()) { copySelection(prevSelection); mSelection.clear(); } return prevSelection; } @Override public boolean select(@NonNull K key) { checkArgument(key != null); if (mSelection.contains(key)) { return false; } if (!canSetState(key, 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(key); notifyItemStateChanged(key, true); notifySelectionChanged(); return true; } @Override public boolean deselect(@NonNull K key) { checkArgument(key != null); if (mSelection.contains(key)) { if (!canSetState(key, false)) { if (DEBUG) Log.d(TAG, "Deselect cancelled by selection predicate test."); return false; } mSelection.remove(key); notifyItemStateChanged(key, 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 position) { if (mSelection.contains(mKeyProvider.getKey(position)) || select(mKeyProvider.getKey(position))) { anchorRange(position); } } @Override public void extendRange(int position) { extendRange(position, Range.TYPE_PRIMARY); } @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); checkArgument(mSelection.contains(mKeyProvider.getKey(position))); mRange = new Range(position, mRangeCallbacks); } @Override public void extendProvisionalRange(int position) { if (mSingleSelect) { return; } if (DEBUG) Log.i(TAG, "Extending provision range to position: " + position); checkState(isRangeActive(), "Range start point not set."); extendRange(position, Range.TYPE_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 position The new end position for the selection range. * @param type The type of selection the range should utilize. */ private void extendRange(int position, @RangeType int type) { checkState(isRangeActive(), "Range start point not set."); mRange.extendRange(position, 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(@NonNull Set newSelection) { if (mSingleSelect) { return; } Map delta = mSelection.setProvisionalSelection(newSelection); for (Map.Entry entry: delta.entrySet()) { notifyItemStateChanged(entry.getKey(), entry.getValue()); } notifySelectionChanged(); } @Override public void mergeProvisionalSelection() { mSelection.mergeProvisionalSelection(); // Note, that for almost all functional purposes, merging a provisional selection // into a the primary selection doesn't change the selection, just an internal // representation of it. But there are some nuanced areas cases where // that isn't true. equality for 1. So, we notify regardless. notifySelectionChanged(); } @Override public void clearProvisionalSelection() { for (K key : mSelection.mProvisionalSelection) { notifyItemStateChanged(key, false); } mSelection.clearProvisionalSelection(); } @Override public boolean isRangeActive() { return mRange != null; } private boolean canSetState(@NonNull K key, boolean nextState) { return mSelectionPredicate.canSetStateForKey(key, nextState); } @Override AdapterDataObserver getAdapterDataObserver() { return mAdapterObserver; } private void onDataSetChanged() { mSelection.clearProvisionalSelection(); notifySelectionRefresh(); for (K key : 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(key, true)) { deselect(key); } else { int lastListener = mObservers.size() - 1; for (int i = lastListener; i >= 0; i--) { mObservers.get(i).onItemStateChanged(key, true); } } } notifySelectionChanged(); } /** * Notifies registered listeners when the selection status of a single item * (identified by {@code position}) changes. */ private void notifyItemStateChanged(@NonNull K key, boolean selected) { checkArgument(key != null); int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onItemStateChanged(key, selected); } } private void notifySelectionCleared(@NonNull Selection selection) { for (K key: selection.mSelection) { notifyItemStateChanged(key, false); } for (K key: selection.mProvisionalSelection) { notifyItemStateChanged(key, 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 notifySelectionRefresh() { int lastListenerIndex = mObservers.size() - 1; for (int i = lastListenerIndex; i >= 0; i--) { mObservers.get(i).onSelectionRefresh(); } } private void updateForRange(int begin, int end, boolean selected, @RangeType int type) { switch (type) { case Range.TYPE_PRIMARY: updateForRegularRange(begin, end, selected); break; case Range.TYPE_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++) { K key = mKeyProvider.getKey(i); if (key == null) { continue; } if (selected) { select(key); } else { deselect(key); } } } private void updateForProvisionalRange(int begin, int end, boolean selected) { checkArgument(end >= begin); for (int i = begin; i <= end; i++) { K key = mKeyProvider.getKey(i); if (key == null) { continue; } boolean changedState = false; if (selected) { boolean canSelect = canSetState(key, true); if (canSelect && !mSelection.mSelection.contains(key)) { mSelection.mProvisionalSelection.add(key); changedState = true; } } else { mSelection.mProvisionalSelection.remove(key); changedState = true; } // Only notify item callbacks when something's state is actually changed in provisional // selection. if (changedState) { notifyItemStateChanged(key, selected); } } notifySelectionChanged(); } @VisibleForTesting String getInstanceStateKey() { return EXTRA_SELECTION_PREFIX + ":" + mSelectionId; } @Override @SuppressWarnings("unchecked") public final void onSaveInstanceState(@NonNull Bundle state) { if (mSelection.isEmpty()) { return; } state.putBundle(getInstanceStateKey(), mStorage.asBundle(mSelection)); } @Override public final void onRestoreInstanceState(@Nullable Bundle state) { if (state == null) { return; } @Nullable Bundle selectionState = state.getBundle(getInstanceStateKey()); if (selectionState == null) { return; } Selection selection = mStorage.asSelection(selectionState); if (selection != null && !selection.isEmpty()) { restoreSelection(selection); } } private final class RangeCallbacks extends Range.Callbacks { @Override void updateForRange(int begin, int end, boolean selected, int type) { switch (type) { case Range.TYPE_PRIMARY: updateForRegularRange(begin, end, selected); break; case Range.TYPE_PROVISIONAL: updateForProvisionalRange(begin, end, selected); break; default: throw new IllegalArgumentException("Invalid range type: " + type); } } } private static final class AdapterObserver extends AdapterDataObserver { private final DefaultSelectionTracker mSelectionTracker; AdapterObserver(@NonNull DefaultSelectionTracker selectionTracker) { checkArgument(selectionTracker != null); mSelectionTracker = selectionTracker; } @Override public void onChanged() { mSelectionTracker.onDataSetChanged(); } @Override public void onItemRangeChanged(int startPosition, int itemCount, @Nullable Object payload) { if (!SelectionTracker.SELECTION_CHANGED_MARKER.equals(payload)) { mSelectionTracker.onDataSetChanged(); } } @Override public void onItemRangeInserted(int startPosition, int itemCount) { mSelectionTracker.endRange(); } @Override public void onItemRangeRemoved(int startPosition, int itemCount) { mSelectionTracker.endRange(); } @Override public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) { mSelectionTracker.endRange(); } } }