FairEmail/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java

618 lines
20 KiB
Java

/*
* 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;
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}.
*
* <p>
* The class supports running in a single-select mode, which can be enabled using
* {@link SelectionPredicate#canSelectMultiple()}.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
* @hide
*/
@RestrictTo(LIBRARY)
@SuppressWarnings("unchecked")
public class DefaultSelectionTracker<K> extends SelectionTracker<K> implements Resettable {
private static final String TAG = "DefaultSelectionTracker";
private static final String EXTRA_SELECTION_PREFIX = "androidx.recyclerview.selection";
private final Selection<K> mSelection = new Selection<>();
private final List<SelectionObserver<K>> mObservers = new ArrayList<>(1);
private final ItemKeyProvider<K> mKeyProvider;
private final SelectionPredicate<K> mSelectionPredicate;
private final StorageStrategy<K> 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<K> keyProvider,
@NonNull SelectionPredicate<K> selectionPredicate,
@NonNull StorageStrategy<K> 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<K> callback) {
checkArgument(callback != null);
mObservers.add(callback);
}
/**
* @return true if there is a primary or previsional selection.
*/
@Override
public boolean hasSelection() {
return !mSelection.isEmpty();
}
@Override
public @NonNull Selection<K> getSelection() {
return mSelection;
}
@Override
public void copySelection(@NonNull MutableSelection<K> dest) {
dest.copyFrom(mSelection);
}
@Override
public boolean isSelected(@Nullable K key) {
return mSelection.contains(key);
}
@Override
protected void restoreSelection(@NonNull Selection<K> 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<K> keys, boolean selected) {
boolean changed = setItemsSelectedQuietly(keys, selected);
notifySelectionChanged();
return changed;
}
private boolean setItemsSelectedQuietly(@NonNull Iterable<K> 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()) {
if (DEBUG) Log.d(TAG, "Ignoring clearSelection request. No selection.");
return false;
}
if (DEBUG) Log.d(TAG, "Handling clearSelection request.");
clearProvisionalSelection();
clearPrimarySelection();
notifySelectionCleared();
return true;
}
private void clearPrimarySelection() {
if (!hasSelection()) {
return;
}
Selection<K> 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<K> clearSelectionQuietly() {
mRange = null;
MutableSelection<K> prevSelection = new MutableSelection();
if (hasSelection()) {
copySelection(prevSelection);
mSelection.clear();
}
return prevSelection;
}
@Override
public void reset() {
if (DEBUG) Log.d(TAG, "Received reset request.");
clearSelection();
mRange = null;
}
@Override
public boolean isResetRequired() {
return hasSelection() || isRangeActive();
}
@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<K> 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) {
if (!isRangeActive()) {
Log.e(TAG, "Ignoring attempt to extend unestablished range. Ignoring.");
if (DEBUG) {
throw new IllegalStateException("Attempted to extend unestablished range.");
}
return;
}
if (position == RecyclerView.NO_POSITION) {
Log.w(TAG, "Ignoring attempt to extend range to invalid position: " + position);
if (DEBUG) {
throw new IllegalStateException(
"Attempting to extend range to invalid position: " + position);
}
return;
}
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<K> newSelection) {
if (mSingleSelect) {
return;
}
Map<K, Boolean> delta = mSelection.setProvisionalSelection(newSelection);
for (Map.Entry<K, Boolean> 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
protected @NonNull AdapterDataObserver getAdapterDataObserver() {
return mAdapterObserver;
}
@SuppressWarnings({"WeakerAccess", "unchecked"}) /* synthetic access */
void onDataSetChanged() {
mSelection.clearProvisionalSelection();
notifySelectionRefresh();
List<K> toRemove = null;
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)) {
if (toRemove == null) {
toRemove = new ArrayList<>();
}
toRemove.add(key);
} else {
int lastListener = mObservers.size() - 1;
for (int i = lastListener; i >= 0; i--) {
mObservers.get(i).onItemStateChanged(key, true);
}
}
}
if (toRemove != null) {
for (K key : toRemove) {
deselect(key);
}
}
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() {
for (SelectionObserver<K> observer : mObservers) {
observer.onSelectionCleared();
}
}
private void notifySelectionCleared(@NonNull Selection<K> 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();
}
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
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);
}
}
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
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
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<K> selection = mStorage.asSelection(selectionState);
if (selection != null && !selection.isEmpty()) {
restoreSelection(selection);
}
}
private final class RangeCallbacks extends Range.Callbacks {
RangeCallbacks() {
}
@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();
}
}
}