mirror of https://github.com/M66B/FairEmail.git
618 lines
20 KiB
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();
|
|
}
|
|
}
|
|
}
|