Added patched recyclerview-selection 1.0.0

pull/147/head
M66B 7 years ago
parent e6c4e05e9d
commit c761642ef2

@ -23,7 +23,7 @@ For authorizing:
## Known problems
A [bug in Android](https://issuetracker.google.com/issues/78495471) lets FairEmail occasionally crash on long pressing or swiping.
~~A [bug in Android](https://issuetracker.google.com/issues/78495471) lets FairEmail occasionally crash on long pressing or swiping.~~
## Planned features

@ -123,7 +123,7 @@ dependencies {
// https://mvnrepository.com/artifact/androidx.recyclerview/recyclerview
implementation "androidx.recyclerview:recyclerview:$recyclerview_version"
implementation "androidx.recyclerview:recyclerview-selection:$recyclerview_version"
//implementation "androidx.recyclerview:recyclerview-selection:$recyclerview_version"
// https://mvnrepository.com/artifact/androidx.coordinatorlayout/coordinatorlayout
implementation "androidx.coordinatorlayout:coordinatorlayout:$coordinatorlayout_version"

@ -0,0 +1,45 @@
/*
* 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 android.graphics.Point;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
/**
* Provides support for auto-scrolling a view.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
public abstract class AutoScroller {
/**
* Resets state of the scroller. Call this when the user activity that is driving
* auto-scrolling is done.
*/
public abstract void reset();
/**
* Processes a new input location.
* @param location
*/
public abstract void scroll(@NonNull Point location);
}

@ -0,0 +1,139 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* Provides a means of controlling when and where band selection can be initiated.
*
* <p>
* Two default implementations are provided: {@link EmptyArea}, and {@link NonDraggableArea}.
*
* @see SelectionTracker.Builder#withBandPredicate(BandPredicate)
*/
public abstract class BandPredicate {
/**
* @return true if band selection can be initiated in response to the {@link MotionEvent}.
*/
public abstract boolean canInitiate(MotionEvent e);
@SuppressWarnings("WeakerAccess") /* synthetic access */
static boolean hasSupportedLayoutManager(@NonNull RecyclerView recyclerView) {
RecyclerView.LayoutManager lm = recyclerView.getLayoutManager();
return lm instanceof GridLayoutManager
|| lm instanceof LinearLayoutManager;
}
/**
* A BandPredicate that allows initiation of band selection only in areas of RecyclerView
* that map to {@link RecyclerView#NO_POSITION}. In most cases, this will be the empty areas
* between views.
*
* <p>
* Use this implementation to permit band selection only in empty areas
* surrounding view items. But be advised that if there is no empy area around
* view items, band selection cannot be initiated.
*/
public static final class EmptyArea extends BandPredicate {
private final RecyclerView mRecyclerView;
/**
* @param recyclerView the owner RecyclerView
*/
public EmptyArea(@NonNull RecyclerView recyclerView) {
checkArgument(recyclerView != null);
mRecyclerView = recyclerView;
}
@Override
public boolean canInitiate(@NonNull MotionEvent e) {
if (!hasSupportedLayoutManager(mRecyclerView)
|| mRecyclerView.hasPendingAdapterUpdates()) {
return false;
}
View itemView = mRecyclerView.findChildViewUnder(e.getX(), e.getY());
int position = itemView != null
? mRecyclerView.getChildAdapterPosition(itemView)
: RecyclerView.NO_POSITION;
return position == RecyclerView.NO_POSITION;
}
}
/**
* A BandPredicate that allows initiation of band selection in any area that is not
* draggable as determined by consulting
* {@link ItemDetailsLookup.ItemDetails#inDragRegion(MotionEvent)}. By default empty
* areas (those with a position that maps to {@link RecyclerView#NO_POSITION}
* are considered non-draggable.
*
* <p>
* Use this implementation in order to permit band selection in
* otherwise empty areas of a View. This is useful especially in
* list layouts where there is no empty space surrounding the list items,
* and individual list items may contain extra white space (like
* in a list of varying length words).
*
* @see ItemDetailsLookup.ItemDetails#inDragRegion(MotionEvent)
*/
public static final class NonDraggableArea extends BandPredicate {
private final RecyclerView mRecyclerView;
private final ItemDetailsLookup mDetailsLookup;
/**
* Creates a new instance.
*
* @param recyclerView the owner RecyclerView
* @param detailsLookup provides access to item details.
*/
public NonDraggableArea(
@NonNull RecyclerView recyclerView, @NonNull ItemDetailsLookup detailsLookup) {
checkArgument(recyclerView != null);
checkArgument(detailsLookup != null);
mRecyclerView = recyclerView;
mDetailsLookup = detailsLookup;
}
@Override
public boolean canInitiate(@NonNull MotionEvent e) {
if (!hasSupportedLayoutManager(mRecyclerView)
|| mRecyclerView.hasPendingAdapterUpdates()) {
return false;
}
@Nullable ItemDetailsLookup.ItemDetails details = mDetailsLookup.getItemDetails(e);
return (details == null) || !details.inDragRegion(e);
}
}
}

@ -0,0 +1,356 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import static androidx.recyclerview.selection.Shared.VERBOSE;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
import android.view.MotionEvent;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import java.util.Set;
/**
* Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
* instance. This class is responsible for rendering a band overlay and manipulating selection
* status of the items it intersects with.
*
* <p>
* Given the recycling nature of RecyclerView items that have scrolled off-screen would not
* be selectable with a band that itself was partially rendered off-screen. To address this,
* BandSelectionController builds a model of the list/grid information presented by RecyclerView as
* the user interacts with items using their pointer (and the band). Selectable items that intersect
* with the band, both on and off screen, are selected on pointer up.
*
* @see SelectionTracker.Builder#withPointerTooltypes(int...) for details on the specific
* tooltypes routed to this helper.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
class BandSelectionHelper<K> implements OnItemTouchListener {
static final String TAG = "BandSelectionHelper";
static final boolean DEBUG = false;
private final BandHost mHost;
private final ItemKeyProvider<K> mKeyProvider;
@SuppressWarnings("WeakerAccess") /* synthetic access */
final SelectionTracker<K> mSelectionTracker;
private final BandPredicate mBandPredicate;
private final FocusDelegate<K> mFocusDelegate;
private final OperationMonitor mLock;
private final AutoScroller mScroller;
private final GridModel.SelectionObserver mGridObserver;
private @Nullable Point mCurrentPosition;
private @Nullable Point mOrigin;
private @Nullable GridModel mModel;
/**
* See {@link BandSelectionHelper#create}.
*/
BandSelectionHelper(
@NonNull BandHost host,
@NonNull AutoScroller scroller,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull SelectionTracker<K> selectionTracker,
@NonNull BandPredicate bandPredicate,
@NonNull FocusDelegate<K> focusDelegate,
@NonNull OperationMonitor lock) {
checkArgument(host != null);
checkArgument(scroller != null);
checkArgument(keyProvider != null);
checkArgument(selectionTracker != null);
checkArgument(bandPredicate != null);
checkArgument(focusDelegate != null);
checkArgument(lock != null);
mHost = host;
mKeyProvider = keyProvider;
mSelectionTracker = selectionTracker;
mBandPredicate = bandPredicate;
mFocusDelegate = focusDelegate;
mLock = lock;
mHost.addOnScrollListener(
new OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
BandSelectionHelper.this.onScrolled(recyclerView, dx, dy);
}
});
mScroller = scroller;
mGridObserver = new GridModel.SelectionObserver<K>() {
@Override
public void onSelectionChanged(Set<K> updatedSelection) {
mSelectionTracker.setProvisionalSelection(updatedSelection);
}
};
}
/**
* Creates a new instance.
*
* @return new BandSelectionHelper instance.
*/
static <K> BandSelectionHelper create(
@NonNull RecyclerView recyclerView,
@NonNull AutoScroller scroller,
@DrawableRes int bandOverlayId,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull SelectionTracker<K> selectionTracker,
@NonNull SelectionPredicate<K> selectionPredicate,
@NonNull BandPredicate bandPredicate,
@NonNull FocusDelegate<K> focusDelegate,
@NonNull OperationMonitor lock) {
return new BandSelectionHelper<>(
new DefaultBandHost<>(recyclerView, bandOverlayId, keyProvider, selectionPredicate),
scroller,
keyProvider,
selectionTracker,
bandPredicate,
focusDelegate,
lock);
}
@VisibleForTesting
boolean isActive() {
boolean active = mModel != null;
if (DEBUG && active) {
mLock.checkStarted();
}
return active;
}
/**
* Clients must call reset when there are any material changes to the layout of items
* in RecyclerView.
*/
void reset() {
if (!isActive()) {
return;
}
mHost.hideBand();
if (mModel != null) {
mModel.stopCapturing();
mModel.onDestroy();
}
mModel = null;
mOrigin = null;
mScroller.reset();
mLock.stop();
}
@VisibleForTesting
boolean shouldStart(@NonNull MotionEvent e) {
// b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
// unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
// mouse moves.
return MotionEvents.isPrimaryMouseButtonPressed(e)
&& MotionEvents.isActionMove(e)
&& mBandPredicate.canInitiate(e)
&& !isActive();
}
@VisibleForTesting
boolean shouldStop(@NonNull MotionEvent e) {
return isActive()
&& (MotionEvents.isActionUp(e)
|| MotionEvents.isActionPointerUp(e)
|| MotionEvents.isActionCancel(e));
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
if (shouldStart(e)) {
startBandSelect(e);
} else if (shouldStop(e)) {
endBandSelect();
}
return isActive();
}
/**
* Processes a MotionEvent by starting, ending, or resizing the band select overlay.
*/
@Override
public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
if (shouldStop(e)) {
endBandSelect();
return;
}
// We shouldn't get any events in this method when band select is not active,
// but it turns some guests show up late to the party.
// Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
if (!isActive()) {
return;
}
if (DEBUG) {
checkArgument(MotionEvents.isActionMove(e));
checkState(mModel != null);
}
mCurrentPosition = MotionEvents.getOrigin(e);
mModel.resizeSelection(mCurrentPosition);
resizeBand();
mScroller.scroll(mCurrentPosition);
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
/**
* Starts band select by adding the drawable to the RecyclerView's overlay.
*/
private void startBandSelect(@NonNull MotionEvent e) {
checkState(!isActive());
if (!MotionEvents.isCtrlKeyPressed(e)) {
mSelectionTracker.clearSelection();
}
Point origin = MotionEvents.getOrigin(e);
if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
mModel = mHost.createGridModel();
mModel.addOnSelectionChangedListener(mGridObserver);
mLock.start();
mFocusDelegate.clearFocus();
mOrigin = origin;
// NOTE: Pay heed that resizeBand modifies the y coordinates
// in onScrolled. Not sure if model expects this. If not
// it should be defending against this.
mModel.startCapturing(mOrigin);
}
/**
* Resizes the band select rectangle by using the origin and the current pointer position as
* two opposite corners of the selection.
*/
private void resizeBand() {
Rect bounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
Math.min(mOrigin.y, mCurrentPosition.y),
Math.max(mOrigin.x, mCurrentPosition.x),
Math.max(mOrigin.y, mCurrentPosition.y));
if (VERBOSE) Log.v(TAG, "Resizing band! " + bounds);
mHost.showBand(bounds);
}
/**
* Ends band select by removing the overlay.
*/
private void endBandSelect() {
if (DEBUG) {
Log.d(TAG, "Ending band select.");
checkState(mModel != null);
}
// TODO: Currently when a band select operation ends outside
// of an item (e.g. in the empty area between items),
// getPositionNearestOrigin may return an unselected item.
// Since the point of this code is to establish the
// anchor point for subsequent range operations (SHIFT+CLICK)
// we really want to do a better job figuring out the last
// item selected (and nearest to the cursor).
int firstSelected = mModel.getPositionNearestOrigin();
if (firstSelected != GridModel.NOT_SET
&& mSelectionTracker.isSelected(mKeyProvider.getKey(firstSelected))) {
// Establish the band selection point as range anchor. This
// allows touch and keyboard based selection activities
// to be based on the band selection anchor point.
mSelectionTracker.anchorRange(firstSelected);
}
mSelectionTracker.mergeProvisionalSelection();
reset();
}
/**
* @see OnScrollListener
*/
@SuppressWarnings("WeakerAccess") /* synthetic access */
void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
if (!isActive()) {
return;
}
// Adjust the y-coordinate of the origin the opposite number of pixels so that the
// origin remains in the same place relative to the view's items.
mOrigin.y -= dy;
resizeBand();
}
/**
* Provides functionality for BandController. Exists primarily to tests that are
* fully isolated from RecyclerView.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
abstract static class BandHost<K> {
/**
* Returns a new GridModel instance.
*/
abstract GridModel<K> createGridModel();
/**
* Show the band covering the bounds.
*
* @param bounds The boundaries of the band to show.
*/
abstract void showBand(@NonNull Rect bounds);
/**
* Hide the band.
*/
abstract void hideBand();
/**
* Add a listener to be notified on scroll events.
*
* @param listener
*/
abstract void addOnScrollListener(@NonNull OnScrollListener listener);
}
}

@ -0,0 +1,156 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.graphics.Canvas;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.view.View;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.ItemDecoration;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
/**
* RecyclerView backed {@link BandSelectionHelper.BandHost}.
*/
final class DefaultBandHost<K> extends GridModel.GridHost<K> {
private static final Rect NILL_RECT = new Rect(0, 0, 0, 0);
private final RecyclerView mRecyclerView;
private final Drawable mBand;
private final ItemKeyProvider<K> mKeyProvider;
private final SelectionPredicate<K> mSelectionPredicate;
DefaultBandHost(
@NonNull RecyclerView recyclerView,
@DrawableRes int bandOverlayId,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull SelectionPredicate<K> selectionPredicate) {
checkArgument(recyclerView != null);
mRecyclerView = recyclerView;
mBand = mRecyclerView.getContext().getResources().getDrawable(bandOverlayId);
checkArgument(mBand != null);
checkArgument(keyProvider != null);
checkArgument(selectionPredicate != null);
mKeyProvider = keyProvider;
mSelectionPredicate = selectionPredicate;
mRecyclerView.addItemDecoration(
new ItemDecoration() {
@Override
public void onDrawOver(
Canvas canvas,
RecyclerView unusedParent,
RecyclerView.State unusedState) {
DefaultBandHost.this.onDrawBand(canvas);
}
});
}
@Override
GridModel<K> createGridModel() {
return new GridModel<>(this, mKeyProvider, mSelectionPredicate);
}
@Override
int getAdapterPositionAt(int index) {
return mRecyclerView.getChildAdapterPosition(mRecyclerView.getChildAt(index));
}
@Override
void addOnScrollListener(@NonNull OnScrollListener listener) {
mRecyclerView.addOnScrollListener(listener);
}
@Override
void removeOnScrollListener(@NonNull OnScrollListener listener) {
mRecyclerView.removeOnScrollListener(listener);
}
@Override
Point createAbsolutePoint(@NonNull Point relativePoint) {
return new Point(relativePoint.x + mRecyclerView.computeHorizontalScrollOffset(),
relativePoint.y + mRecyclerView.computeVerticalScrollOffset());
}
@Override
Rect getAbsoluteRectForChildViewAt(int index) {
final View child = mRecyclerView.getChildAt(index);
final Rect childRect = new Rect();
child.getHitRect(childRect);
childRect.left += mRecyclerView.computeHorizontalScrollOffset();
childRect.right += mRecyclerView.computeHorizontalScrollOffset();
childRect.top += mRecyclerView.computeVerticalScrollOffset();
childRect.bottom += mRecyclerView.computeVerticalScrollOffset();
return childRect;
}
@Override
int getVisibleChildCount() {
return mRecyclerView.getChildCount();
}
@Override
int getColumnCount() {
RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
if (layoutManager instanceof GridLayoutManager) {
return ((GridLayoutManager) layoutManager).getSpanCount();
}
// Otherwise, it is a list with 1 column.
return 1;
}
@Override
void showBand(@NonNull Rect rect) {
mBand.setBounds(rect);
// TODO: mRecyclerView.invalidateItemDecorations() should work, but it isn't currently.
// NOTE: That without invalidating rv, the band only gets updated
// when the pointer moves off a the item view into "NO_POSITION" territory.
mRecyclerView.invalidate();
}
@Override
void hideBand() {
mBand.setBounds(NILL_RECT);
// TODO: mRecyclerView.invalidateItemDecorations() should work, but it isn't currently.
mRecyclerView.invalidate();
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void onDrawBand(@NonNull Canvas c) {
mBand.draw(c);
}
@Override
boolean hasView(int pos) {
return mRecyclerView.findViewHolderForAdapterPosition(pos) != null;
}
}

@ -0,0 +1,589 @@
/*
* 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}.
*
* <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_GROUP)
public class DefaultSelectionTracker<K> extends SelectionTracker<K> {
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> 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 keyProvider,
@NonNull SelectionPredicate 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 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<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()) {
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<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
AdapterDataObserver getAdapterDataObserver() {
return mAdapterObserver;
}
@SuppressWarnings("WeakerAccess") /* 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(@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();
}
}
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);
}
}
@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
@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<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();
}
}
}

@ -0,0 +1,105 @@
/*
* 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.annotation.VisibleForTesting.PACKAGE_PRIVATE;
import static androidx.core.util.Preconditions.checkArgument;
import static androidx.recyclerview.selection.Shared.VERBOSE;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.recyclerview.widget.RecyclerView;
/**
* Provides the necessary glue to notify RecyclerView when selection data changes,
* and to notify SelectionTracker when the underlying RecyclerView.Adapter data changes.
*
* This strict decoupling is necessary to permit a single SelectionTracker to work
* with multiple RecyclerView instances. This may be necessary when multiple
* different views of data are presented to the user.
*
* @hide
*/
@RestrictTo(LIBRARY_GROUP)
@VisibleForTesting(otherwise = PACKAGE_PRIVATE)
public class EventBridge {
private static final String TAG = "EventsRelays";
/**
* Installs the event bridge for on the supplied adapter/helper.
*
* @param adapter
* @param selectionTracker
* @param keyProvider
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public static <K> void install(
@NonNull RecyclerView.Adapter<?> adapter,
@NonNull SelectionTracker<K> selectionTracker,
@NonNull ItemKeyProvider<K> keyProvider) {
// setup bridges to relay selection and adapter events
new TrackerToAdapterBridge<>(selectionTracker, keyProvider, adapter);
adapter.registerAdapterDataObserver(selectionTracker.getAdapterDataObserver());
}
private static final class TrackerToAdapterBridge<K>
extends SelectionTracker.SelectionObserver<K> {
private final ItemKeyProvider<K> mKeyProvider;
private final RecyclerView.Adapter<?> mAdapter;
TrackerToAdapterBridge(
@NonNull SelectionTracker<K> selectionTracker,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull RecyclerView.Adapter<?> adapter) {
selectionTracker.addObserver(this);
checkArgument(keyProvider != null);
checkArgument(adapter != null);
mKeyProvider = keyProvider;
mAdapter = adapter;
}
/**
* Called when state of an item has been changed.
*/
@Override
public void onItemStateChanged(@NonNull K key, boolean selected) {
int position = mKeyProvider.getPosition(key);
if (VERBOSE) Log.v(TAG, "ITEM " + key + " CHANGED at pos: " + position);
if (position < 0) {
Log.w(TAG, "Item change notification received for unknown item: " + key);
return;
}
mAdapter.notifyItemChanged(position, SelectionTracker.SELECTION_CHANGED_MARKER);
}
}
private EventBridge() {
}
}

@ -0,0 +1,72 @@
/*
* 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 androidx.annotation.NonNull;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import androidx.recyclerview.widget.RecyclerView;
/**
* Override methods in this class to provide application specific behaviors
* related to focusing item.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract class FocusDelegate<K> {
static <K> FocusDelegate<K> dummy() {
return new FocusDelegate<K>() {
@Override
public void focusItem(@NonNull ItemDetails<K> item) {
}
@Override
public boolean hasFocusedItem() {
return false;
}
@Override
public int getFocusedPosition() {
return RecyclerView.NO_POSITION;
}
@Override
public void clearFocus() {
}
};
}
/**
* If environment supports focus, focus {@code item}.
*/
public abstract void focusItem(@NonNull ItemDetails<K> item);
/**
* @return true if there is a focused item.
*/
public abstract boolean hasFocusedItem();
/**
* @return the position of the currently focused item, if any.
*/
public abstract int getFocusedPosition();
/**
* If the environment supports focus and something is focused, unfocus it.
*/
public abstract void clearFocus();
}

@ -0,0 +1,104 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.view.GestureDetector.OnDoubleTapListener;
import android.view.GestureDetector.OnGestureListener;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* GestureRouter is responsible for routing gestures detected by a GestureDetector
* to registered handlers. The primary function is to divide events by tool-type
* allowing handlers to cleanly implement tool-type specific policies.
*
* @param <T> listener type. Must extend OnGestureListener & OnDoubleTapListener.
*/
final class GestureRouter<T extends OnGestureListener & OnDoubleTapListener>
implements OnGestureListener, OnDoubleTapListener {
private final ToolHandlerRegistry<T> mDelegates;
GestureRouter(@NonNull T defaultDelegate) {
checkArgument(defaultDelegate != null);
mDelegates = new ToolHandlerRegistry<>(defaultDelegate);
}
GestureRouter() {
this((T) new SimpleOnGestureListener());
}
/**
* @param toolType
* @param delegate the delegate, or null to unregister.
*/
public void register(int toolType, @Nullable T delegate) {
mDelegates.set(toolType, delegate);
}
@Override
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
return mDelegates.get(e).onSingleTapConfirmed(e);
}
@Override
public boolean onDoubleTap(@NonNull MotionEvent e) {
return mDelegates.get(e).onDoubleTap(e);
}
@Override
public boolean onDoubleTapEvent(@NonNull MotionEvent e) {
return mDelegates.get(e).onDoubleTapEvent(e);
}
@Override
public boolean onDown(@NonNull MotionEvent e) {
return mDelegates.get(e).onDown(e);
}
@Override
public void onShowPress(@NonNull MotionEvent e) {
mDelegates.get(e).onShowPress(e);
}
@Override
public boolean onSingleTapUp(@NonNull MotionEvent e) {
return mDelegates.get(e).onSingleTapUp(e);
}
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
float distanceX, float distanceY) {
return mDelegates.get(e2).onScroll(e1, e2, distanceX, distanceY);
}
@Override
public void onLongPress(@NonNull MotionEvent e) {
mDelegates.get(e).onLongPress(e);
}
@Override
public boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
float velocityX, float velocityY) {
return mDelegates.get(e2).onFling(e1, e2, velocityX, velocityY);
}
}

@ -0,0 +1,319 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import android.graphics.Point;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
/**
* GestureSelectionHelper provides logic that interprets a combination
* of motions and gestures in order to provide gesture driven selection support
* when used in conjunction with RecyclerView and other classes in the ReyclerView
* selection support package.
*/
final class GestureSelectionHelper implements OnItemTouchListener {
private static final String TAG = "GestureSelectionHelper";
private final SelectionTracker<?> mSelectionMgr;
private final ItemDetailsLookup<?> mDetailsLookup;
private final AutoScroller mScroller;
private final ViewDelegate mView;
private final OperationMonitor mLock;
private int mLastStartedItemPos = RecyclerView.NO_POSITION;
private boolean mStarted = false;
/**
* See {@link GestureSelectionHelper#create} for convenience
* method.
*/
GestureSelectionHelper(
@NonNull SelectionTracker<?> selectionTracker,
@NonNull ItemDetailsLookup<?> detailsLookup,
@NonNull ViewDelegate view,
@NonNull AutoScroller scroller,
@NonNull OperationMonitor lock) {
checkArgument(selectionTracker != null);
checkArgument(detailsLookup != null);
checkArgument(view != null);
checkArgument(scroller != null);
checkArgument(lock != null);
mSelectionMgr = selectionTracker;
mDetailsLookup = detailsLookup;
mView = view;
mScroller = scroller;
mLock = lock;
}
/**
* Explicitly kicks off a gesture multi-select.
*/
void start() {
checkState(!mStarted);
// See: b/70518185. It appears start() is being called via onLongPress
// even though we never received an intial handleInterceptedDownEvent
// where we would usually initialize mLastStartedItemPos.
if (mLastStartedItemPos == RecyclerView.NO_POSITION) {
Log.w(TAG, "Illegal state. Can't start without valid mLastStartedItemPos.");
return;
}
// Partner code in MotionInputHandler ensures items
// are selected and range established prior to
// start being called.
// Verify the truth of that statement here
// to make the implicit coupling less of a time bomb.
checkState(mSelectionMgr.isRangeActive());
mLock.checkStopped();
mStarted = true;
mLock.start();
}
@Override
/** @hide */
public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
if (MotionEvents.isMouseEvent(e)) {
if (Shared.DEBUG) Log.w(TAG, "Unexpected Mouse event. Check configuration.");
}
// TODO(b/109808552): It seems that mLastStartedItemPos should likely be set as a method
// parameter in start().
if (e.getActionMasked() == MotionEvent.ACTION_DOWN) {
if (mDetailsLookup.getItemDetails(e) != null) {
mLastStartedItemPos = mView.getItemUnder(e);
}
}
// See handleTouch(MotionEvent) javadoc for explanation as to why this is correct.
return handleTouch(e);
}
@Override
/** @hide */
public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
// See handleTouch(MotionEvent) javadoc for explanation as to why this is correct.
handleTouch(e);
}
/**
* If selection has started, will handle all appropriate types of MotionEvents and will return
* true if this OnItemTouchListener should start intercepting the rest of the MotionEvents.
*
* <p>This code, and the fact that this method is used by both OnInterceptTouchEvent and
* OnTouchEvent, is correct and valid because:
* <ol>
* <li>MotionEvents that aren't ACTION_DOWN are only ever passed to either onInterceptTouchEvent
* or onTouchEvent; never to both. The MotionEvents we are handling in this method are not
* ACTION_DOWN, and therefore, its appropriate that both the onInterceptTouchEvent and
* onTouchEvent code paths cross this method.
* <li>This method returns true when we want to intercept MotionEvents. OnInterceptTouchEvent
* uses that information to determine its own return, and OnMotionEvent doesn't have a return
* so this methods return value is irrelevant to it.
* </ol>
*/
private boolean handleTouch(MotionEvent e) {
if (!mStarted) {
return false;
}
switch (e.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
handleMoveEvent(e);
return true;
case MotionEvent.ACTION_UP:
handleUpEvent();
return true;
case MotionEvent.ACTION_CANCEL:
handleCancelEvent();
return true;
}
return false;
}
@Override
/** @hide */
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
// Called when ACTION_UP event is to be handled.
// Essentially, since this means all gesture movement is over, reset everything and apply
// provisional selection.
private void handleUpEvent() {
mSelectionMgr.mergeProvisionalSelection();
endSelection();
if (mLastStartedItemPos != RecyclerView.NO_POSITION) {
mSelectionMgr.startRange(mLastStartedItemPos);
}
}
// Called when ACTION_CANCEL event is to be handled.
// This means this gesture selection is aborted, so reset everything and abandon provisional
// selection.
private void handleCancelEvent() {
mSelectionMgr.clearProvisionalSelection();
endSelection();
}
private void endSelection() {
checkState(mStarted);
mLastStartedItemPos = RecyclerView.NO_POSITION;
mStarted = false;
mScroller.reset();
mLock.stop();
}
// Call when an intercepted ACTION_MOVE event is passed down.
// At this point, we are sure user wants to gesture multi-select.
private void handleMoveEvent(@NonNull MotionEvent e) {
Point lastInterceptedPoint = MotionEvents.getOrigin(e);
int lastGlidedItemPos = mView.getLastGlidedItemPosition(e);
if (lastGlidedItemPos != RecyclerView.NO_POSITION) {
extendSelection(lastGlidedItemPos);
}
mScroller.scroll(lastInterceptedPoint);
}
// It's possible for events to go over the top/bottom of the RecyclerView.
// We want to get a Y-coordinate within the RecyclerView so we can find the childView underneath
// correctly.
@SuppressWarnings("WeakerAccess") /* synthetic access */
static float getInboundY(float max, float y) {
if (y < 0f) {
return 0f;
} else if (y > max) {
return max;
}
return y;
}
/* Given the end position, select everything in-between.
* @param endPos The adapter position of the end item.
*/
private void extendSelection(int endPos) {
mSelectionMgr.extendProvisionalRange(endPos);
}
/**
* Returns a new instance of GestureSelectionHelper.
*/
static GestureSelectionHelper create(
@NonNull SelectionTracker<?> selectionMgr,
@NonNull ItemDetailsLookup<?> detailsLookup,
@NonNull RecyclerView recyclerView,
@NonNull AutoScroller scroller,
@NonNull OperationMonitor lock) {
return new GestureSelectionHelper(
selectionMgr,
detailsLookup,
new RecyclerViewDelegate(recyclerView),
scroller,
lock);
}
@VisibleForTesting
abstract static class ViewDelegate {
abstract int getHeight();
abstract int getItemUnder(@NonNull MotionEvent e);
abstract int getLastGlidedItemPosition(@NonNull MotionEvent e);
}
@VisibleForTesting
static final class RecyclerViewDelegate extends ViewDelegate {
private final RecyclerView mRecyclerView;
RecyclerViewDelegate(@NonNull RecyclerView recyclerView) {
checkArgument(recyclerView != null);
mRecyclerView = recyclerView;
}
@Override
int getHeight() {
return mRecyclerView.getHeight();
}
@Override
int getItemUnder(@NonNull MotionEvent e) {
View child = mRecyclerView.findChildViewUnder(e.getX(), e.getY());
return child != null
? mRecyclerView.getChildAdapterPosition(child)
: RecyclerView.NO_POSITION;
}
@Override
int getLastGlidedItemPosition(@NonNull MotionEvent e) {
// If user has moved his pointer to the bottom-right empty pane (ie. to the right of the
// last item of the recycler view), we would want to set that as the currentItemPos
View lastItem = mRecyclerView.getLayoutManager()
.getChildAt(mRecyclerView.getLayoutManager().getChildCount() - 1);
int direction = ViewCompat.getLayoutDirection(mRecyclerView);
final boolean pastLastItem = isPastLastItem(lastItem.getTop(),
lastItem.getLeft(),
lastItem.getRight(),
e,
direction);
// Since views get attached & detached from RecyclerView,
// {@link LayoutManager#getChildCount} can return a different number from the actual
// number
// of items in the adapter. Using the adapter is the for sure way to get the actual last
// item position.
final float inboundY = getInboundY(mRecyclerView.getHeight(), e.getY());
return (pastLastItem) ? mRecyclerView.getAdapter().getItemCount() - 1
: mRecyclerView.getChildAdapterPosition(
mRecyclerView.findChildViewUnder(e.getX(), inboundY));
}
/*
* Check to see if MotionEvent if past a particular item, i.e. to the right or to the bottom
* of the item.
* For RTL, it would to be to the left or to the bottom of the item.
*/
@VisibleForTesting
static boolean isPastLastItem(
int top, int left, int right, @NonNull MotionEvent e, int direction) {
if (direction == View.LAYOUT_DIRECTION_LTR) {
return e.getX() > right && e.getY() > top;
} else {
return e.getX() < left && e.getY() > top;
}
}
}
}

@ -0,0 +1,795 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.graphics.Point;
import android.graphics.Rect;
import android.util.Log;
import android.util.SparseArray;
import android.util.SparseBooleanArray;
import android.util.SparseIntArray;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Provides a band selection item model for views within a RecyclerView. This class queries the
* RecyclerView to determine where its items are placed; then, once band selection is underway,
* it alerts listeners of which items are covered by the selections.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
final class GridModel<K> {
// Magical value indicating that a value has not been previously set. primitive null :)
static final int NOT_SET = -1;
// Enum values used to determine the corner at which the origin is located within the
private static final int UPPER = 0x00;
private static final int LOWER = 0x01;
private static final int LEFT = 0x00;
private static final int RIGHT = 0x02;
private static final int UPPER_LEFT = UPPER | LEFT;
private static final int UPPER_RIGHT = UPPER | RIGHT;
private static final int LOWER_LEFT = LOWER | LEFT;
private static final int LOWER_RIGHT = LOWER | RIGHT;
private final GridHost<K> mHost;
private final ItemKeyProvider<K> mKeyProvider;
private final SelectionPredicate<K> mSelectionPredicate;
private final List<SelectionObserver> mOnSelectionChangedListeners = new ArrayList<>();
// Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed
// by their y-offset. For example, if the first column of the view starts at an x-value of 5,
// mColumns.get(5) would return an array of positions in that column. Within that array, the
// value for key y is the adapter position for the item whose y-offset is y.
private final SparseArray<SparseIntArray> mColumns = new SparseArray<>();
// List of limits along the x-axis (columns).
// This list is sorted from furthest left to furthest right.
private final List<Limits> mColumnBounds = new ArrayList<>();
// List of limits along the y-axis (rows). Note that this list only contains items which
// have been in the viewport.
private final List<Limits> mRowBounds = new ArrayList<>();
// The adapter positions which have been recorded so far.
private final SparseBooleanArray mKnownPositions = new SparseBooleanArray();
// Array passed to registered OnSelectionChangedListeners. One array is created and reused
// throughout the lifetime of the object.
private final Set<K> mSelection = new HashSet<>();
// The current pointer (in absolute positioning from the top of the view).
private Point mPointer;
// The bounds of the band selection.
private RelativePoint mRelOrigin;
private RelativePoint mRelPointer;
private boolean mIsActive;
// Tracks where the band select originated from. This is used to determine where selections
// should expand from when Shift+click is used.
private int mPositionNearestOrigin = NOT_SET;
private final OnScrollListener mScrollListener;
GridModel(
GridHost host,
ItemKeyProvider<K> keyProvider,
SelectionPredicate<K> selectionPredicate) {
checkArgument(host != null);
checkArgument(keyProvider != null);
checkArgument(selectionPredicate != null);
mHost = host;
mKeyProvider = keyProvider;
mSelectionPredicate = selectionPredicate;
mScrollListener = new OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
GridModel.this.onScrolled(recyclerView, dx, dy);
}
};
mHost.addOnScrollListener(mScrollListener);
}
/**
* Start a band select operation at the given point.
*
* @param relativeOrigin The origin of the band select operation, relative to the viewport.
* For example, if the view is scrolled to the bottom, the top-left of
* the
* viewport
* would have a relative origin of (0, 0), even though its absolute point
* has a higher
* y-value.
*/
void startCapturing(Point relativeOrigin) {
recordVisibleChildren();
if (isEmpty()) {
// The selection band logic works only if there is at least one visible child.
return;
}
mIsActive = true;
mPointer = mHost.createAbsolutePoint(relativeOrigin);
mRelOrigin = createRelativePoint(mPointer);
mRelPointer = createRelativePoint(mPointer);
computeCurrentSelection();
notifySelectionChanged();
}
/**
* Ends the band selection.
*/
void stopCapturing() {
mIsActive = false;
}
/**
* Resizes the selection by adjusting the pointer (i.e., the corner of the selection
* opposite the origin.
*
* @param relativePointer The pointer (opposite of the origin) of the band select operation,
* relative to the viewport. For example, if the view is scrolled to the
* bottom, the
* top-left of the viewport would have a relative origin of (0, 0), even
* though its
* absolute point has a higher y-value.
*/
void resizeSelection(Point relativePointer) {
mPointer = mHost.createAbsolutePoint(relativePointer);
updateModel();
}
/**
* @return The adapter position for the item nearest the origin corresponding to the latest
* band select operation, or NOT_SET if the selection did not cover any items.
*/
int getPositionNearestOrigin() {
return mPositionNearestOrigin;
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (!mIsActive) {
return;
}
mPointer.x += dx;
mPointer.y += dy;
recordVisibleChildren();
updateModel();
}
/**
* Queries the view for all children and records their location metadata.
*/
private void recordVisibleChildren() {
for (int i = 0; i < mHost.getVisibleChildCount(); i++) {
int adapterPosition = mHost.getAdapterPositionAt(i);
// Sometimes the view is not attached, as we notify the multi selection manager
// synchronously, while views are attached asynchronously. As a result items which
// are in the adapter may not actually have a corresponding view (yet).
if (mHost.hasView(adapterPosition)
&& mSelectionPredicate.canSetStateAtPosition(adapterPosition, true)
&& !mKnownPositions.get(adapterPosition)) {
mKnownPositions.put(adapterPosition, true);
recordItemData(mHost.getAbsoluteRectForChildViewAt(i), adapterPosition);
}
}
}
/**
* Checks if there are any recorded children.
*/
private boolean isEmpty() {
return mColumnBounds.size() == 0 || mRowBounds.size() == 0;
}
/**
* Updates the limits lists and column map with the given item metadata.
*
* @param absoluteChildRect The absolute rectangle for the child view being processed.
* @param adapterPosition The position of the child view being processed.
*/
private void recordItemData(Rect absoluteChildRect, int adapterPosition) {
if (mColumnBounds.size() != mHost.getColumnCount()) {
// If not all x-limits have been recorded, record this one.
recordLimits(
mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right));
}
recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom));
SparseIntArray columnList = mColumns.get(absoluteChildRect.left);
if (columnList == null) {
columnList = new SparseIntArray();
mColumns.put(absoluteChildRect.left, columnList);
}
columnList.put(absoluteChildRect.top, adapterPosition);
}
/**
* Ensures limits exists within the sorted list limitsList, and adds it to the list if it
* does not exist.
*/
private void recordLimits(List<Limits> limitsList, Limits limits) {
int index = Collections.binarySearch(limitsList, limits);
if (index < 0) {
limitsList.add(~index, limits);
}
}
/**
* Handles a moved pointer; this function determines whether the pointer movement resulted
* in a selection change and, if it has, notifies listeners of this change.
*/
private void updateModel() {
RelativePoint old = mRelPointer;
mRelPointer = createRelativePoint(mPointer);
if (old != null && mRelPointer.equals(old)) {
return;
}
computeCurrentSelection();
notifySelectionChanged();
}
/**
* Computes the currently-selected items.
*/
private void computeCurrentSelection() {
if (areItemsCoveredByBand(mRelPointer, mRelOrigin)) {
updateSelection(computeBounds());
} else {
mSelection.clear();
mPositionNearestOrigin = NOT_SET;
}
}
/**
* Notifies all listeners of a selection change. Note that this function simply passes
* mSelection, so computeCurrentSelection() should be called before this
* function.
*/
private void notifySelectionChanged() {
for (SelectionObserver listener : mOnSelectionChangedListeners) {
listener.onSelectionChanged(mSelection);
}
}
/**
* @param rect Rectangle including all covered items.
*/
private void updateSelection(Rect rect) {
int columnStart =
Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left));
checkArgument(columnStart >= 0, "Rect doesn't intesect any known column.");
int columnEnd = columnStart;
for (int i = columnStart; i < mColumnBounds.size()
&& mColumnBounds.get(i).lowerLimit <= rect.right; i++) {
columnEnd = i;
}
int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top));
if (rowStart < 0) {
mPositionNearestOrigin = NOT_SET;
return;
}
int rowEnd = rowStart;
for (int i = rowStart; i < mRowBounds.size()
&& mRowBounds.get(i).lowerLimit <= rect.bottom; i++) {
rowEnd = i;
}
updateSelection(columnStart, columnEnd, rowStart, rowEnd);
}
/**
* Computes the selection given the previously-computed start- and end-indices for each
* row and column.
*/
private void updateSelection(
int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) {
if (BandSelectionHelper.DEBUG) {
Log.d(BandSelectionHelper.TAG, String.format(
"updateSelection: %d, %d, %d, %d",
columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex));
}
mSelection.clear();
for (int column = columnStartIndex; column <= columnEndIndex; column++) {
SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit);
for (int row = rowStartIndex; row <= rowEndIndex; row++) {
// The default return value for SparseIntArray.get is 0, which is a valid
// position. Use a sentry value to prevent erroneously selecting item 0.
final int rowKey = mRowBounds.get(row).lowerLimit;
int position = items.get(rowKey, NOT_SET);
if (position != NOT_SET) {
K key = mKeyProvider.getKey(position);
if (key != null) {
// The adapter inserts items for UI layout purposes that aren't
// associated with files. Those will have a null model ID.
// Don't select them.
if (canSelect(key)) {
mSelection.add(key);
}
}
if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex,
row, rowStartIndex, rowEndIndex)) {
// If this is the position nearest the origin, record it now so that it
// can be returned by endSelection() later.
mPositionNearestOrigin = position;
}
}
}
}
}
private boolean canSelect(K key) {
return mSelectionPredicate.canSetStateForKey(key, true);
}
/**
* @return Returns true if the position is the nearest to the origin, or, in the case of the
* lower-right corner, whether it is possible that the position is the nearest to the
* origin. See comment below for reasoning for this special case.
*/
private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex,
int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) {
int corner = computeCornerNearestOrigin();
switch (corner) {
case UPPER_LEFT:
return columnIndex == columnStartIndex && rowIndex == rowStartIndex;
case UPPER_RIGHT:
return columnIndex == columnEndIndex && rowIndex == rowStartIndex;
case LOWER_LEFT:
return columnIndex == columnStartIndex && rowIndex == rowEndIndex;
case LOWER_RIGHT:
// Note that in some cases, the last row will not have as many items as there
// are columns (e.g., if there are 4 items and 3 columns, the second row will
// only have one item in the first column). This function is invoked for each
// position from left to right, so return true for any position in the bottom
// row and only the right-most position in the bottom row will be recorded.
return rowIndex == rowEndIndex;
default:
throw new RuntimeException("Invalid corner type.");
}
}
/**
* Listener for changes in which items have been band selected.
*/
public abstract static class SelectionObserver<K> {
abstract void onSelectionChanged(Set<K> updatedSelection);
}
void addOnSelectionChangedListener(SelectionObserver listener) {
mOnSelectionChangedListeners.add(listener);
}
/**
* Called when {@link BandSelectionHelper} is finished with a GridModel.
*/
void onDestroy() {
mOnSelectionChangedListeners.clear();
// Cleanup listeners to prevent memory leaks.
mHost.removeOnScrollListener(mScrollListener);
}
/**
* Limits of a view item. For example, if an item's left side is at x-value 5 and its right side
* is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides
* of item columns and the top- and bottom sides of item rows so that it can be determined
* whether the pointer is located within the bounds of an item.
*/
private static class Limits implements Comparable<Limits> {
public int lowerLimit;
public int upperLimit;
Limits(int lowerLimit, int upperLimit) {
this.lowerLimit = lowerLimit;
this.upperLimit = upperLimit;
}
@Override
public int compareTo(Limits other) {
return lowerLimit - other.lowerLimit;
}
@Override
public int hashCode() {
return lowerLimit ^ upperLimit;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof Limits)) {
return false;
}
return ((Limits) other).lowerLimit == lowerLimit
&& ((Limits) other).upperLimit == upperLimit;
}
@Override
public String toString() {
return "(" + lowerLimit + ", " + upperLimit + ")";
}
}
/**
* The location of a coordinate relative to items. This class represents a general area of the
* view as it relates to band selection rather than an explicit point. For example, two
* different points within an item are considered to have the same "location" because band
* selection originating within the item would select the same items no matter which point
* was used. Same goes for points between items as well as those at the very beginning or end
* of the view.
*
* Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the
* advantage of tying the value to the Limits of items along that axis. This allows easy
* selection of items within those Limits as opposed to a search through every item to see if a
* given coordinate value falls within those Limits.
*/
private static class RelativeCoordinate
implements Comparable<RelativeCoordinate> {
/**
* Location describing points after the last known item.
*/
static final int AFTER_LAST_ITEM = 0;
/**
* Location describing points before the first known item.
*/
static final int BEFORE_FIRST_ITEM = 1;
/**
* Location describing points between two items.
*/
static final int BETWEEN_TWO_ITEMS = 2;
/**
* Location describing points within the limits of one item.
*/
static final int WITHIN_LIMITS = 3;
/**
* The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM,
* BETWEEN_TWO_ITEMS, or WITHIN_LIMITS.
*/
public final int type;
/**
* The limits before the coordinate; only populated when type == WITHIN_LIMITS or type ==
* BETWEEN_TWO_ITEMS.
*/
public Limits limitsBeforeCoordinate;
/**
* The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS.
*/
public Limits limitsAfterCoordinate;
// Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM.
public Limits mFirstKnownItem;
// Limits of the last known item; only populated when type == AFTER_LAST_ITEM.
public Limits mLastKnownItem;
/**
* @param limitsList The sorted limits list for the coordinate type. If this
* CoordinateLocation is an x-value, mXLimitsList should be passed;
* otherwise,
* mYLimitsList should be pased.
* @param value The coordinate value.
*/
RelativeCoordinate(List<Limits> limitsList, int value) {
int index = Collections.binarySearch(limitsList, new Limits(value, value));
if (index >= 0) {
this.type = WITHIN_LIMITS;
this.limitsBeforeCoordinate = limitsList.get(index);
} else if (~index == 0) {
this.type = BEFORE_FIRST_ITEM;
this.mFirstKnownItem = limitsList.get(0);
} else if (~index == limitsList.size()) {
Limits lastLimits = limitsList.get(limitsList.size() - 1);
if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) {
this.type = WITHIN_LIMITS;
this.limitsBeforeCoordinate = lastLimits;
} else {
this.type = AFTER_LAST_ITEM;
this.mLastKnownItem = lastLimits;
}
} else {
Limits limitsBeforeIndex = limitsList.get(~index - 1);
if (limitsBeforeIndex.lowerLimit <= value
&& value <= limitsBeforeIndex.upperLimit) {
this.type = WITHIN_LIMITS;
this.limitsBeforeCoordinate = limitsList.get(~index - 1);
} else {
this.type = BETWEEN_TWO_ITEMS;
this.limitsBeforeCoordinate = limitsList.get(~index - 1);
this.limitsAfterCoordinate = limitsList.get(~index);
}
}
}
int toComparisonValue() {
if (type == BEFORE_FIRST_ITEM) {
return mFirstKnownItem.lowerLimit - 1;
} else if (type == AFTER_LAST_ITEM) {
return mLastKnownItem.upperLimit + 1;
} else if (type == BETWEEN_TWO_ITEMS) {
return limitsBeforeCoordinate.upperLimit + 1;
} else {
return limitsBeforeCoordinate.lowerLimit;
}
}
@Override
public int hashCode() {
return mFirstKnownItem.lowerLimit
^ mLastKnownItem.upperLimit
^ limitsBeforeCoordinate.upperLimit
^ limitsBeforeCoordinate.lowerLimit;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof RelativeCoordinate)) {
return false;
}
RelativeCoordinate otherCoordinate = (RelativeCoordinate) other;
return toComparisonValue() == otherCoordinate.toComparisonValue();
}
@Override
public int compareTo(RelativeCoordinate other) {
return toComparisonValue() - other.toComparisonValue();
}
}
RelativePoint createRelativePoint(Point point) {
return new RelativePoint(
new RelativeCoordinate(mColumnBounds, point.x),
new RelativeCoordinate(mRowBounds, point.y));
}
/**
* The location of a point relative to the Limits of nearby items; consists of both an x- and
* y-RelativeCoordinateLocation.
*/
private static class RelativePoint {
final RelativeCoordinate mX;
final RelativeCoordinate mY;
RelativePoint(
@NonNull List<Limits> columnLimits,
@NonNull List<Limits> rowLimits, Point point) {
this.mX = new RelativeCoordinate(columnLimits, point.x);
this.mY = new RelativeCoordinate(rowLimits, point.y);
}
RelativePoint(@NonNull RelativeCoordinate x, @NonNull RelativeCoordinate y) {
this.mX = x;
this.mY = y;
}
@Override
public int hashCode() {
return mX.toComparisonValue() ^ mY.toComparisonValue();
}
@Override
public boolean equals(@Nullable Object other) {
if (!(other instanceof RelativePoint)) {
return false;
}
RelativePoint otherPoint = (RelativePoint) other;
return mX.equals(otherPoint.mX) && mY.equals(otherPoint.mY);
}
}
/**
* Generates a rectangle which contains the items selected by the pointer and origin.
*
* @return The rectangle, or null if no items were selected.
*/
private Rect computeBounds() {
Rect rect = new Rect();
rect.left = getCoordinateValue(
min(mRelOrigin.mX, mRelPointer.mX),
mColumnBounds,
true);
rect.right = getCoordinateValue(
max(mRelOrigin.mX, mRelPointer.mX),
mColumnBounds,
false);
rect.top = getCoordinateValue(
min(mRelOrigin.mY, mRelPointer.mY),
mRowBounds,
true);
rect.bottom = getCoordinateValue(
max(mRelOrigin.mY, mRelPointer.mY),
mRowBounds,
false);
return rect;
}
/**
* Computes the corner of the selection nearest the origin.
*/
private int computeCornerNearestOrigin() {
int cornerValue = 0;
if (mRelOrigin.mY.equals(min(mRelOrigin.mY, mRelPointer.mY))) {
cornerValue |= UPPER;
} else {
cornerValue |= LOWER;
}
if (mRelOrigin.mX.equals(min(mRelOrigin.mX, mRelPointer.mX))) {
cornerValue |= LEFT;
} else {
cornerValue |= RIGHT;
}
return cornerValue;
}
private RelativeCoordinate min(
@NonNull RelativeCoordinate first, @NonNull RelativeCoordinate second) {
return first.compareTo(second) < 0 ? first : second;
}
private RelativeCoordinate max(
@NonNull RelativeCoordinate first, @NonNull RelativeCoordinate second) {
return first.compareTo(second) > 0 ? first : second;
}
/**
* @return The absolute coordinate (i.e., the x- or y-value) of the given relative
* coordinate.
*/
private int getCoordinateValue(
@NonNull RelativeCoordinate coordinate,
@NonNull List<Limits> limitsList,
boolean isStartOfRange) {
switch (coordinate.type) {
case RelativeCoordinate.BEFORE_FIRST_ITEM:
return limitsList.get(0).lowerLimit;
case RelativeCoordinate.AFTER_LAST_ITEM:
return limitsList.get(limitsList.size() - 1).upperLimit;
case RelativeCoordinate.BETWEEN_TWO_ITEMS:
if (isStartOfRange) {
return coordinate.limitsAfterCoordinate.lowerLimit;
} else {
return coordinate.limitsBeforeCoordinate.upperLimit;
}
case RelativeCoordinate.WITHIN_LIMITS:
return coordinate.limitsBeforeCoordinate.lowerLimit;
}
throw new RuntimeException("Invalid coordinate value.");
}
private boolean areItemsCoveredByBand(
@NonNull RelativePoint first, @NonNull RelativePoint second) {
return doesCoordinateLocationCoverItems(first.mX, second.mX)
&& doesCoordinateLocationCoverItems(first.mY, second.mY);
}
private boolean doesCoordinateLocationCoverItems(
@NonNull RelativeCoordinate pointerCoordinate,
@NonNull RelativeCoordinate originCoordinate) {
if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM
&& originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) {
return false;
}
if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM
&& originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) {
return false;
}
if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS
&& originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS
&& pointerCoordinate.limitsBeforeCoordinate.equals(
originCoordinate.limitsBeforeCoordinate)
&& pointerCoordinate.limitsAfterCoordinate.equals(
originCoordinate.limitsAfterCoordinate)) {
return false;
}
return true;
}
/**
* Provides functionality for BandController. Exists primarily to tests that are
* fully isolated from RecyclerView.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
abstract static class GridHost<K> extends BandSelectionHelper.BandHost<K> {
/**
* Remove the listener.
*
* @param listener
*/
abstract void removeOnScrollListener(@NonNull OnScrollListener listener);
/**
* @param relativePoint for which to create absolute point.
* @return absolute point.
*/
abstract Point createAbsolutePoint(@NonNull Point relativePoint);
/**
* @param index index of child.
* @return rectangle describing child at {@code index}.
*/
abstract Rect getAbsoluteRectForChildViewAt(int index);
/**
* @param index index of child.
* @return child adapter position for the child at {@code index}
*/
abstract int getAdapterPositionAt(int index);
/** @return column count. */
abstract int getColumnCount();
/** @return number of children visible in the view. */
abstract int getVisibleChildCount();
/**
* @return true if the item at adapter position is attached to a view.
*/
abstract boolean hasView(int adapterPosition);
}
}

@ -0,0 +1,258 @@
/*
* 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 android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
/**
* The Selection library calls {@link #getItemDetails(MotionEvent)} when it needs
* access to information about the area and/or {@link ItemDetails} under a {@link MotionEvent}.
* Your implementation must negotiate
* {@link RecyclerView.ViewHolder ViewHolder} lookup with the
* corresponding RecyclerView instance, and the subsequent conversion of the ViewHolder
* instance to an {@link ItemDetails} instance.
*
* <p>
* <b>Example</b>
* <pre>
* final class MyDetailsLookup extends ItemDetailsLookup<Uri> {
*
* private final RecyclerView mRecyclerView;
*
* MyDetailsLookup(RecyclerView recyclerView) {
* mRecyclerView = recyclerView;
* }
*
* public ItemDetails<Uri> getItemDetails(MotionEvent e) {
* View view = mRecView.findChildViewUnder(e.getX(), e.getY());
* if (view != null) {
* ViewHolder holder = mRecView.getChildViewHolder(view);
* if (holder instanceof MyHolder) {
* return ((MyHolder) holder).getItemDetails();
* }
* }
* return null;
* }
*}
* </pre>
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract class ItemDetailsLookup<K> {
/**
* @return true if there is an item at the event coordinates.
*/
final boolean overItem(@NonNull MotionEvent e) {
return getItemPosition(e) != RecyclerView.NO_POSITION;
}
/**
* @return true if there is an item w/ a stable ID at the event coordinates.
*/
final boolean overItemWithSelectionKey(@NonNull MotionEvent e) {
return overItem(e) && hasSelectionKey(getItemDetails(e));
}
/**
* @return true if the event coordinates are in an area of the item
* that can result in dragging the item. List items frequently have a white
* area that is not draggable allowing band selection to be initiated
* in that area.
*/
final boolean inItemDragRegion(@NonNull MotionEvent e) {
return overItem(e) && getItemDetails(e).inDragRegion(e);
}
/**
* @return true if the event coordinates are in a "selection hot spot"
* region of an item. Contact in these regions result in immediate
* selection, even when there is no existing selection.
*/
final boolean inItemSelectRegion(@NonNull MotionEvent e) {
return overItem(e) && getItemDetails(e).inSelectionHotspot(e);
}
/**
* @return the adapter position of the item at the event coordinates.
*/
final int getItemPosition(@NonNull MotionEvent e) {
@Nullable ItemDetails<?> item = getItemDetails(e);
return item != null
? item.getPosition()
: RecyclerView.NO_POSITION;
}
private static boolean hasSelectionKey(@Nullable ItemDetails<?> item) {
return item != null && item.getSelectionKey() != null;
}
private static boolean hasPosition(@Nullable ItemDetails<?> item) {
return item != null && item.getPosition() != RecyclerView.NO_POSITION;
}
/**
* @return the ItemDetails for the item under the event, or null.
*/
public abstract @Nullable ItemDetails<K> getItemDetails(@NonNull MotionEvent e);
/**
* An ItemDetails implementation provides the selection library with access to information
* about a specific RecyclerView item. This class is a key component in controling
* the behaviors of the selection library in the context of a specific activity.
*
* <p>
* <b>Selection Hotspot</b>
*
* <p>
* This is an optional feature identifying an area within a view that
* is single-tap to select. Ordinarily a single tap on an item when there is no
* existing selection will result in that item being activated. If the tap
* occurs within the "selection hotspot" the item will instead be selected.
*
* <p>
* See {@link OnItemActivatedListener} for details on handling item activation.
*
* <p>
* <b>Drag Region</b>
*
* <p>
* The selection library provides support for mouse driven band selection. The "lasso"
* typically associated with mouse selection can be started only in an empty
* area of the RecyclerView (an area where the item position == RecyclerView#NO_POSITION,
* or where RecyclerView#findChildViewUnder returns null). But in many instances
* the item views presented by RecyclerView will contain areas that may be perceived
* by the user as being empty. The user may expect to be able to initiate band
* selection in these empty areas.
*
* <p>
* The "drag region" concept exists in large part to accommodate this user expectation.
* Drag region is the content in an item view that the user doesn't otherwise
* perceive to be empty or part of the background of recycler view.
*
* Take for example a traditional single column layout where
* the view layout width is "match_parent":
* <pre>
* -------------------------------------------------------
* | [icon] A string label. ...empty space... |
* -------------------------------------------------------
* < --- drag region --> < --treated as background-->
*</pre>
*
* <p>
* Further more, within a drag region, a mouse click and drag will immediately
* initiate drag and drop (if supported by your configuration).
*
* <p>
* As user expectations around touch and mouse input differ substantially,
* "drag region" has no effect on handling of touch input.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract static class ItemDetails<K> {
/**
* Returns the adapter position of the item. See
* {@link RecyclerView.ViewHolder#getAdapterPosition() ViewHolder.getAdapterPosition}
*
* @return the position of an item.
*/
public abstract int getPosition();
/**
* @return true if the item has a selection key.
*/
public boolean hasSelectionKey() {
return getSelectionKey() != null;
}
/**
* @return the selection key of an item.
*/
public abstract @Nullable K getSelectionKey();
/**
* Areas are often included in a view that behave similar to checkboxes, such
* as the icon to the left of an email message. "selection
* hotspot" provides a mechanism to identify such regions, and for the
* library to directly translate taps in these regions into a change
* in selection state.
*
* @return true if the event is in an area of the item that should be
* directly interpreted as a user wishing to select the item. This
* is useful for checkboxes and other UI affordances focused on enabling
* selection.
*/
public boolean inSelectionHotspot(@NonNull MotionEvent e) {
return false;
}
/**
* "Item Drag Region" identifies areas of an item that are not considered when the library
* evaluates whether or not to initiate band-selection for mouse input. The drag region
* will usually correspond to an area of an item that represents user visible content.
* Mouse driven band selection operations are only ever initiated in non-drag-regions.
* This is a consideration as many layouts may not include empty space between
* RecyclerView items where band selection can be initiated.
*
* <p>
* For example. You may present a single column list of contact names in a
* RecyclerView instance in which the individual view items expand to fill all
* available space.
* But within the expanded view item after the contact name there may be empty space that a
* user would reasonably expect to initiate band selection. When a MotionEvent occurs
* in such an area, you should return identify this as NOT in a drag region.
*
* <p>
* Further more, within a drag region, a mouse click and drag will immediately
* initiate drag and drop (if supported by your configuration).
*
* @return true if the item is in an area of the item that can result in dragging
* the item. List items frequently have a white area that is not draggable allowing
* mouse driven band selection to be initiated in that area.
*/
public boolean inDragRegion(@NonNull MotionEvent e) {
return false;
}
@Override
public boolean equals(@Nullable Object obj) {
return (obj instanceof ItemDetails)
&& isEqualTo((ItemDetails) obj);
}
private boolean isEqualTo(@NonNull ItemDetails other) {
K key = getSelectionKey();
boolean sameKeys = false;
if (key == null) {
sameKeys = other.getSelectionKey() == null;
} else {
sameKeys = key.equals(other.getSelectionKey());
}
return sameKeys && this.getPosition() == other.getPosition();
}
@Override
public int hashCode() {
return getPosition() >>> 8;
}
}
}

@ -0,0 +1,85 @@
/*
* 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.core.util.Preconditions.checkArgument;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Provides selection library access to stable selection keys identifying items
* presented by a {@link RecyclerView RecyclerView} instance.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract class ItemKeyProvider<K> {
/**
* Provides access to all data, regardless of whether it is bound to a view or not.
* Key providers with this access type enjoy support for enhanced features like:
* SHIFT+click range selection, and band selection.
*/
public static final int SCOPE_MAPPED = 0;
/**
* Provides access to cached data based for items that were recently bound in the view.
* Employing this provider will result in a reduced feature-set, as some
* features like SHIFT+click range selection and band selection are dependent
* on mapped access.
*/
public static final int SCOPE_CACHED = 1;
@IntDef({
SCOPE_MAPPED,
SCOPE_CACHED
})
@Retention(RetentionPolicy.SOURCE)
public @interface Scope {}
private final @Scope int mScope;
/**
* Creates a new provider with the given scope.
*
* @param scope Scope can't be changed at runtime.
*/
protected ItemKeyProvider(@Scope int scope) {
checkArgument(scope == SCOPE_MAPPED || scope == SCOPE_CACHED);
mScope = scope;
}
final boolean hasAccess(@Scope int scope) {
return scope == mScope;
}
/**
* @return The selection key at the given adapter position, or null.
*/
public abstract @Nullable K getKey(int position);
/**
* @return the position corresponding to the selection key, or RecyclerView.NO_POSITION.
*/
public abstract int getPosition(@NonNull K key);
}

@ -0,0 +1,120 @@
/*
* 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 android.graphics.Point;
import android.view.KeyEvent;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
/**
* Utility methods for working with {@link MotionEvent} instances.
*/
final class MotionEvents {
private MotionEvents() {}
static boolean isMouseEvent(@NonNull MotionEvent e) {
return e.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE;
}
static boolean isTouchEvent(@NonNull MotionEvent e) {
return e.getToolType(0) == MotionEvent.TOOL_TYPE_FINGER;
}
static boolean isActionMove(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_MOVE;
}
static boolean isActionDown(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_DOWN;
}
static boolean isActionUp(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_UP;
}
static boolean isActionPointerUp(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_POINTER_UP;
}
@SuppressWarnings("unused")
static boolean isActionPointerDown(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_POINTER_DOWN;
}
static boolean isActionCancel(@NonNull MotionEvent e) {
return e.getActionMasked() == MotionEvent.ACTION_CANCEL;
}
static Point getOrigin(@NonNull MotionEvent e) {
return new Point((int) e.getX(), (int) e.getY());
}
static boolean isPrimaryMouseButtonPressed(@NonNull MotionEvent e) {
return isButtonPressed(e, MotionEvent.BUTTON_PRIMARY);
}
static boolean isSecondaryMouseButtonPressed(@NonNull MotionEvent e) {
return isButtonPressed(e, MotionEvent.BUTTON_SECONDARY);
}
static boolean isTertiaryMouseButtonPressed(@NonNull MotionEvent e) {
return isButtonPressed(e, MotionEvent.BUTTON_TERTIARY);
}
// NOTE: Can replace this with MotionEvent.isButtonPressed once targeting 21 or higher.
private static boolean isButtonPressed(MotionEvent e, int button) {
if (button == 0) {
return false;
}
return (e.getButtonState() & button) == button;
}
static boolean isShiftKeyPressed(@NonNull MotionEvent e) {
return hasBit(e.getMetaState(), KeyEvent.META_SHIFT_ON);
}
static boolean isCtrlKeyPressed(@NonNull MotionEvent e) {
return hasBit(e.getMetaState(), KeyEvent.META_CTRL_ON);
}
static boolean isAltKeyPressed(@NonNull MotionEvent e) {
return hasBit(e.getMetaState(), KeyEvent.META_ALT_ON);
}
static boolean isTouchpadScroll(@NonNull MotionEvent e) {
// Touchpad inputs are treated as mouse inputs, and when scrolling, there are no buttons
// returned.
return isMouseEvent(e) && isActionMove(e) && e.getButtonState() == 0;
}
/**
* Returns true if the event is a drag event (which is presumbaly, but not
* explicitly required to be a mouse event).
* @param e
*/
static boolean isPointerDragEvent(MotionEvent e) {
return isPrimaryMouseButtonPressed(e)
&& isActionMove(e);
}
private static boolean hasBit(int metaState, int bit) {
return (metaState & bit) != 0;
}
}

@ -0,0 +1,112 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import androidx.recyclerview.widget.RecyclerView;
/**
* Base class for handlers that can be registered w/ {@link GestureRouter}.
*/
abstract class MotionInputHandler<K> extends SimpleOnGestureListener {
protected final SelectionTracker<K> mSelectionTracker;
private final ItemKeyProvider<K> mKeyProvider;
private final FocusDelegate<K> mFocusDelegate;
MotionInputHandler(
@NonNull SelectionTracker<K> selectionTracker,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull FocusDelegate<K> focusDelegate) {
checkArgument(selectionTracker != null);
checkArgument(keyProvider != null);
checkArgument(focusDelegate != null);
mSelectionTracker = selectionTracker;
mKeyProvider = keyProvider;
mFocusDelegate = focusDelegate;
}
final boolean selectItem(@NonNull ItemDetails<K> details) {
checkArgument(details != null);
checkArgument(hasPosition(details));
checkArgument(hasSelectionKey(details));
if (mSelectionTracker.select(details.getSelectionKey())) {
mSelectionTracker.anchorRange(details.getPosition());
}
// we set the focus on this doc so it will be the origin for keyboard events or shift+clicks
// if there is only a single item selected, otherwise clear focus
if (mSelectionTracker.getSelection().size() == 1) {
mFocusDelegate.focusItem(details);
} else {
mFocusDelegate.clearFocus();
}
return true;
}
protected final boolean focusItem(@NonNull ItemDetails<K> details) {
checkArgument(details != null);
checkArgument(hasSelectionKey(details));
mSelectionTracker.clearSelection();
mFocusDelegate.focusItem(details);
return true;
}
protected final void extendSelectionRange(@NonNull ItemDetails<K> details) {
checkState(mKeyProvider.hasAccess(ItemKeyProvider.SCOPE_MAPPED));
checkArgument(hasPosition(details));
checkArgument(hasSelectionKey(details));
mSelectionTracker.extendRange(details.getPosition());
mFocusDelegate.focusItem(details);
}
final boolean isRangeExtension(@NonNull MotionEvent e) {
return MotionEvents.isShiftKeyPressed(e)
&& mSelectionTracker.isRangeActive()
// Without full corpus access we can't reliably implement range
// as a user can scroll *anywhere* then SHIFT+click.
&& mKeyProvider.hasAccess(ItemKeyProvider.SCOPE_MAPPED);
}
boolean shouldClearSelection(@NonNull MotionEvent e, @NonNull ItemDetails<K> item) {
return !MotionEvents.isCtrlKeyPressed(e)
&& !item.inSelectionHotspot(e)
&& !mSelectionTracker.isSelected(item.getSelectionKey());
}
static boolean hasSelectionKey(@Nullable ItemDetails<?> item) {
return item != null && item.getSelectionKey() != null;
}
static boolean hasPosition(@Nullable ItemDetails<?> item) {
return item != null && item.getPosition() != RecyclerView.NO_POSITION;
}
}

@ -0,0 +1,224 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import static androidx.recyclerview.selection.Shared.DEBUG;
import static androidx.recyclerview.selection.Shared.VERBOSE;
import android.util.Log;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import androidx.recyclerview.widget.RecyclerView;
/**
* A MotionInputHandler that provides the high-level glue for mouse driven selection. This
* class works with {@link RecyclerView}, {@link GestureRouter}, and {@link GestureSelectionHelper}
* to implement the primary policies around mouse input.
*/
final class MouseInputHandler<K> extends MotionInputHandler<K> {
private static final String TAG = "MouseInputDelegate";
private final ItemDetailsLookup<K> mDetailsLookup;
private final OnContextClickListener mOnContextClickListener;
private final OnItemActivatedListener<K> mOnItemActivatedListener;
private final FocusDelegate<K> mFocusDelegate;
// The event has been handled in onSingleTapUp
private boolean mHandledTapUp;
// true when the previous event has consumed a right click motion event
private boolean mHandledOnDown;
MouseInputHandler(
@NonNull SelectionTracker<K> selectionTracker,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull ItemDetailsLookup<K> detailsLookup,
@NonNull OnContextClickListener onContextClickListener,
@NonNull OnItemActivatedListener<K> onItemActivatedListener,
@NonNull FocusDelegate<K> focusDelegate) {
super(selectionTracker, keyProvider, focusDelegate);
checkArgument(detailsLookup != null);
checkArgument(onContextClickListener != null);
checkArgument(onItemActivatedListener != null);
mDetailsLookup = detailsLookup;
mOnContextClickListener = onContextClickListener;
mOnItemActivatedListener = onItemActivatedListener;
mFocusDelegate = focusDelegate;
}
@Override
public boolean onDown(@NonNull MotionEvent e) {
if (VERBOSE) Log.v(TAG, "Delegated onDown event.");
if ((MotionEvents.isAltKeyPressed(e) && MotionEvents.isPrimaryMouseButtonPressed(e))
|| MotionEvents.isSecondaryMouseButtonPressed(e)) {
mHandledOnDown = true;
return onRightClick(e);
}
return false;
}
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2,
float distanceX, float distanceY) {
// Don't scroll content window in response to mouse drag
// If it's two-finger trackpad scrolling, we want to scroll
return !MotionEvents.isTouchpadScroll(e2);
}
@Override
public boolean onSingleTapUp(@NonNull MotionEvent e) {
// See b/27377794. Since we don't get a button state back from UP events, we have to
// explicitly save this state to know whether something was previously handled by
// DOWN events or not.
if (mHandledOnDown) {
if (VERBOSE) Log.v(TAG, "Ignoring onSingleTapUp, previously handled in onDown.");
mHandledOnDown = false;
return false;
}
if (!mDetailsLookup.overItemWithSelectionKey(e)) {
if (DEBUG) Log.d(TAG, "Tap not associated w/ model item. Clearing selection.");
mSelectionTracker.clearSelection();
mFocusDelegate.clearFocus();
return false;
}
if (MotionEvents.isTertiaryMouseButtonPressed(e)) {
if (DEBUG) Log.d(TAG, "Ignoring middle click");
return false;
}
if (mSelectionTracker.hasSelection()) {
onItemClick(e, mDetailsLookup.getItemDetails(e));
mHandledTapUp = true;
return true;
}
return false;
}
// tap on an item when there is an existing selection. We could extend
// a selection, we could clear selection (then launch)
private void onItemClick(@NonNull MotionEvent e, @NonNull ItemDetails<K> item) {
checkState(mSelectionTracker.hasSelection());
checkArgument(item != null);
if (isRangeExtension(e)) {
extendSelectionRange(item);
} else {
if (shouldClearSelection(e, item)) {
mSelectionTracker.clearSelection();
}
if (mSelectionTracker.isSelected(item.getSelectionKey())) {
if (mSelectionTracker.deselect(item.getSelectionKey())) {
mFocusDelegate.clearFocus();
}
} else {
selectOrFocusItem(item, e);
}
}
}
@Override
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
if (mHandledTapUp) {
if (VERBOSE) {
Log.v(TAG,
"Ignoring onSingleTapConfirmed, previously handled in onSingleTapUp.");
}
mHandledTapUp = false;
return false;
}
if (mSelectionTracker.hasSelection()) {
return false; // should have been handled by onSingleTapUp.
}
if (!mDetailsLookup.overItem(e)) {
if (DEBUG) Log.d(TAG, "Ignoring Confirmed Tap on non-item.");
return false;
}
if (MotionEvents.isTertiaryMouseButtonPressed(e)) {
if (DEBUG) Log.d(TAG, "Ignoring middle click");
return false;
}
@Nullable ItemDetails<K> item = mDetailsLookup.getItemDetails(e);
if (item == null || !item.hasSelectionKey()) {
return false;
}
if (mFocusDelegate.hasFocusedItem() && MotionEvents.isShiftKeyPressed(e)) {
mSelectionTracker.startRange(mFocusDelegate.getFocusedPosition());
mSelectionTracker.extendRange(item.getPosition());
} else {
selectOrFocusItem(item, e);
}
return true;
}
@Override
public boolean onDoubleTap(@NonNull MotionEvent e) {
mHandledTapUp = false;
if (!mDetailsLookup.overItemWithSelectionKey(e)) {
if (DEBUG) Log.d(TAG, "Ignoring DoubleTap on non-model-backed item.");
return false;
}
if (MotionEvents.isTertiaryMouseButtonPressed(e)) {
if (DEBUG) Log.d(TAG, "Ignoring middle click");
return false;
}
ItemDetails<K> item = mDetailsLookup.getItemDetails(e);
return (item != null) && mOnItemActivatedListener.onItemActivated(item, e);
}
private boolean onRightClick(@NonNull MotionEvent e) {
if (mDetailsLookup.overItemWithSelectionKey(e)) {
@Nullable ItemDetails<K> item = mDetailsLookup.getItemDetails(e);
if (item != null && !mSelectionTracker.isSelected(item.getSelectionKey())) {
mSelectionTracker.clearSelection();
selectItem(item);
}
}
// We always delegate final handling of the event,
// since the handler might want to show a context menu
// in an empty area or some other weirdo view.
return mOnContextClickListener.onContextClick(e);
}
private void selectOrFocusItem(@NonNull ItemDetails<K> item, @NonNull MotionEvent e) {
if (item.inSelectionHotspot(e) || MotionEvents.isCtrlKeyPressed(e)) {
selectItem(item);
} else {
focusItem(item);
}
}
}

@ -0,0 +1,76 @@
/*
* 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 androidx.annotation.NonNull;
/**
* Subclass of {@link Selection} exposing public support for mutating the underlying
* selection data. This is useful for clients of {@link SelectionTracker} that wish to
* manipulate a copy of selection data obtained via
* {@link SelectionTracker#copySelection(MutableSelection)}.
*
* <p>
* While the {@link Selection} class is not intrinsically immutable, it is not mutable
* by non-library code. Furthermore the value returned from {@link SelectionTracker#getSelection()}
* is a live view of the underlying selection, mutable by the library itself.
*
* <p>
* {@link MutableSelection} allows clients to obtain a mutable copy of the Selection
* state held by the selection library. This is useful in situations where a stable
* snapshot of the selection is required.
*
*
* <p><b>Example</b>
*
* <p>
* <pre>
* MutableSelection snapshot = new MutableSelection();
* selectionTracker.copySelection(snapshot);
*
* // Clear the user visible selection.
* selectionTracker.clearSelection();
* // tracker.getSelection().isEmpty() will be true.
* // shapshot has a copy of the previous selection.
* </pre>
*
* @see android.text.Selection
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public final class MutableSelection<K> extends Selection<K> {
@Override
public boolean add(@NonNull K key) {
return super.add(key);
}
@Override
public boolean remove(@NonNull K key) {
return super.remove(key);
}
@Override
public void copyFrom(@NonNull Selection<K> source) {
super.copyFrom(source);
}
@Override
public void clear() {
super.clear();
}
}

@ -0,0 +1,41 @@
/*
* 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 android.view.MotionEvent;
import androidx.annotation.NonNull;
/**
* Override methods in this class to provide application specific behaviors
* related to mouse input.
*/
/**
* Register an OnContextClickListener to be notified when a context click
* occurs.
*/
public interface OnContextClickListener {
/**
* Called when user performs a context click, usually via mouse pointer
* right-click.
*
* @param e the event associated with the click.
* @return true if the event was handled.
*/
boolean onContextClick(@NonNull MotionEvent e);
}

@ -0,0 +1,73 @@
/*
* 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.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import android.content.ClipData;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
/**
* Register an OnDragInitiatedListener to be notified when user intent to perform drag and drop
* operations on an item or items has been detected. Handle these events using {@link View}
* support for Drag and drop.
*
* <p>
* See {@link View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)}
* for details.
*/
public interface OnDragInitiatedListener {
/**
* Called when user intent to perform a drag and drop operation has been detected.
*
* <p>
* The following circumstances are considered to be expressing drag and drop intent:
*
* <ol>
* <li>Long press on selected item.</li>
* <li>Click and drag in the {@link ItemDetails#inDragRegion(MotionEvent) drag region}
* of selected item with a pointer device.</li>
* <li>Click and drag in drag region of un-selected item with a pointer device.</li>
* </ol>
*
* <p>
* The RecyclerView item at the coordinates of the MotionEvent is not supplied as a parameter
* to this method as there may be multiple items selected or no items selected (as may be
* the case in pointer drive drag and drop.)
*
* <p>
* Obtain the current list of selected items from
* {@link SelectionTracker#copySelection(MutableSelection)}. If there is no selection
* get the item under the event using {@link ItemDetailsLookup#getItemDetails(MotionEvent)}.
*
* <p>
* Drag region used with pointer devices is specified by
* {@link ItemDetails#inDragRegion(MotionEvent)}
*
* <p>
* See {@link android.view.View#startDragAndDrop(ClipData, View.DragShadowBuilder, Object, int)}
* for details on drag and drop implementation.
*
* @param e the event associated with the drag.
* @return true if drag and drop was initiated.
*/
boolean onDragInitiated(@NonNull MotionEvent e);
}

@ -0,0 +1,43 @@
/*
* 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 android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
/**
* Register an OnItemActivatedListener to be notified when an item is activated
* (tapped or double clicked).
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public interface OnItemActivatedListener<K> {
/**
* Called when an item is "activated". An item is activated, for example, when no selection
* exists and the user taps an item with her finger, or double clicks an item with a
* pointing device like a Mouse.
*
* @param item details of the item.
* @param e the event associated with item.
*
* @return true if the event was handled.
*/
boolean onItemActivated(@NonNull ItemDetails<K> item, @NonNull MotionEvent e);
}

@ -0,0 +1,131 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import static androidx.recyclerview.selection.Shared.DEBUG;
import android.util.Log;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
/**
* OperationMonitor provides a mechanism to coordinate application
* logic with ongoing user selection activities (such as active band selection
* and active gesture selection).
*
* <p>
* The host {@link android.app.Activity} or {@link android.app.Fragment} should avoid changing
* {@link RecyclerView.Adapter Adapter} data while there
* are active selection operations, as this can result in a poor user experience.
*
* <p>
* To know when an operation is active listen to changes using an {@link OnChangeListener}.
*/
public final class OperationMonitor {
private static final String TAG = "OperationMonitor";
private int mNumOps = 0;
private List<OnChangeListener> mListeners = new ArrayList<>();
@MainThread
synchronized void start() {
mNumOps++;
if (mNumOps == 1) {
for (OnChangeListener l : mListeners) {
l.onChanged();
}
}
if (DEBUG) Log.v(TAG, "Incremented content lock count to " + mNumOps + ".");
}
@MainThread
synchronized void stop() {
checkState(mNumOps > 0);
mNumOps--;
if (DEBUG) Log.v(TAG, "Decremented content lock count to " + mNumOps + ".");
if (mNumOps == 0) {
for (OnChangeListener l : mListeners) {
l.onChanged();
}
}
}
/**
* @return true if there are any running operations.
*/
@SuppressWarnings("unused")
public synchronized boolean isStarted() {
return mNumOps > 0;
}
/**
* Registers supplied listener to be notified when operation status changes.
* @param listener
*/
public void addListener(@NonNull OnChangeListener listener) {
checkArgument(listener != null);
mListeners.add(listener);
}
/**
* Unregisters listener for further notifications.
* @param listener
*/
public void removeListener(@NonNull OnChangeListener listener) {
checkArgument(listener != null);
mListeners.remove(listener);
}
/**
* Allows other selection code to perform a precondition check asserting the state is locked.
*/
void checkStarted() {
checkState(mNumOps > 0);
}
/**
* Allows other selection code to perform a precondition check asserting the state is unlocked.
*/
void checkStopped() {
checkState(mNumOps == 0);
}
/**
* Listen to changes in operation status. Authors should avoid
* changing the Adapter model while there are active operations.
*/
public interface OnChangeListener {
/**
* Called when operation status changes. Call {@link OperationMonitor#isStarted()}
* to determine the current status.
*/
void onChanged();
}
}

@ -0,0 +1,75 @@
/*
* Copyright 2018 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.core.util.Preconditions.checkArgument;
import android.view.MotionEvent;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
/**
* OnItemTouchListener that delegates drag events to a drag listener,
* else sends event to fallback {@link OnItemTouchListener}.
*
* <p>See {@link OnDragInitiatedListener} for details on implementing drag and drop.
*/
final class PointerDragEventInterceptor implements OnItemTouchListener {
private final ItemDetailsLookup mEventDetailsLookup;
private final OnDragInitiatedListener mDragListener;
private @Nullable OnItemTouchListener mDelegate;
PointerDragEventInterceptor(
ItemDetailsLookup eventDetailsLookup,
OnDragInitiatedListener dragListener,
@Nullable OnItemTouchListener delegate) {
checkArgument(eventDetailsLookup != null);
checkArgument(dragListener != null);
mEventDetailsLookup = eventDetailsLookup;
mDragListener = dragListener;
mDelegate = delegate;
}
@Override
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
if (MotionEvents.isPointerDragEvent(e) && mEventDetailsLookup.inItemDragRegion(e)) {
return mDragListener.onDragInitiated(e);
} else if (mDelegate != null) {
return mDelegate.onInterceptTouchEvent(rv, e);
}
return false;
}
@Override
public void onTouchEvent(RecyclerView rv, MotionEvent e) {
if (mDelegate != null) {
mDelegate.onTouchEvent(rv, e);
}
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
if (mDelegate != null) {
mDelegate.onRequestDisallowInterceptTouchEvent(disallowIntercept);
}
}
}

@ -0,0 +1,190 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.recyclerview.selection.Shared.DEBUG;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
import android.util.Log;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Class providing support for managing range selections.
*/
final class Range {
static final int TYPE_PRIMARY = 0;
/**
* "Provisional" selection represents a overlay on the primary selection. A provisional
* selection maybe be eventually added to the primary selection, or it may be abandoned.
*
* <p>
* E.g. BandSelectionHelper creates a provisional selection while a user is actively
* selecting items with a band. GestureSelectionHelper creates a provisional selection
* while a user is active selecting via gesture.
*
* <p>
* Provisionally selected items are considered to be selected in
* {@link Selection#contains(String)} and related methods. A provisional may be abandoned or
* merged into the promary selection.
*
* <p>
* A provisional selection may intersect with the primary selection, however clearing the
* provisional selection will not affect the primary selection where the two may intersect.
*/
static final int TYPE_PROVISIONAL = 1;
@IntDef({
TYPE_PRIMARY,
TYPE_PROVISIONAL
})
@Retention(RetentionPolicy.SOURCE)
@interface RangeType {}
private static final String TAG = "Range";
private final Callbacks mCallbacks;
private final int mBegin;
private int mEnd = NO_POSITION;
/**
* Creates a new range anchored at {@code position}.
*
* @param position
* @param callbacks
*/
Range(int position, @NonNull Callbacks callbacks) {
mBegin = position;
mCallbacks = callbacks;
if (DEBUG) Log.d(TAG, "Creating new Range anchored @ " + position);
}
void extendRange(int position, @RangeType int type) {
checkArgument(position != NO_POSITION, "Position cannot be NO_POSITION.");
if (mEnd == NO_POSITION || mEnd == mBegin) {
// Reset mEnd so it can be established in establishRange.
mEnd = NO_POSITION;
establishRange(position, type);
} else {
reviseRange(position, type);
}
}
private void establishRange(int position, @RangeType int type) {
checkArgument(mEnd == NO_POSITION, "End has already been set.");
mEnd = position;
if (position > mBegin) {
if (DEBUG) log(type, "Establishing initial range at @ " + position);
updateRange(mBegin + 1, position, true, type);
} else if (position < mBegin) {
if (DEBUG) log(type, "Establishing initial range at @ " + position);
updateRange(position, mBegin - 1, true, type);
}
}
private void reviseRange(int position, @RangeType int type) {
checkArgument(mEnd != NO_POSITION, "End must already be set.");
checkArgument(mBegin != mEnd, "Beging and end point to same position.");
if (position == mEnd) {
if (DEBUG) log(type, "Ignoring no-op revision for range @ " + position);
}
if (mEnd > mBegin) {
reviseAscending(position, type);
} else if (mEnd < mBegin) {
reviseDescending(position, type);
}
// the "else" case is covered by checkState at beginning of method.
mEnd = position;
}
/**
* Updates an existing ascending selection.
*/
private void reviseAscending(int position, @RangeType int type) {
if (DEBUG) log(type, "*ascending* Revising range @ " + position);
if (position < mEnd) {
if (position < mBegin) {
updateRange(mBegin + 1, mEnd, false, type);
updateRange(position, mBegin - 1, true, type);
} else {
updateRange(position + 1, mEnd, false, type);
}
} else if (position > mEnd) { // Extending the range...
updateRange(mEnd + 1, position, true, type);
}
}
private void reviseDescending(int position, @RangeType int type) {
if (DEBUG) log(type, "*descending* Revising range @ " + position);
if (position > mEnd) {
if (position > mBegin) {
updateRange(mEnd, mBegin - 1, false, type);
updateRange(mBegin + 1, position, true, type);
} else {
updateRange(mEnd, position - 1, false, type);
}
} else if (position < mEnd) { // Extending the range...
updateRange(position, mEnd - 1, true, type);
}
}
/**
* Try to set selection state for all elements in range. Not that callbacks can cancel
* selection of specific items, so some or even all items may not reflect the desired state
* after the update is complete.
*
* @param begin Adapter position for range start (inclusive).
* @param end Adapter position for range end (inclusive).
* @param selected New selection state.
*/
private void updateRange(
int begin, int end, boolean selected, @RangeType int type) {
mCallbacks.updateForRange(begin, end, selected, type);
}
@Override
public String toString() {
return "Range{begin=" + mBegin + ", end=" + mEnd + "}";
}
private void log(@RangeType int type, String message) {
String opType = type == TYPE_PRIMARY ? "PRIMARY" : "PROVISIONAL";
Log.d(TAG, String.valueOf(this) + ": " + message + " (" + opType + ")");
}
/*
* @see {@link DefaultSelectionTracker#updateForRange(int, int , boolean, int)}.
*/
abstract static class Callbacks {
abstract void updateForRange(
int begin, int end, boolean selected, @RangeType int type);
}
}

@ -0,0 +1,247 @@
/*
* 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 androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
/**
* Object representing a "primary" selection and a "provisional" selection.
*
* <p>
* This class tracks selected items by managing two sets:
*
* <p>
* <b>Primary Selection</b>
*
* <p>
* Primary selection consists of items selected by a user. This represents the selection
* "at rest", as the selection does not contains items that are in a "provisional" selected
* state created by way of an ongoing gesture or band operation.
*
* <p>
* <b>Provisional Selection</b>
*
* <p>
* Provisional selections are selections which are interim in nature.
*
* <p>
* Provisional selection exists to address issues where a transitory selection might
* momentarily intersect with a previously established selection resulting in a some
* or all of the established selection being erased. Such situations may arise
* when band selection is being performed in "additive" mode (e.g. SHIFT or CTRL is pressed
* on the keyboard prior to mouse down), or when there's an active gesture selection
* (which can be initiated by long pressing an unselected item while there is an
* existing selection).
*
* @see MutableSelection
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public class Selection<K> implements Iterable<K> {
// NOTE: Not currently private as DefaultSelectionTracker directly manipulates values.
final Set<K> mSelection;
final Set<K> mProvisionalSelection;
Selection() {
mSelection = new HashSet<>();
mProvisionalSelection = new HashSet<>();
}
/**
* Used by {@link StorageStrategy} when restoring selection.
*/
Selection(@NonNull Set<K> selection) {
mSelection = selection;
mProvisionalSelection = new HashSet<>();
}
/**
* @param key
* @return true if the position is currently selected.
*/
public boolean contains(@Nullable K key) {
return mSelection.contains(key) || mProvisionalSelection.contains(key);
}
/**
* Returns an {@link Iterator} that iterators over the selection, *excluding*
* any provisional selection.
*
* {@inheritDoc}
*/
@Override
public Iterator<K> iterator() {
return mSelection.iterator();
}
/**
* @return size of the selection including both final and provisional selected items.
*/
public int size() {
return mSelection.size() + mProvisionalSelection.size();
}
/**
* @return true if the selection is empty.
*/
public boolean isEmpty() {
return mSelection.isEmpty() && mProvisionalSelection.isEmpty();
}
/**
* Sets the provisional selection, which is a temporary selection that can be saved,
* canceled, or adjusted at a later time. When a new provision selection is applied, the old
* one (if it exists) is abandoned.
* @return Map of ids added or removed. Added ids have a value of true, removed are false.
*/
Map<K, Boolean> setProvisionalSelection(@NonNull Set<K> newSelection) {
Map<K, Boolean> delta = new HashMap<>();
for (K key: mProvisionalSelection) {
// Mark each item that used to be in the provisional selection
// but is not in the new provisional selection.
if (!newSelection.contains(key) && !mSelection.contains(key)) {
delta.put(key, false);
}
}
for (K key: mSelection) {
// Mark each item that used to be in the selection but is unsaved and not in the new
// provisional selection.
if (!newSelection.contains(key)) {
delta.put(key, false);
}
}
for (K key: newSelection) {
// Mark each item that was not previously in the selection but is in the new
// provisional selection.
if (!mSelection.contains(key) && !mProvisionalSelection.contains(key)) {
delta.put(key, true);
}
}
// Now, iterate through the changes and actually add/remove them to/from the current
// selection. This could not be done in the previous loops because changing the size of
// the selection mid-iteration changes iteration order erroneously.
for (Map.Entry<K, Boolean> entry: delta.entrySet()) {
K key = entry.getKey();
if (entry.getValue()) {
mProvisionalSelection.add(key);
} else {
mProvisionalSelection.remove(key);
}
}
return delta;
}
/**
* Saves the existing provisional selection. Once the provisional selection is saved,
* subsequent provisional selections which are different from this existing one cannot
* cause items in this existing provisional selection to become deselected.
*/
void mergeProvisionalSelection() {
mSelection.addAll(mProvisionalSelection);
mProvisionalSelection.clear();
}
/**
* Abandons the existing provisional selection so that all items provisionally selected are
* now deselected.
*/
void clearProvisionalSelection() {
mProvisionalSelection.clear();
}
/**
* Adds a new item to the primary selection.
*
* @return true if the operation resulted in a modification to the selection.
*/
boolean add(@NonNull K key) {
return mSelection.add(key);
}
/**
* Removes an item from the primary selection.
*
* @return true if the operation resulted in a modification to the selection.
*/
boolean remove(@NonNull K key) {
return mSelection.remove(key);
}
/**
* Clears the primary selection. The provisional selection, if any, is unaffected.
*/
void clear() {
mSelection.clear();
}
/**
* Clones primary and provisional selection from supplied {@link Selection}.
* Does not copy active range data.
*/
void copyFrom(@NonNull Selection<K> source) {
mSelection.clear();
mSelection.addAll(source.mSelection);
mProvisionalSelection.clear();
mProvisionalSelection.addAll(source.mProvisionalSelection);
}
@Override
public String toString() {
if (size() <= 0) {
return "size=0, items=[]";
}
StringBuilder buffer = new StringBuilder(size() * 28);
buffer.append("Selection{")
.append("primary{size=" + mSelection.size())
.append(", entries=" + mSelection)
.append("}, provisional{size=" + mProvisionalSelection.size())
.append(", entries=" + mProvisionalSelection)
.append("}}");
return buffer.toString();
}
@Override
public int hashCode() {
return mSelection.hashCode() ^ mProvisionalSelection.hashCode();
}
@Override
public boolean equals(Object other) {
return (this == other)
|| (other instanceof Selection && isEqualTo((Selection) other));
}
private boolean isEqualTo(Selection other) {
return mSelection.equals(other.mSelection)
&& mProvisionalSelection.equals(other.mProvisionalSelection);
}
}

@ -0,0 +1,81 @@
/*
* 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 androidx.annotation.NonNull;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
/**
* Utility class for creating SelectionPredicate instances. Provides default
* implementations for common cases like "single selection" and "select anything".
*/
public final class SelectionPredicates {
private SelectionPredicates() {}
/**
* Returns a selection predicate that allows multiples items to be selected, without
* any restrictions on which items can be selected.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
* @return
*/
public static <K> SelectionPredicate<K> createSelectAnything() {
return new SelectionPredicate<K>() {
@Override
public boolean canSetStateForKey(@NonNull K key, boolean nextState) {
return true;
}
@Override
public boolean canSetStateAtPosition(int position, boolean nextState) {
return true;
}
@Override
public boolean canSelectMultiple() {
return true;
}
};
}
/**
* Returns a selection predicate that allows a single item to be selected, without
* any restrictions on which item can be selected.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
* @return
*/
public static <K> SelectionPredicate<K> createSelectSingleAnything() {
return new SelectionPredicate<K>() {
@Override
public boolean canSetStateForKey(@NonNull K key, boolean nextState) {
return true;
}
@Override
public boolean canSetStateAtPosition(int position, boolean nextState) {
return true;
}
@Override
public boolean canSelectMultiple() {
return false;
}
};
}
}

@ -0,0 +1,817 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.GestureDetector;
import android.view.HapticFeedbackConstants;
import android.view.MotionEvent;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
import java.util.Set;
/**
* SelectionTracker provides support for managing a selection of items in a RecyclerView instance.
*
* <p>
* This class provides support for managing a "primary" set of selected items,
* in addition to a "provisional" set of selected items using conventional
* {@link java.util.Collections}-like methods.
*
* <p>
* Create an instance of SelectionTracker using {@link Builder SelectionTracker.Builder}.
*
* <p>
* <b>Inspecting the current selection</b>
*
* <p>
* The underlying selection is described by the {@link Selection} class.
*
* <p>
* A live view of the current selection can be obtained using {@link #getSelection}. Changes made
* to the selection using SelectionTracker will be immediately reflected in this instance.
*
* <p>
* To obtain a stable snapshot of the selection use {@link #copySelection(MutableSelection)}.
*
* <p>
* Selection state for an individual item can be obtained using {@link #isSelected(Object)}.
*
* <p>
* <b>Provisional Selection</b>
*
* <p>
* Provisional selection exists to address issues where a transitory selection might
* momentarily intersect with a previously established selection resulting in a some
* or all of the established selection being erased. Such situations may arise
* when band selection is being performed in "additive" mode (e.g. SHIFT or CTRL is pressed
* on the keyboard prior to mouse down), or when there's an active gesture selection
* (which can be initiated by long pressing an unselected item while there is an
* existing selection).
*
* <p>
* A provisional selection can be abandoned, or merged into the primary selection.
*
* <p>
* <b>Enforcing selection policies</b>
*
* <p>
* Which items can be selected by the user is a matter of policy in an Application.
* Developers supply these policies by way of {@link SelectionPredicate}.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract class SelectionTracker<K> {
/**
* This value is included in the payload when SelectionTracker notifies RecyclerView
* of changes to selection. Look for this value in the {@code payload}
* Object argument supplied to
* {@link RecyclerView.Adapter#onBindViewHolder
* Adapter#onBindViewHolder}.
* If present the call is occurring in response to a selection state change.
* This would be a good opportunity to animate changes between unselected and selected state.
* When state is being restored, this argument will not be present.
*/
public static final String SELECTION_CHANGED_MARKER = "Selection-Changed";
/**
* Adds {@code observer} to be notified when changes to selection occur.
*
* <p>
* Use an observer to track attributes about the selection and
* update the UI to reflect the state of the selection. For example, an author
* may use an observer to control the enabled status of menu items,
* or to initiate {@link android.view.ActionMode}.
*/
public abstract void addObserver(SelectionObserver observer);
/** @return true if has a selection */
public abstract boolean hasSelection();
/**
* Returns a Selection object that provides a live view on the current selection.
*
* @return The current selection.
* @see #copySelection(MutableSelection) on how to get a snapshot
* of the selection that will not reflect future changes
* to selection.
*/
public abstract Selection<K> getSelection();
/**
* Updates {@code dest} to reflect the current selection.
*/
public abstract void copySelection(@NonNull MutableSelection<K> dest);
/**
* @return true if the item specified by its id is selected. Shorthand for
* {@code getSelection().contains(K)}.
*/
public abstract boolean isSelected(@Nullable K key);
/**
* Restores the selected state of specified items. Used in cases such as restore the selection
* after rotation etc. Provisional selection is not restored.
*
* <p>
* This affords clients the ability to restore selection from selection saved
* in Activity state.
*
* @see StorageStrategy details on selection state support.
*
* @param selection selection being restored.
*/
protected abstract void restoreSelection(@NonNull Selection<K> selection);
/**
* Clears both primary and provisional selections.
*
* @return true if primary selection changed.
*/
public abstract boolean clearSelection();
/**
* Sets the selected state of the specified items if permitted after consulting
* SelectionPredicate.
*/
public abstract boolean setItemsSelected(@NonNull Iterable<K> keys, boolean selected);
/**
* Attempts to select an item.
*
* @return true if the item was selected. False if the item could not be selected, or was
* was already selected.
*/
public abstract boolean select(@NonNull K key);
/**
* Attempts to deselect an item.
*
* @return true if the item was deselected. False if the item could not be deselected, or was
* was already un-selected.
*/
public abstract boolean deselect(@NonNull K key);
abstract AdapterDataObserver getAdapterDataObserver();
/**
* Attempts to establish a range selection at {@code position}, selecting the item
* at {@code position} if needed.
*
* @param position The "anchor" position for the range. Subsequent range operations
* (primarily keyboard and mouse based operations like SHIFT + click)
* work with the established anchor point to define selection ranges.
*/
abstract void startRange(int position);
/**
* Sets the end point for the active range selection.
*
* <p>
* This function should only be called when a range selection is active
* (see {@link #isRangeActive()}. Items in the range [anchor, end] will be
* selected after consulting SelectionPredicate.
*
* @param position The new end position for the selection range.
* @throws IllegalStateException if a range selection is not active. Range selection
* must have been started by a call to {@link #startRange(int)}.
*/
abstract void extendRange(int position);
/**
* Clears an in-progress range selection. Provisional range selection established
* using {@link #extendProvisionalRange(int)} will be cleared (unless
* {@link #mergeProvisionalSelection()} is called first.)
*/
abstract void endRange();
/**
* @return Whether or not there is a current range selection active.
*/
abstract boolean isRangeActive();
/**
* Establishes the "anchor" at which a selection range begins. This "anchor" is consulted
* when determining how to extend, and modify selection ranges. Calling this when a
* range selection is active will reset the range selection.
*
* TODO: Reconcile this with startRange. Maybe just docs need to be updated.
*
* @param position the anchor position. Must already be selected.
*/
abstract void anchorRange(int position);
/**
* Creates a provisional selection from anchor to {@code position}.
*
* @param position the end point.
*/
abstract void extendProvisionalRange(int position);
/**
* Sets the provisional selection, replacing any existing selection.
* @param newSelection
*/
abstract void setProvisionalSelection(@NonNull Set<K> newSelection);
/**
* Clears any existing provisional selection
*/
abstract void clearProvisionalSelection();
/**
* Converts the provisional selection into primary selection, then clears
* provisional selection.
*/
abstract void mergeProvisionalSelection();
/**
* Preserves selection, if any. Call this method from Activity#onSaveInstanceState
*
* @param state Bundle instance supplied to onSaveInstanceState.
*/
public abstract void onSaveInstanceState(@NonNull Bundle state);
/**
* Restores selection from previously saved state. Call this method from
* Activity#onCreate.
*
* @param state Bundle instance supplied to onCreate.
*/
public abstract void onRestoreInstanceState(@Nullable Bundle state);
/**
* Observer class providing access to information about Selection state changes.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract static class SelectionObserver<K> {
/**
* Called when the state of an item has been changed.
*/
public void onItemStateChanged(@NonNull K key, boolean selected) {
}
/**
* Called when the underlying data set has changed. After this method is called
* SelectionTracker will traverse the existing selection,
* calling {@link #onItemStateChanged(K, boolean)} for each selected item,
* and deselecting any items that cannot be selected given the updated data-set
* (and after consulting SelectionPredicate).
*/
public void onSelectionRefresh() {
}
/**
* Called immediately after completion of any set of changes, excluding
* those resulting in calls to {@link #onSelectionRefresh()} and
* {@link #onSelectionRestored()}.
*/
public void onSelectionChanged() {
}
/**
* Called immediately after selection is restored.
* {@link #onItemStateChanged(K, boolean)} will *not* be called
* for individual items in the selection.
*/
public void onSelectionRestored() {
}
}
/**
* Implement SelectionPredicate to control when items can be selected or unselected.
* See {@link Builder#withSelectionPredicate(SelectionPredicate)}.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
public abstract static class SelectionPredicate<K> {
/**
* Validates a change to selection for a specific key.
*
* @param key the item key
* @param nextState the next potential selected/unselected state
* @return true if the item at {@code id} can be set to {@code nextState}.
*/
public abstract boolean canSetStateForKey(@NonNull K key, boolean nextState);
/**
* Validates a change to selection for a specific position. If necessary
* use {@link ItemKeyProvider} to identy associated key.
*
* @param position the item position
* @param nextState the next potential selected/unselected state
* @return true if the item at {@code id} can be set to {@code nextState}.
*/
public abstract boolean canSetStateAtPosition(int position, boolean nextState);
/**
* Permits restriction to single selection mode. Single selection mode has
* unique behaviors in that it'll deselect an item already selected
* in order to select the new item.
*
* <p>
* In order to limit the number of items that can be selected,
* use {@link #canSetStateForKey(Object, boolean)} and
* {@link #canSetStateAtPosition(int, boolean)}.
*
* @return true if more than a single item can be selected.
*/
public abstract boolean canSelectMultiple();
}
/**
* Builder is the primary mechanism for create a {@link SelectionTracker} that
* can be used with your RecyclerView. Once installed, users will be able to create and
* manipulate selection using a variety of intuitive techniques like tap, gesture,
* and mouse lasso.
*
* <p>
* Example usage:
* <pre>SelectionTracker<Uri> tracker = new SelectionTracker.Builder<>(
* "my-uri-selection",
* recyclerView,
* new DemoStableIdProvider(recyclerView.getAdapter()),
* new MyDetailsLookup(recyclerView),
* StorageStrategy.createParcelableStorage(Uri.class))
* .build();
*</pre>
*
* <p>
* <b>Restricting which items can be selected and limiting selection size</b>
*
* <p>
* {@link SelectionPredicate} provides a mechanism to restrict which Items can be selected,
* to limit the number of items that can be selected, as well as allowing the selection
* code to be placed into "single select" mode, which as the name indicates, constrains
* the selection size to a single item.
*
* <p>Configuring the tracker for single single selection support can be done
* by supplying {@link SelectionPredicates#createSelectSingleAnything()}.
*
* SelectionTracker<String> tracker = new SelectionTracker.Builder<>(
* "my-string-selection",
* recyclerView,
* new DemoStableIdProvider(recyclerView.getAdapter()),
* new MyDetailsLookup(recyclerView),
* StorageStrategy.createStringStorage())
* .withSelectionPredicate(SelectionPredicates#createSelectSingleAnything())
* .build();
*</pre>
* <p>
* <b>Retaining state across Android lifecycle events</b>
*
* <p>
* Support for storage/persistence of selection must be configured and invoked manually
* owing to its reliance on Activity lifecycle events.
* Failure to include support for selection storage will result in the active selection
* being lost when the Activity receives a configuration change (e.g. rotation)
* or when the application process is destroyed by the OS to reclaim resources.
*
* <p>
* <b>Key Type</b>
*
* <p>
* Developers must decide on the key type used to identify selected items. Support
* is provided for three types: {@link Parcelable}, {@link String}, and {@link Long}.
*
* <p>
* {@link Parcelable}: Any Parcelable type can be used as the selection key. This is especially
* useful in conjunction with {@link android.net.Uri} as the Android URI implementation is both
* parcelable and makes for a natural stable selection key for values represented by
* the Android Content Provider framework. If items in your view are associated with
* stable {@code content://} uris, you should use Uri for your key type.
*
* <p>
* {@link String}: Use String when a string based stable identifier is available.
*
* <p>
* {@link Long}: Use Long when RecyclerView's long stable ids are
* already in use. It comes with some limitations, however, as access to stable ids
* at runtime is limited. Band selection support is not available when using the default
* long key storage implementation. See {@link StableIdKeyProvider} for details.
*
* <p>
* Usage:
*
* <pre>
* private SelectionTracker<Uri> mTracker;
*
* public void onCreate(Bundle savedInstanceState) {
* // See above for details on constructing a SelectionTracker instance.
*
* if (savedInstanceState != null) {
* mTracker.onRestoreInstanceState(savedInstanceState);
* }
* }
*
* protected void onSaveInstanceState(Bundle outState) {
* super.onSaveInstanceState(outState);
* mTracker.onSaveInstanceState(outState);
* }
* </pre>
*
* @param <K> Selection key type. Built in support is provided for {@link String},
* {@link Long}, and {@link Parcelable}. {@link StorageStrategy}
* provides factory methods for each type:
* {@link StorageStrategy#createStringStorage()},
* {@link StorageStrategy#createParcelableStorage(Class)},
* {@link StorageStrategy#createLongStorage()}
*/
public static final class Builder<K> {
final RecyclerView mRecyclerView;
private final RecyclerView.Adapter<?> mAdapter;
private final Context mContext;
private final String mSelectionId;
private final StorageStrategy<K> mStorage;
SelectionPredicate<K> mSelectionPredicate =
SelectionPredicates.createSelectAnything();
private OperationMonitor mMonitor = new OperationMonitor();
private ItemKeyProvider<K> mKeyProvider;
private ItemDetailsLookup<K> mDetailsLookup;
private FocusDelegate<K> mFocusDelegate = FocusDelegate.dummy();
private OnItemActivatedListener<K> mOnItemActivatedListener;
private OnDragInitiatedListener mOnDragInitiatedListener;
private OnContextClickListener mOnContextClickListener;
private BandPredicate mBandPredicate;
private int mBandOverlayId = eu.faircode.email.R.drawable.selection_band_overlay;
private int[] mGestureToolTypes = new int[] {
MotionEvent.TOOL_TYPE_FINGER,
MotionEvent.TOOL_TYPE_UNKNOWN
};
private int[] mPointerToolTypes = new int[] {
MotionEvent.TOOL_TYPE_MOUSE
};
/**
* Creates a new SelectionTracker.Builder useful for configuring and creating
* a new SelectionTracker for use with your {@link RecyclerView}.
*
* @param selectionId A unique string identifying this selection in the context
* of the activity or fragment.
* @param recyclerView the owning RecyclerView
* @param keyProvider the source of selection keys
* @param detailsLookup the source of information about RecyclerView items.
* @param storage Strategy for type-safe storage of selection state in
* {@link Bundle}.
*/
public Builder(
@NonNull String selectionId,
@NonNull RecyclerView recyclerView,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull ItemDetailsLookup<K> detailsLookup,
@NonNull StorageStrategy<K> storage) {
checkArgument(selectionId != null);
checkArgument(!selectionId.trim().isEmpty());
checkArgument(recyclerView != null);
mSelectionId = selectionId;
mRecyclerView = recyclerView;
mContext = recyclerView.getContext();
mAdapter = recyclerView.getAdapter();
checkArgument(mAdapter != null);
checkArgument(keyProvider != null);
checkArgument(detailsLookup != null);
checkArgument(storage != null);
mDetailsLookup = detailsLookup;
mKeyProvider = keyProvider;
mStorage = storage;
mBandPredicate = new BandPredicate.NonDraggableArea(mRecyclerView, detailsLookup);
}
/**
* Install selection predicate.
*
* @param predicate the predicate to be used.
* @return this
*/
public Builder<K> withSelectionPredicate(
@NonNull SelectionPredicate<K> predicate) {
checkArgument(predicate != null);
mSelectionPredicate = predicate;
return this;
}
/**
* Add operation monitor allowing access to information about active
* operations (like band selection and gesture selection).
*
* @param monitor the monitor to be used
* @return this
*/
public Builder<K> withOperationMonitor(
@NonNull OperationMonitor monitor) {
checkArgument(monitor != null);
mMonitor = monitor;
return this;
}
/**
* Add focus delegate to interact with selection related focus changes.
*
* @param delegate the delegate to be used
* @return this
*/
public Builder<K> withFocusDelegate(@NonNull FocusDelegate<K> delegate) {
checkArgument(delegate != null);
mFocusDelegate = delegate;
return this;
}
/**
* Adds an item activation listener. Respond to taps/enter/double-click on items.
*
* @param listener the listener to be used
* @return this
*/
public Builder<K> withOnItemActivatedListener(
@NonNull OnItemActivatedListener<K> listener) {
checkArgument(listener != null);
mOnItemActivatedListener = listener;
return this;
}
/**
* Adds a context click listener. Respond to right-click.
*
* @param listener the listener to be used
* @return this
*/
public Builder<K> withOnContextClickListener(
@NonNull OnContextClickListener listener) {
checkArgument(listener != null);
mOnContextClickListener = listener;
return this;
}
/**
* Adds a drag initiated listener. Add support for drag and drop.
*
* @param listener the listener to be used
* @return this
*/
public Builder<K> withOnDragInitiatedListener(
@NonNull OnDragInitiatedListener listener) {
checkArgument(listener != null);
mOnDragInitiatedListener = listener;
return this;
}
/**
* Replaces default tap and gesture tool-types. Defaults are:
* {@link MotionEvent#TOOL_TYPE_FINGER} and {@link MotionEvent#TOOL_TYPE_UNKNOWN}.
*
* @param toolTypes the tool types to be used
* @return this
*/
public Builder<K> withGestureTooltypes(int... toolTypes) {
mGestureToolTypes = toolTypes;
return this;
}
/**
* Replaces default band overlay.
*
* @param bandOverlayId
* @return this
*/
public Builder<K> withBandOverlay(@DrawableRes int bandOverlayId) {
mBandOverlayId = bandOverlayId;
return this;
}
/**
* Replaces default band predicate.
* @param bandPredicate
* @return this
*/
public Builder<K> withBandPredicate(@NonNull BandPredicate bandPredicate) {
checkArgument(bandPredicate != null);
mBandPredicate = bandPredicate;
return this;
}
/**
* Replaces default pointer tool-types. Pointer tools
* are associated with band selection, and certain
* drag and drop behaviors. Defaults are:
* {@link MotionEvent#TOOL_TYPE_MOUSE}.
*
* @param toolTypes the tool types to be used
* @return this
*/
public Builder<K> withPointerTooltypes(int... toolTypes) {
mPointerToolTypes = toolTypes;
return this;
}
/**
* Prepares and returns a SelectionTracker.
*
* @return this
*/
public SelectionTracker<K> build() {
SelectionTracker<K> tracker = new DefaultSelectionTracker<>(
mSelectionId, mKeyProvider, mSelectionPredicate, mStorage);
// Event glue between RecyclerView and SelectionTracker keeps the classes separate
// so that a SelectionTracker can be shared across RecyclerView instances that
// represent the same data in different ways.
EventBridge.install(mAdapter, tracker, mKeyProvider);
AutoScroller scroller =
new ViewAutoScroller(ViewAutoScroller.createScrollHost(mRecyclerView));
// Setup basic input handling, with the touch handler as the default consumer
// of events. If mouse handling is configured as well, the mouse input
// related handlers will intercept mouse input events.
// GestureRouter is responsible for routing GestureDetector events
// to tool-type specific handlers.
GestureRouter<MotionInputHandler> gestureRouter = new GestureRouter<>();
GestureDetector gestureDetector = new GestureDetector(mContext, gestureRouter);
// TouchEventRouter takes its name from RecyclerView#OnItemTouchListener.
// Despite "Touch" being in the name, it receives events for all types of tools.
// This class is responsible for routing events to tool-type specific handlers,
// and if not handled by a handler, on to a GestureDetector for analysis.
TouchEventRouter eventRouter = new TouchEventRouter(gestureDetector);
// GestureSelectionHelper provides logic that interprets a combination
// of motions and gestures in order to provide gesture driven selection support
// when used in conjunction with RecyclerView.
final GestureSelectionHelper gestureHelper = GestureSelectionHelper.create(
tracker, mDetailsLookup, mRecyclerView, scroller, mMonitor);
// Finally hook the framework up to listening to recycle view events.
mRecyclerView.addOnItemTouchListener(eventRouter);
// But before you move on, there's more work to do. Event plumbing has been
// installed, but we haven't registered any of our helpers or callbacks.
// Helpers contain predefined logic converting events into selection related events.
// Callbacks provide developers the ability to reponspond to other types of
// events (like "activate" a tapped item). This is broken up into two main
// suites, one for "touch" and one for "mouse", though both can and should (usually)
// be configured to handle other types of input (to satisfy user expectation).);
// Internally, the code doesn't permit nullable listeners, so we lazily
// initialize dummy instances if the developer didn't supply a real listener.
mOnDragInitiatedListener = (mOnDragInitiatedListener != null)
? mOnDragInitiatedListener
: new OnDragInitiatedListener() {
@Override
public boolean onDragInitiated(@NonNull MotionEvent e) {
return false;
}
};
mOnItemActivatedListener = (mOnItemActivatedListener != null)
? mOnItemActivatedListener
: new OnItemActivatedListener<K>() {
@Override
public boolean onItemActivated(
@NonNull ItemDetailsLookup.ItemDetails<K> item,
@NonNull MotionEvent e) {
return false;
}
};
mOnContextClickListener = (mOnContextClickListener != null)
? mOnContextClickListener
: new OnContextClickListener() {
@Override
public boolean onContextClick(@NonNull MotionEvent e) {
return false;
}
};
// Provides high level glue for binding touch events
// and gestures to selection framework.
TouchInputHandler<K> touchHandler = new TouchInputHandler<K>(
tracker,
mKeyProvider,
mDetailsLookup,
mSelectionPredicate,
new Runnable() {
@Override
public void run() {
if (mSelectionPredicate.canSelectMultiple()) {
try {
gestureHelper.start();
} catch (IllegalStateException ex) {
ex.printStackTrace();
}
}
}
},
mOnDragInitiatedListener,
mOnItemActivatedListener,
mFocusDelegate,
new Runnable() {
@Override
public void run() {
mRecyclerView.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);
}
});
for (int toolType : mGestureToolTypes) {
gestureRouter.register(toolType, touchHandler);
eventRouter.register(toolType, gestureHelper);
}
// Provides high level glue for binding mouse events and gestures
// to selection framework.
MouseInputHandler<K> mouseHandler = new MouseInputHandler<>(
tracker,
mKeyProvider,
mDetailsLookup,
mOnContextClickListener,
mOnItemActivatedListener,
mFocusDelegate);
for (int toolType : mPointerToolTypes) {
gestureRouter.register(toolType, mouseHandler);
}
@Nullable BandSelectionHelper bandHelper = null;
// Band selection not supported in single select mode, or when key access
// is limited to anything less than the entire corpus.
if (mKeyProvider.hasAccess(ItemKeyProvider.SCOPE_MAPPED)
&& mSelectionPredicate.canSelectMultiple()) {
// BandSelectionHelper provides support for band selection on-top of a RecyclerView
// instance. Given the recycling nature of RecyclerView BandSelectionController
// necessarily models and caches list/grid information as the user's pointer
// interacts with the item in the RecyclerView. Selectable items that intersect
// with the band, both on and off screen, are selected.
bandHelper = BandSelectionHelper.create(
mRecyclerView,
scroller,
mBandOverlayId,
mKeyProvider,
tracker,
mSelectionPredicate,
mBandPredicate,
mFocusDelegate,
mMonitor);
}
OnItemTouchListener pointerEventHandler = new PointerDragEventInterceptor(
mDetailsLookup, mOnDragInitiatedListener, bandHelper);
for (int toolType : mPointerToolTypes) {
eventRouter.register(toolType, pointerEventHandler);
}
return tracker;
}
}
}

@ -0,0 +1,28 @@
/*
* 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;
/**
* Shared constants used in this package. Disable DEBUG and VERBOSE prior to releases.
*/
final class Shared {
static final boolean DEBUG = false;
static final boolean VERBOSE = false;
private Shared() {}
}

@ -0,0 +1,110 @@
/*
* 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 android.util.SparseArray;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnChildAttachStateChangeListener;
import java.util.HashMap;
import java.util.Map;
/**
* An {@link ItemKeyProvider} that provides stable ids by way of cached
* {@link RecyclerView.Adapter} stable ids. Items enter the cache as they are laid out by
* RecyclerView, and are removed from the cache as they are recycled.
*
* <p>
* There are trade-offs with this implementation as it necessarily auto-boxes {@code long}
* stable id values into {@code Long} values for use as selection keys. The core Selection API
* uses a parameterized key type to permit other keys (such as Strings or URIs).
*/
public final class StableIdKeyProvider extends ItemKeyProvider<Long> {
private final SparseArray<Long> mPositionToKey = new SparseArray<>();
private final Map<Long, Integer> mKeyToPosition = new HashMap<Long, Integer>();
private final RecyclerView mRecyclerView;
/**
* Creates a new key provider that uses cached {@code long} stable ids associated
* with the RecyclerView items.
*
* @param recyclerView the owner RecyclerView
*/
public StableIdKeyProvider(@NonNull RecyclerView recyclerView) {
// Since this provide is based on stable ids based on whats laid out in the window
// we can only satisfy "window" scope key access.
super(SCOPE_CACHED);
mRecyclerView = recyclerView;
mRecyclerView.addOnChildAttachStateChangeListener(
new OnChildAttachStateChangeListener() {
@Override
public void onChildViewAttachedToWindow(View view) {
onAttached(view);
}
@Override
public void onChildViewDetachedFromWindow(View view) {
onDetached(view);
}
}
);
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void onAttached(@NonNull View view) {
RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view);
int position = holder.getAdapterPosition();
long id = holder.getItemId();
if (position != RecyclerView.NO_POSITION && id != RecyclerView.NO_ID) {
mPositionToKey.put(position, id);
mKeyToPosition.put(id, position);
}
}
@SuppressWarnings("WeakerAccess") /* synthetic access */
void onDetached(@NonNull View view) {
RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view);
int position = holder.getAdapterPosition();
long id = holder.getItemId();
if (position != RecyclerView.NO_POSITION && id != RecyclerView.NO_ID) {
mPositionToKey.delete(position);
mKeyToPosition.remove(id);
}
}
@Override
public @Nullable Long getKey(int position) {
return mPositionToKey.get(position, null);
}
@Override
public int getPosition(@NonNull Long key) {
if (mKeyToPosition.containsKey(key)) {
return mKeyToPosition.get(key);
}
return RecyclerView.NO_POSITION;
}
}

@ -0,0 +1,230 @@
/*
* Copyright 2018 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.core.util.Preconditions.checkArgument;
import android.os.Bundle;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.util.ArrayList;
/**
* Strategy for storing keys in saved state. Extend this class when using custom
* key types that aren't supported by default. Prefer use of builtin storage strategies:
* {@link #createStringStorage()}, {@link #createLongStorage()},
* {@link #createParcelableStorage(Class)}.
*
* <p>
* See
* {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder}
* for more detailed advice on which key type to use for your selection keys.
*
* @param <K> Selection key type. Built in support is provided for String, Long, and Parcelable
* types. Use the respective factory method to create a StorageStrategy instance
* appropriate to the desired type.
* {@link #createStringStorage()},
* {@link #createParcelableStorage(Class)},
* {@link #createLongStorage()}
*/
public abstract class StorageStrategy<K> {
@VisibleForTesting
static final String SELECTION_ENTRIES = "androidx.recyclerview.selection.entries";
@VisibleForTesting
static final String SELECTION_KEY_TYPE = "androidx.recyclerview.selection.type";
private final Class<K> mType;
/**
* Creates a new instance.
*
* @param type the key type class that is being used.
*/
public StorageStrategy(@NonNull Class<K> type) {
checkArgument(type != null);
mType = type;
}
/**
* Create a {@link Selection} from supplied {@link Bundle}.
*
* @param state Bundle instance that may contain parceled Selection instance.
* @return
*/
public abstract @Nullable Selection<K> asSelection(@NonNull Bundle state);
/**
* Creates a {@link Bundle} from supplied {@link Selection}.
*
* @param selection The selection to asBundle.
* @return
*/
public abstract @NonNull Bundle asBundle(@NonNull Selection<K> selection);
String getKeyTypeName() {
return mType.getCanonicalName();
}
/**
* @return StorageStrategy suitable for use with {@link Parcelable} keys
* (like {@link android.net.Uri}).
*/
public static <K extends Parcelable> StorageStrategy<K> createParcelableStorage(Class<K> type) {
return new ParcelableStorageStrategy(type);
}
/**
* @return StorageStrategy suitable for use with {@link String} keys.
*/
public static StorageStrategy<String> createStringStorage() {
return new StringStorageStrategy();
}
/**
* @return StorageStrategy suitable for use with {@link Long} keys.
*/
public static StorageStrategy<Long> createLongStorage() {
return new LongStorageStrategy();
}
private static class StringStorageStrategy extends StorageStrategy<String> {
StringStorageStrategy() {
super(String.class);
}
@Override
public @Nullable Selection<String> asSelection(@NonNull Bundle state) {
String keyType = state.getString(SELECTION_KEY_TYPE, null);
if (keyType == null || !keyType.equals(getKeyTypeName())) {
return null;
}
@Nullable ArrayList<String> stored = state.getStringArrayList(SELECTION_ENTRIES);
if (stored == null) {
return null;
}
Selection<String> selection = new Selection<>();
selection.mSelection.addAll(stored);
return selection;
}
@Override
public @NonNull Bundle asBundle(@NonNull Selection<String> selection) {
Bundle bundle = new Bundle();
bundle.putString(SELECTION_KEY_TYPE, getKeyTypeName());
ArrayList<String> value = new ArrayList<>(selection.size());
value.addAll(selection.mSelection);
bundle.putStringArrayList(SELECTION_ENTRIES, value);
return bundle;
}
}
private static class LongStorageStrategy extends StorageStrategy<Long> {
LongStorageStrategy() {
super(Long.class);
}
@Override
public @Nullable Selection<Long> asSelection(@NonNull Bundle state) {
String keyType = state.getString(SELECTION_KEY_TYPE, null);
if (keyType == null || !keyType.equals(getKeyTypeName())) {
return null;
}
@Nullable long[] stored = state.getLongArray(SELECTION_ENTRIES);
if (stored == null) {
return null;
}
Selection<Long> selection = new Selection<>();
for (long key : stored) {
selection.mSelection.add(key);
}
return selection;
}
@Override
public @NonNull Bundle asBundle(@NonNull Selection<Long> selection) {
Bundle bundle = new Bundle();
bundle.putString(SELECTION_KEY_TYPE, getKeyTypeName());
long[] value = new long[selection.size()];
int i = 0;
for (Long key : selection) {
value[i++] = key;
}
bundle.putLongArray(SELECTION_ENTRIES, value);
return bundle;
}
}
private static class ParcelableStorageStrategy<K extends Parcelable>
extends StorageStrategy<K> {
ParcelableStorageStrategy(Class<K> type) {
super(type);
checkArgument(Parcelable.class.isAssignableFrom(type));
}
@Override
public @Nullable Selection<K> asSelection(@NonNull Bundle state) {
String keyType = state.getString(SELECTION_KEY_TYPE, null);
if (keyType == null || !keyType.equals(getKeyTypeName())) {
return null;
}
@Nullable ArrayList<K> stored = state.getParcelableArrayList(SELECTION_ENTRIES);
if (stored == null) {
return null;
}
Selection<K> selection = new Selection<>();
selection.mSelection.addAll(stored);
return selection;
}
@Override
public @NonNull Bundle asBundle(@NonNull Selection<K> selection) {
Bundle bundle = new Bundle();
bundle.putString(SELECTION_KEY_TYPE, getKeyTypeName());
ArrayList<K> value = new ArrayList<>(selection.size());
value.addAll(selection.mSelection);
bundle.putParcelableArrayList(SELECTION_ENTRIES, value);
return bundle;
}
}
}

@ -0,0 +1,74 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import java.util.List;
/**
* Registry for tool specific event handler. This provides map like functionality,
* along with fallback to a default handler, while avoiding auto-boxing of tool
* type values that would be necessitated where a Map used.
*
* @param <T> type of item being registered.
*/
final class ToolHandlerRegistry<T> {
// Currently there are four known input types. ERASER is the last one, so has the
// highest value. UNKNOWN is zero, so we add one. This allows delegates to be
// registered by type, and avoid the auto-boxing that would be necessary were we
// to store delegates in a Map<Integer, Delegate>.
private static final int NUM_INPUT_TYPES = MotionEvent.TOOL_TYPE_ERASER + 1;
private final List<T> mHandlers = Arrays.asList(null, null, null, null, null);
private final T mDefault;
ToolHandlerRegistry(@NonNull T defaultDelegate) {
checkArgument(defaultDelegate != null);
mDefault = defaultDelegate;
// Initialize all values to null.
for (int i = 0; i < NUM_INPUT_TYPES; i++) {
mHandlers.set(i, null);
}
}
/**
* @param toolType
* @param delegate the delegate, or null to unregister.
* @throws IllegalStateException if an tooltype handler is already registered.
*/
void set(int toolType, @Nullable T delegate) {
checkArgument(toolType >= 0 && toolType <= MotionEvent.TOOL_TYPE_ERASER);
checkState(mHandlers.get(toolType) == null);
mHandlers.set(toolType, delegate);
}
T get(@NonNull MotionEvent e) {
T d = mHandlers.get(e.getToolType(0));
return d != null ? d : mDefault;
}
}

@ -0,0 +1,113 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.view.GestureDetector;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
/**
* A class responsible for routing MotionEvents to tool-type specific handlers,
* and if not handled by a handler, on to a {@link GestureDetector} for further
* processing.
*
* <p>
* TouchEventRouter takes its name from
* {@link RecyclerView#addOnItemTouchListener(OnItemTouchListener)}. Despite "Touch"
* being in the name, it receives MotionEvents for all types of tools.
*/
final class TouchEventRouter implements OnItemTouchListener {
private static final String TAG = "TouchEventRouter";
private final GestureDetector mDetector;
private final ToolHandlerRegistry<OnItemTouchListener> mDelegates;
TouchEventRouter(
@NonNull GestureDetector detector, @NonNull OnItemTouchListener defaultDelegate) {
checkArgument(detector != null);
checkArgument(defaultDelegate != null);
mDetector = detector;
mDelegates = new ToolHandlerRegistry<>(defaultDelegate);
}
TouchEventRouter(@NonNull GestureDetector detector) {
this(
detector,
// Supply a fallback listener does nothing...because the caller
// didn't supply a fallback.
new OnItemTouchListener() {
@Override
public boolean onInterceptTouchEvent(
@NonNull RecyclerView unused, @NonNull MotionEvent e) {
return false;
}
@Override
public void onTouchEvent(
@NonNull RecyclerView unused, @NonNull MotionEvent e) {
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
}
});
}
/**
* @param toolType See MotionEvent for details on available types.
* @param delegate An {@link OnItemTouchListener} to receive events
* of {@code toolType}.
*/
void register(int toolType, @NonNull OnItemTouchListener delegate) {
checkArgument(delegate != null);
mDelegates.set(toolType, delegate);
}
@Override
public boolean onInterceptTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
boolean handled = mDelegates.get(e).onInterceptTouchEvent(rv, e);
// Forward all events to UserInputHandler.
// This is necessary since UserInputHandler needs to always see the first DOWN event. Or
// else all future UP events will be tossed.
handled |= mDetector.onTouchEvent(e);
return handled;
}
@Override
public void onTouchEvent(@NonNull RecyclerView rv, @NonNull MotionEvent e) {
mDelegates.get(e).onTouchEvent(rv, e);
// Note: even though this event is being handled as part of gestures such as drag and band,
// continue forwarding to the GestureDetector. The detector needs to see the entire cluster
// of events in order to properly interpret other gestures, such as long press.
mDetector.onTouchEvent(e);
}
@Override
public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
}

@ -0,0 +1,152 @@
/*
* 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.core.util.Preconditions.checkArgument;
import android.util.Log;
import android.view.MotionEvent;
import androidx.annotation.NonNull;
import androidx.recyclerview.selection.ItemDetailsLookup.ItemDetails;
import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
import androidx.recyclerview.widget.RecyclerView;
/**
* A MotionInputHandler that provides the high-level glue for touch driven selection. This class
* works with {@link RecyclerView}, {@link GestureRouter}, and {@link GestureSelectionHelper} to
* to implement the primary policies around touch input.
*
* @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
*/
final class TouchInputHandler<K> extends MotionInputHandler<K> {
private static final String TAG = "TouchInputDelegate";
private static final boolean DEBUG = false;
private final ItemDetailsLookup<K> mDetailsLookup;
private final SelectionPredicate<K> mSelectionPredicate;
private final OnItemActivatedListener<K> mOnItemActivatedListener;
private final OnDragInitiatedListener mOnDragInitiatedListener;
private final Runnable mGestureStarter;
private final Runnable mHapticPerformer;
TouchInputHandler(
@NonNull SelectionTracker<K> selectionTracker,
@NonNull ItemKeyProvider<K> keyProvider,
@NonNull ItemDetailsLookup<K> detailsLookup,
@NonNull SelectionPredicate<K> selectionPredicate,
@NonNull Runnable gestureStarter,
@NonNull OnDragInitiatedListener onDragInitiatedListener,
@NonNull OnItemActivatedListener<K> onItemActivatedListener,
@NonNull FocusDelegate<K> focusDelegate,
@NonNull Runnable hapticPerformer) {
super(selectionTracker, keyProvider, focusDelegate);
checkArgument(detailsLookup != null);
checkArgument(selectionPredicate != null);
checkArgument(gestureStarter != null);
checkArgument(onItemActivatedListener != null);
checkArgument(onDragInitiatedListener != null);
checkArgument(hapticPerformer != null);
mDetailsLookup = detailsLookup;
mSelectionPredicate = selectionPredicate;
mGestureStarter = gestureStarter;
mOnItemActivatedListener = onItemActivatedListener;
mOnDragInitiatedListener = onDragInitiatedListener;
mHapticPerformer = hapticPerformer;
}
@Override
public boolean onSingleTapUp(@NonNull MotionEvent e) {
if (!mDetailsLookup.overItemWithSelectionKey(e)) {
if (DEBUG) Log.d(TAG, "Tap not associated w/ model item. Clearing selection.");
mSelectionTracker.clearSelection();
return false;
}
ItemDetails<K> item = mDetailsLookup.getItemDetails(e);
// Should really not be null at this point, but...
if (item == null) {
return false;
}
if (mSelectionTracker.hasSelection()) {
if (isRangeExtension(e)) {
extendSelectionRange(item);
} else if (mSelectionTracker.isSelected(item.getSelectionKey())) {
mSelectionTracker.deselect(item.getSelectionKey());
} else {
selectItem(item);
}
return true;
}
// Touch events select if they occur in the selection hotspot,
// otherwise they activate.
return item.inSelectionHotspot(e)
? selectItem(item)
: mOnItemActivatedListener.onItemActivated(item, e);
}
@Override
public void onLongPress(@NonNull MotionEvent e) {
if (!mDetailsLookup.overItemWithSelectionKey(e)) {
if (DEBUG) Log.d(TAG, "Ignoring LongPress on non-model-backed item.");
return;
}
ItemDetails<K> item = mDetailsLookup.getItemDetails(e);
// Should really not be null at this point, but...
if (item == null) {
return;
}
boolean handled = false;
if (isRangeExtension(e)) {
extendSelectionRange(item);
handled = true;
} else {
if (!mSelectionTracker.isSelected(item.getSelectionKey())
&& mSelectionPredicate.canSetStateForKey(item.getSelectionKey(), true)) {
// If we cannot select it, we didn't apply anchoring - therefore should not
// start gesture selection
if (selectItem(item)) {
// And finally if the item was selected && we can select multiple
// we kick off gesture selection.
if (mSelectionPredicate.canSelectMultiple()) {
mGestureStarter.run();
}
handled = true;
}
} else {
// We only initiate drag and drop on long press for touch to allow regular
// touch-based scrolling
mOnDragInitiatedListener.onDragInitiated(e);
handled = true;
}
}
if (handled) {
mHapticPerformer.run();
}
}
}

@ -0,0 +1,273 @@
/*
* 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.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import static androidx.recyclerview.selection.Shared.DEBUG;
import static androidx.recyclerview.selection.Shared.VERBOSE;
import android.graphics.Point;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;
/**
* Provides auto-scrolling upon request when user's interaction with the application
* introduces a natural intent to scroll. Used by BandSelectionHelper and GestureSelectionHelper,
* to provide auto scrolling when user is performing selection operations.
*/
final class ViewAutoScroller extends AutoScroller {
private static final String TAG = "ViewAutoScroller";
// ratio used to calculate the top/bottom hotspot region; used with view height
private static final float DEFAULT_SCROLL_THRESHOLD_RATIO = 0.125f;
private static final int MAX_SCROLL_STEP = 70;
private final float mScrollThresholdRatio;
private final ScrollHost mHost;
private final Runnable mRunner;
private @Nullable Point mOrigin;
private @Nullable Point mLastLocation;
private boolean mPassedInitialMotionThreshold;
ViewAutoScroller(@NonNull ScrollHost scrollHost) {
this(scrollHost, DEFAULT_SCROLL_THRESHOLD_RATIO);
}
@VisibleForTesting
ViewAutoScroller(@NonNull ScrollHost scrollHost, float scrollThresholdRatio) {
checkArgument(scrollHost != null);
mHost = scrollHost;
mScrollThresholdRatio = scrollThresholdRatio;
mRunner = new Runnable() {
@Override
public void run() {
runScroll();
}
};
}
@Override
public void reset() {
mHost.removeCallback(mRunner);
mOrigin = null;
mLastLocation = null;
mPassedInitialMotionThreshold = false;
}
@Override
public void scroll(@NonNull Point location) {
mLastLocation = location;
// See #aboveMotionThreshold for details on how we track initial location.
if (mOrigin == null) {
mOrigin = location;
if (VERBOSE) Log.v(TAG, "Origin @ " + mOrigin);
}
if (VERBOSE) Log.v(TAG, "Current location @ " + mLastLocation);
mHost.runAtNextFrame(mRunner);
}
/**
* Attempts to smooth-scroll the view at the given UI frame. Application should be
* responsible to do any clean up (such as unsubscribing scrollListeners) after the run has
* finished, and re-run this method on the next UI frame if applicable.
*/
@SuppressWarnings("WeakerAccess") /* synthetic access */
void runScroll() {
if (DEBUG) checkState(mLastLocation != null);
if (VERBOSE) Log.v(TAG, "Running in background using event location @ " + mLastLocation);
// Compute the number of pixels the pointer's y-coordinate is past the view.
// Negative values mean the pointer is at or before the top of the view, and
// positive values mean that the pointer is at or after the bottom of the view. Note
// that top/bottom threshold is added here so that the view still scrolls when the
// pointer are in these buffer pixels.
int pixelsPastView = 0;
final int verticalThreshold = (int) (mHost.getViewHeight()
* mScrollThresholdRatio);
if (mLastLocation.y <= verticalThreshold) {
pixelsPastView = mLastLocation.y - verticalThreshold;
} else if (mLastLocation.y >= mHost.getViewHeight()
- verticalThreshold) {
pixelsPastView = mLastLocation.y - mHost.getViewHeight()
+ verticalThreshold;
}
if (pixelsPastView == 0) {
// If the operation that started the scrolling is no longer inactive, or if it is active
// but not at the edge of the view, no scrolling is necessary.
return;
}
// We're in one of the endzones. Now determine if there's enough of a difference
// from the orgin to take any action. Basically if a user has somehow initiated
// selection, but is hovering at or near their initial contact point, we don't
// scroll. This avoids a situation where the user initiates selection in an "endzone"
// only to have scrolling start automatically.
if (!mPassedInitialMotionThreshold && !aboveMotionThreshold(mLastLocation)) {
if (VERBOSE) Log.v(TAG, "Ignoring event below motion threshold.");
return;
}
mPassedInitialMotionThreshold = true;
if (pixelsPastView > verticalThreshold) {
pixelsPastView = verticalThreshold;
}
// Compute the number of pixels to scroll, and scroll that many pixels.
final int numPixels = computeScrollDistance(pixelsPastView);
mHost.scrollBy(numPixels);
// Replace any existing scheduled jobs with the latest and greatest..
mHost.removeCallback(mRunner);
mHost.runAtNextFrame(mRunner);
}
private boolean aboveMotionThreshold(@NonNull Point location) {
// We reuse the scroll threshold to calculate a much smaller area
// in which we ignore motion initially.
int motionThreshold =
(int) ((mHost.getViewHeight() * mScrollThresholdRatio)
* (mScrollThresholdRatio * 2));
return Math.abs(mOrigin.y - location.y) >= motionThreshold;
}
/**
* Computes the number of pixels to scroll based on how far the pointer is past the end
* of the region. Roughly based on ItemTouchHelper's algorithm for computing the number of
* pixels to scroll when an item is dragged to the end of a view.
* @return
*/
@VisibleForTesting
int computeScrollDistance(int pixelsPastView) {
final int topBottomThreshold =
(int) (mHost.getViewHeight() * mScrollThresholdRatio);
final int direction = (int) Math.signum(pixelsPastView);
final int absPastView = Math.abs(pixelsPastView);
// Calculate the ratio of how far out of the view the pointer currently resides to
// the top/bottom scrolling hotspot of the view.
final float outOfBoundsRatio = Math.min(
1.0f, (float) absPastView / topBottomThreshold);
// Interpolate this ratio and use it to compute the maximum scroll that should be
// possible for this step.
final int cappedScrollStep =
(int) (direction * MAX_SCROLL_STEP * smoothOutOfBoundsRatio(outOfBoundsRatio));
// If the final number of pixels to scroll ends up being 0, the view should still
// scroll at least one pixel.
return cappedScrollStep != 0 ? cappedScrollStep : direction;
}
/**
* Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
* at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
* drags that are at the edge or barely past the edge of the threshold does little to no
* scrolling, while drags that are near the edge of the view does a lot of
* scrolling. The equation y=x^10 is used, but this could also be tweaked if
* needed.
* @param ratio A ratio which is in the range [0, 1].
* @return A "smoothed" value, also in the range [0, 1].
*/
private float smoothOutOfBoundsRatio(float ratio) {
return (float) Math.pow(ratio, 10);
}
/**
* Used by to calculate the proper amount of pixels to scroll given time passed
* since scroll started, and to properly scroll / proper listener clean up if necessary.
*
* Callback used by scroller to perform UI tasks, such as scrolling and rerunning at next UI
* cycle.
*/
abstract static class ScrollHost {
/**
* @return height of the view.
*/
abstract int getViewHeight();
/**
* @param dy distance to scroll.
*/
abstract void scrollBy(int dy);
/**
* @param r schedule runnable to be run at next convenient time.
*/
abstract void runAtNextFrame(@NonNull Runnable r);
/**
* @param r remove runnable from being run.
*/
abstract void removeCallback(@NonNull Runnable r);
}
static ScrollHost createScrollHost(final RecyclerView recyclerView) {
return new RuntimeHost(recyclerView);
}
/**
* Tracks location of last surface contact as reported by RecyclerView.
*/
private static final class RuntimeHost extends ScrollHost {
private final RecyclerView mRecyclerView;
RuntimeHost(@NonNull RecyclerView recyclerView) {
mRecyclerView = recyclerView;
}
@Override
void runAtNextFrame(@NonNull Runnable r) {
ViewCompat.postOnAnimation(mRecyclerView, r);
}
@Override
void removeCallback(@NonNull Runnable r) {
mRecyclerView.removeCallbacks(r);
}
@Override
void scrollBy(int dy) {
if (VERBOSE) Log.v(TAG, "Scrolling view by: " + dy);
mRecyclerView.scrollBy(0, dy);
}
@Override
int getViewHeight() {
return mRecyclerView.getHeight();
}
}
}

@ -0,0 +1,129 @@
/*
* Copyright 2018 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.
*/
/**
* A {@link androidx.recyclerview.widget.RecyclerView RecyclerView} addon library providing
* support for item selection. The library provides support for both touch
* and mouse driven selection. Developers retain control over the visual representation,
* and the policies controlling selection behavior (like which items are eligible
* for selection, and how many items can be selected.)
*
* <p>
* Want to add selection support to your RecyclerView? Here's how you do it:
*
* <p>
* <b>Determine which selection key type to use, then build your KeyProvider</b>
*
* <p>
* Developers must decide on the key type used to identify selected items. Support
* is provided for three types: {@link android.os.Parcelable Parcelable},
* {@link java.lang.String String}, and {@link java.lang.Long Long}.
*
* <p>
* See
* {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder}
* for more detailed advice on which key type to use for your selection keys.
*
* <p>
* <b>Implement {@link androidx.recyclerview.selection.ItemDetailsLookup ItemDetailsLookup}
* </b>
*
* <p>
* This class provides the selection library code necessary access to information about
* items associated with {@link android.view.MotionEvent}. This will likely
* depend on concrete {@link androidx.recyclerview.widget.RecyclerView.ViewHolder
* RecyclerView.ViewHolder} type employed by your application.
*
* <p>
* <b>Update views used in RecyclerView to reflect selected state</b>
*
* <p>
* When the user selects an item the library will record that in
* {@link androidx.recyclerview.selection.SelectionTracker SelectionTracker}
* then notify RecyclerView that the state of the item has changed. This
* will ultimately cause the value to be rebound by way of
* {@link androidx.recyclerview.widget.RecyclerView.Adapter#onBindViewHolder
* RecyclerView.Adapter#onBindViewHolder}. The item must then be updated
* to reflect the new selection status. Without this
* the user will not *see* that the item has been selected.
*
* <ul>
* <li>In Adapter#onBindViewHolder, set the "activated" status on view.
* Note that the status should be "activated" not "selected".
* See <a href="https://developer.android.com/reference/android/view/View.html#setActivated(boolean)">
* View.html#setActivated</a> for details.
* <li>Update the styling of the view to represent the activated status. This can be done
* with a
* <a href="https://developer.android.com/guide/topics/resources/color-list-resource.html">
* color state list</a>.
* </ul>
*
* <p>
* <b>Use {@link androidx.appcompat.view.ActionMode ActionMode} when there is a selection</b>
*
* <p>
* Register a {@link androidx.recyclerview.selection.SelectionTracker.SelectionObserver}
* to be notified when selection changes. When a selection is first created, start
* {@link androidx.appcompat.view.ActionMode ActionMode} to represent this to the user,
* and provide selection specific actions.
*
* <p>
* <b>Interpreted secondary actions: Drag and Drop, and Item Activation</b>
*
* <p>
* At the end of the event processing pipeline the library may determine that the user
* is attempting to activate an item by tapping it, or is attempting to drag and drop
* an item or set of selected items. React to these interpretations by registering a
* respective listener. See
* {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder}
* for details.
*
* <p>
* <b>Assemble everything with
* {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder}
* </b>
*
* <p>
* Example usage (with {@code Long} selection keys:
* <pre>SelectionTracker<Long> tracker = new SelectionTracker.Builder<>(
* "my-selection-id",
* recyclerView,
* new StableIdKeyProvider(recyclerView),
* new MyDetailsLookup(recyclerView),
* StorageStrategy.createLongStorage())
* .build();
*</pre>
*
* <p>In order to build a SelectionTracker instance the supplied RecyclerView must be initialized
* with an Adapter. Given this fact, you will probably need to inject the SelectionTracker
* instance into your RecyclerView.Adapter after the Adapter is created, as it will be necessary
* to consult selected status using SelectionTracker from the onBindViewHolder method.
*
* <p>
* <b>Include Selection in Activity lifecycle events</b>
*
* <p>
* In order to preserve state the author must the selection library in handling
* of Activity lifecycle events. See SelectionTracker#onSaveInstanceState
* and SelectionTracker#onRestoreInstanceState.
*
* <p>A unique selection id must be supplied to
* {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder}
* constructor. This is necessary as an activity or fragment may have multiple distinct
* selectable lists that may both need to be persisted in saved state.
*/
package androidx.recyclerview.selection;

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#339999ff" />
<stroke android:width="1dp" android:color="#44000000" />
</shape>
Loading…
Cancel
Save