From c761642ef2f7461aa911d16d6f709b82425d855d Mon Sep 17 00:00:00 2001 From: M66B Date: Thu, 7 Feb 2019 17:21:21 +0000 Subject: [PATCH] Added patched recyclerview-selection 1.0.0 --- FAQ.md | 2 +- app/build.gradle | 2 +- .../recyclerview/selection/AutoScroller.java | 45 + .../recyclerview/selection/BandPredicate.java | 139 +++ .../selection/BandSelectionHelper.java | 356 ++++++++ .../selection/DefaultBandHost.java | 156 ++++ .../selection/DefaultSelectionTracker.java | 589 +++++++++++++ .../recyclerview/selection/EventBridge.java | 105 +++ .../recyclerview/selection/FocusDelegate.java | 72 ++ .../recyclerview/selection/GestureRouter.java | 104 +++ .../selection/GestureSelectionHelper.java | 319 +++++++ .../recyclerview/selection/GridModel.java | 795 +++++++++++++++++ .../selection/ItemDetailsLookup.java | 258 ++++++ .../selection/ItemKeyProvider.java | 85 ++ .../recyclerview/selection/MotionEvents.java | 120 +++ .../selection/MotionInputHandler.java | 112 +++ .../selection/MouseInputHandler.java | 224 +++++ .../selection/MutableSelection.java | 76 ++ .../selection/OnContextClickListener.java | 41 + .../selection/OnDragInitiatedListener.java | 73 ++ .../selection/OnItemActivatedListener.java | 43 + .../selection/OperationMonitor.java | 131 +++ .../PointerDragEventInterceptor.java | 75 ++ .../recyclerview/selection/Range.java | 190 ++++ .../recyclerview/selection/Selection.java | 247 ++++++ .../selection/SelectionPredicates.java | 81 ++ .../selection/SelectionTracker.java | 817 ++++++++++++++++++ .../recyclerview/selection/Shared.java | 28 + .../selection/StableIdKeyProvider.java | 110 +++ .../selection/StorageStrategy.java | 230 +++++ .../selection/ToolHandlerRegistry.java | 74 ++ .../selection/TouchEventRouter.java | 113 +++ .../selection/TouchInputHandler.java | 152 ++++ .../selection/ViewAutoScroller.java | 273 ++++++ .../recyclerview/selection/package-info.java | 129 +++ .../res/drawable/selection_band_overlay.xml | 22 + 36 files changed, 6386 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/androidx/recyclerview/selection/AutoScroller.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/BandPredicate.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/EventBridge.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/GestureRouter.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/GridModel.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/MotionEvents.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/MutableSelection.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/Range.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/Selection.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/Shared.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/TouchEventRouter.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java create mode 100644 app/src/main/java/androidx/recyclerview/selection/package-info.java create mode 100644 app/src/main/res/drawable/selection_band_overlay.xml diff --git a/FAQ.md b/FAQ.md index a7e0939e1d..cb7cc83cc4 100644 --- a/FAQ.md +++ b/FAQ.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle index 69bdc89220..7cc7963e3d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java b/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java new file mode 100644 index 0000000000..c3112c44b6 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/AutoScroller.java @@ -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); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java b/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java new file mode 100644 index 0000000000..1ee3d73801 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/BandPredicate.java @@ -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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java b/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java new file mode 100644 index 0000000000..495b63f2d0 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/BandSelectionHelper.java @@ -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. + * + *

+ * 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +class BandSelectionHelper implements OnItemTouchListener { + + static final String TAG = "BandSelectionHelper"; + static final boolean DEBUG = false; + + private final BandHost mHost; + private final ItemKeyProvider mKeyProvider; + @SuppressWarnings("WeakerAccess") /* synthetic access */ + final SelectionTracker mSelectionTracker; + private final BandPredicate mBandPredicate; + private final FocusDelegate 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 keyProvider, + @NonNull SelectionTracker selectionTracker, + @NonNull BandPredicate bandPredicate, + @NonNull FocusDelegate 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() { + @Override + public void onSelectionChanged(Set updatedSelection) { + mSelectionTracker.setProvisionalSelection(updatedSelection); + } + }; + } + + /** + * Creates a new instance. + * + * @return new BandSelectionHelper instance. + */ + static BandSelectionHelper create( + @NonNull RecyclerView recyclerView, + @NonNull AutoScroller scroller, + @DrawableRes int bandOverlayId, + @NonNull ItemKeyProvider keyProvider, + @NonNull SelectionTracker selectionTracker, + @NonNull SelectionPredicate selectionPredicate, + @NonNull BandPredicate bandPredicate, + @NonNull FocusDelegate 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ + abstract static class BandHost { + + /** + * Returns a new GridModel instance. + */ + abstract GridModel 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); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java b/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java new file mode 100644 index 0000000000..bb1a034de5 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/DefaultBandHost.java @@ -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 extends GridModel.GridHost { + + private static final Rect NILL_RECT = new Rect(0, 0, 0, 0); + + private final RecyclerView mRecyclerView; + private final Drawable mBand; + private final ItemKeyProvider mKeyProvider; + private final SelectionPredicate mSelectionPredicate; + + DefaultBandHost( + @NonNull RecyclerView recyclerView, + @DrawableRes int bandOverlayId, + @NonNull ItemKeyProvider keyProvider, + @NonNull SelectionPredicate 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 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; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java new file mode 100644 index 0000000000..014d2c64fa --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/DefaultSelectionTracker.java @@ -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}. + * + *

+ * The class supports running in a single-select mode, which can be enabled using + * {@link SelectionPredicate#canSelectMultiple()}. + * + * @param Selection key type. @see {@link StorageStrategy} for supported types. + * + * @hide + */ +@RestrictTo(LIBRARY_GROUP) +public class DefaultSelectionTracker extends SelectionTracker { + + private static final String TAG = "DefaultSelectionTracker"; + private static final String EXTRA_SELECTION_PREFIX = "androidx.recyclerview.selection"; + + private final Selection mSelection = new Selection<>(); + private final List mObservers = new ArrayList<>(1); + private final ItemKeyProvider mKeyProvider; + private final SelectionPredicate mSelectionPredicate; + private final StorageStrategy mStorage; + private final RangeCallbacks mRangeCallbacks; + private final AdapterObserver mAdapterObserver; + private final boolean mSingleSelect; + private final String mSelectionId; + + private @Nullable Range mRange; + + /** + * Creates a new instance. + * + * @param selectionId A unique string identifying this selection in the context + * of the activity or fragment. + * @param keyProvider client supplied class providing access to stable ids. + * @param selectionPredicate A predicate allowing the client to disallow selection + * @param storage Strategy for storing typed selection in bundle. + */ + public DefaultSelectionTracker( + @NonNull String selectionId, + @NonNull ItemKeyProvider keyProvider, + @NonNull SelectionPredicate selectionPredicate, + @NonNull StorageStrategy storage) { + + checkArgument(selectionId != null); + checkArgument(!selectionId.trim().isEmpty()); + checkArgument(keyProvider != null); + checkArgument(selectionPredicate != null); + checkArgument(storage != null); + + mSelectionId = selectionId; + mKeyProvider = keyProvider; + mSelectionPredicate = selectionPredicate; + mStorage = storage; + + mRangeCallbacks = new RangeCallbacks(); + + mSingleSelect = !selectionPredicate.canSelectMultiple(); + + mAdapterObserver = new AdapterObserver(this); + } + + @Override + public void addObserver(@NonNull SelectionObserver callback) { + checkArgument(callback != null); + mObservers.add(callback); + } + + @Override + public boolean hasSelection() { + return !mSelection.isEmpty(); + } + + @Override + public Selection getSelection() { + return mSelection; + } + + @Override + public void copySelection(@NonNull MutableSelection dest) { + dest.copyFrom(mSelection); + } + + @Override + public boolean isSelected(@Nullable K key) { + return mSelection.contains(key); + } + + @Override + protected void restoreSelection(@NonNull Selection other) { + checkArgument(other != null); + setItemsSelectedQuietly(other.mSelection, true); + // NOTE: We intentionally don't restore provisional selection. It's provisional. + notifySelectionRestored(); + } + + @Override + public boolean setItemsSelected(@NonNull Iterable keys, boolean selected) { + boolean changed = setItemsSelectedQuietly(keys, selected); + notifySelectionChanged(); + return changed; + } + + private boolean setItemsSelectedQuietly(@NonNull Iterable keys, boolean selected) { + boolean changed = false; + for (K key: keys) { + boolean itemChanged = selected + ? canSetState(key, true) && mSelection.add(key) + : canSetState(key, false) && mSelection.remove(key); + if (itemChanged) { + notifyItemStateChanged(key, selected); + } + changed |= itemChanged; + } + return changed; + } + + @Override + public boolean clearSelection() { + if (!hasSelection()) { + return false; + } + + clearProvisionalSelection(); + clearPrimarySelection(); + return true; + } + + private void clearPrimarySelection() { + if (!hasSelection()) { + return; + } + + Selection prev = clearSelectionQuietly(); + notifySelectionCleared(prev); + notifySelectionChanged(); + } + + /** + * Clears the selection, without notifying selection listeners. + * Returns items in previous selection. Callers are responsible for notifying + * listeners about changes. + */ + private Selection clearSelectionQuietly() { + mRange = null; + + MutableSelection prevSelection = new MutableSelection(); + if (hasSelection()) { + copySelection(prevSelection); + mSelection.clear(); + } + + return prevSelection; + } + + @Override + public boolean select(@NonNull K key) { + checkArgument(key != null); + + if (mSelection.contains(key)) { + return false; + } + + if (!canSetState(key, true)) { + if (DEBUG) Log.d(TAG, "Select cancelled by selection predicate test."); + return false; + } + + // Enforce single selection policy. + if (mSingleSelect && hasSelection()) { + Selection prev = clearSelectionQuietly(); + notifySelectionCleared(prev); + } + + mSelection.add(key); + notifyItemStateChanged(key, true); + notifySelectionChanged(); + + return true; + } + + @Override + public boolean deselect(@NonNull K key) { + checkArgument(key != null); + + if (mSelection.contains(key)) { + if (!canSetState(key, false)) { + if (DEBUG) Log.d(TAG, "Deselect cancelled by selection predicate test."); + return false; + } + mSelection.remove(key); + notifyItemStateChanged(key, false); + notifySelectionChanged(); + if (mSelection.isEmpty() && isRangeActive()) { + // if there's nothing in the selection and there is an active ranger it results + // in unexpected behavior when the user tries to start range selection: the item + // which the ranger 'thinks' is the already selected anchor becomes unselectable + endRange(); + } + return true; + } + + return false; + } + + @Override + public void startRange(int position) { + if (mSelection.contains(mKeyProvider.getKey(position)) + || select(mKeyProvider.getKey(position))) { + anchorRange(position); + } + } + + @Override + public void extendRange(int position) { + extendRange(position, Range.TYPE_PRIMARY); + } + + @Override + public void endRange() { + mRange = null; + // Clean up in case there was any leftover provisional selection + clearProvisionalSelection(); + } + + @Override + public void anchorRange(int position) { + checkArgument(position != RecyclerView.NO_POSITION); + checkArgument(mSelection.contains(mKeyProvider.getKey(position))); + + mRange = new Range(position, mRangeCallbacks); + } + + @Override + public void extendProvisionalRange(int position) { + if (mSingleSelect) { + return; + } + + if (DEBUG) Log.i(TAG, "Extending provision range to position: " + position); + checkState(isRangeActive(), "Range start point not set."); + extendRange(position, Range.TYPE_PROVISIONAL); + } + + /** + * Sets the end point for the current range selection, started by a call to + * {@link #startRange(int)}. This function should only be called when a range selection + * is active (see {@link #isRangeActive()}. Items in the range [anchor, end] will be + * selected or in provisional select, depending on the type supplied. Note that if the type is + * provisional selection, one should do {@link #mergeProvisionalSelection()} at some + * point before calling on {@link #endRange()}. + * + * @param position The new end position for the selection range. + * @param type The type of selection the range should utilize. + */ + private void extendRange(int position, @RangeType int type) { + checkState(isRangeActive(), "Range start point not set."); + + mRange.extendRange(position, type); + + // We're being lazy here notifying even when something might not have changed. + // To make this more correct, we'd need to update the Ranger class to return + // information about what has changed. + notifySelectionChanged(); + } + + @Override + public void setProvisionalSelection(@NonNull Set newSelection) { + if (mSingleSelect) { + return; + } + + Map delta = mSelection.setProvisionalSelection(newSelection); + for (Map.Entry entry: delta.entrySet()) { + notifyItemStateChanged(entry.getKey(), entry.getValue()); + } + + notifySelectionChanged(); + } + + @Override + public void mergeProvisionalSelection() { + mSelection.mergeProvisionalSelection(); + + // Note, that for almost all functional purposes, merging a provisional selection + // into a the primary selection doesn't change the selection, just an internal + // representation of it. But there are some nuanced areas cases where + // that isn't true. equality for 1. So, we notify regardless. + + notifySelectionChanged(); + } + + @Override + public void clearProvisionalSelection() { + for (K key : mSelection.mProvisionalSelection) { + notifyItemStateChanged(key, false); + } + mSelection.clearProvisionalSelection(); + } + + @Override + public boolean isRangeActive() { + return mRange != null; + } + + private boolean canSetState(@NonNull K key, boolean nextState) { + return mSelectionPredicate.canSetStateForKey(key, nextState); + } + + @Override + AdapterDataObserver getAdapterDataObserver() { + return mAdapterObserver; + } + + @SuppressWarnings("WeakerAccess") /* synthetic access */ + void onDataSetChanged() { + mSelection.clearProvisionalSelection(); + + notifySelectionRefresh(); + + List 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 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 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(); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/EventBridge.java b/app/src/main/java/androidx/recyclerview/selection/EventBridge.java new file mode 100644 index 0000000000..6c43f7e09a --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/EventBridge.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ + public static void install( + @NonNull RecyclerView.Adapter adapter, + @NonNull SelectionTracker selectionTracker, + @NonNull ItemKeyProvider keyProvider) { + + // setup bridges to relay selection and adapter events + new TrackerToAdapterBridge<>(selectionTracker, keyProvider, adapter); + adapter.registerAdapterDataObserver(selectionTracker.getAdapterDataObserver()); + } + + private static final class TrackerToAdapterBridge + extends SelectionTracker.SelectionObserver { + + private final ItemKeyProvider mKeyProvider; + private final RecyclerView.Adapter mAdapter; + + TrackerToAdapterBridge( + @NonNull SelectionTracker selectionTracker, + @NonNull ItemKeyProvider 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() { + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java b/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java new file mode 100644 index 0000000000..ca69c85214 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/FocusDelegate.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +public abstract class FocusDelegate { + + static FocusDelegate dummy() { + return new FocusDelegate() { + @Override + public void focusItem(@NonNull ItemDetails 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 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(); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java new file mode 100644 index 0000000000..797f32d991 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/GestureRouter.java @@ -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 listener type. Must extend OnGestureListener & OnDoubleTapListener. + */ +final class GestureRouter + implements OnGestureListener, OnDoubleTapListener { + + private final ToolHandlerRegistry 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); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java b/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java new file mode 100644 index 0000000000..f127576102 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/GestureSelectionHelper.java @@ -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. + * + *

This code, and the fact that this method is used by both OnInterceptTouchEvent and + * OnTouchEvent, is correct and valid because: + *

    + *
  1. 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. + *
  2. 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. + *
+ */ + 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; + } + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/GridModel.java b/app/src/main/java/androidx/recyclerview/selection/GridModel.java new file mode 100644 index 0000000000..406ae618ff --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/GridModel.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +final class GridModel { + + // 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 mHost; + private final ItemKeyProvider mKeyProvider; + private final SelectionPredicate mSelectionPredicate; + + private final List 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 mColumns = new SparseArray<>(); + + // List of limits along the x-axis (columns). + // This list is sorted from furthest left to furthest right. + private final List 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 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 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 keyProvider, + SelectionPredicate 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 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 { + abstract void onSelectionChanged(Set 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 { + 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 { + /** + * 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 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 columnLimits, + @NonNull List 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 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ + abstract static class GridHost extends BandSelectionHelper.BandHost { + + /** + * 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); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java b/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java new file mode 100644 index 0000000000..9cb6d34551 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ItemDetailsLookup.java @@ -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. + * + *

+ * Example + *

+ * final class MyDetailsLookup extends ItemDetailsLookup {
+ *
+ *   private final RecyclerView mRecyclerView;
+ *
+ *   MyDetailsLookup(RecyclerView recyclerView) {
+ *       mRecyclerView = recyclerView;
+ *   }
+ *
+ *   public ItemDetails 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;
+ *   }
+ *}
+ * 
+ * + * @param Selection key type. @see {@link StorageStrategy} for supported types. + */ +public abstract class ItemDetailsLookup { + + /** + * @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 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. + * + *

+ * Selection Hotspot + * + *

+ * 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. + * + *

+ * See {@link OnItemActivatedListener} for details on handling item activation. + * + *

+ * Drag Region + * + *

+ * 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. + * + *

+ * 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": + *

+     * -------------------------------------------------------
+     * | [icon]  A string label.   ...empty space...         |
+     * -------------------------------------------------------
+     *   < ---  drag region  --> < --treated as background-->
+     *
+ * + *

+ * Further more, within a drag region, a mouse click and drag will immediately + * initiate drag and drop (if supported by your configuration). + * + *

+ * As user expectations around touch and mouse input differ substantially, + * "drag region" has no effect on handling of touch input. + * + * @param Selection key type. @see {@link StorageStrategy} for supported types. + */ + public abstract static class ItemDetails { + + /** + * 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. + * + *

+ * 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. + * + *

+ * 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; + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java b/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java new file mode 100644 index 0000000000..a216b2466c --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ItemKeyProvider.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +public abstract class ItemKeyProvider { + + /** + * 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); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java b/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java new file mode 100644 index 0000000000..bc47a76ccf --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/MotionEvents.java @@ -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; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java new file mode 100644 index 0000000000..a7eb208358 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/MotionInputHandler.java @@ -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 extends SimpleOnGestureListener { + + protected final SelectionTracker mSelectionTracker; + + private final ItemKeyProvider mKeyProvider; + private final FocusDelegate mFocusDelegate; + + MotionInputHandler( + @NonNull SelectionTracker selectionTracker, + @NonNull ItemKeyProvider keyProvider, + @NonNull FocusDelegate focusDelegate) { + + checkArgument(selectionTracker != null); + checkArgument(keyProvider != null); + checkArgument(focusDelegate != null); + + mSelectionTracker = selectionTracker; + mKeyProvider = keyProvider; + mFocusDelegate = focusDelegate; + } + + final boolean selectItem(@NonNull ItemDetails 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 details) { + checkArgument(details != null); + checkArgument(hasSelectionKey(details)); + + mSelectionTracker.clearSelection(); + mFocusDelegate.focusItem(details); + return true; + } + + protected final void extendSelectionRange(@NonNull ItemDetails 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 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; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java new file mode 100644 index 0000000000..ca45137a00 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/MouseInputHandler.java @@ -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 extends MotionInputHandler { + + private static final String TAG = "MouseInputDelegate"; + + private final ItemDetailsLookup mDetailsLookup; + private final OnContextClickListener mOnContextClickListener; + private final OnItemActivatedListener mOnItemActivatedListener; + private final FocusDelegate 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 selectionTracker, + @NonNull ItemKeyProvider keyProvider, + @NonNull ItemDetailsLookup detailsLookup, + @NonNull OnContextClickListener onContextClickListener, + @NonNull OnItemActivatedListener onItemActivatedListener, + @NonNull FocusDelegate 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 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 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 item = mDetailsLookup.getItemDetails(e); + return (item != null) && mOnItemActivatedListener.onItemActivated(item, e); + } + + private boolean onRightClick(@NonNull MotionEvent e) { + if (mDetailsLookup.overItemWithSelectionKey(e)) { + @Nullable ItemDetails 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 item, @NonNull MotionEvent e) { + if (item.inSelectionHotspot(e) || MotionEvents.isCtrlKeyPressed(e)) { + selectItem(item); + } else { + focusItem(item); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java b/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java new file mode 100644 index 0000000000..3a5cebe19f --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/MutableSelection.java @@ -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)}. + * + *

+ * 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. + * + *

+ * {@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. + * + * + *

Example + * + *

+ *

+ * 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.
+ * 
+ * + * @see android.text.Selection + * + * @param Selection key type. @see {@link StorageStrategy} for supported types. + */ +public final class MutableSelection extends Selection { + + @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 source) { + super.copyFrom(source); + } + + @Override + public void clear() { + super.clear(); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java b/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java new file mode 100644 index 0000000000..b7e432e602 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/OnContextClickListener.java @@ -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); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java b/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java new file mode 100644 index 0000000000..50d8f1b3d7 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/OnDragInitiatedListener.java @@ -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. + * + *

+ * 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. + * + *

+ * The following circumstances are considered to be expressing drag and drop intent: + * + *

    + *
  1. Long press on selected item.
  2. + *
  3. Click and drag in the {@link ItemDetails#inDragRegion(MotionEvent) drag region} + * of selected item with a pointer device.
  4. + *
  5. Click and drag in drag region of un-selected item with a pointer device.
  6. + *
+ * + *

+ * 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.) + * + *

+ * 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)}. + * + *

+ * Drag region used with pointer devices is specified by + * {@link ItemDetails#inDragRegion(MotionEvent)} + * + *

+ * 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); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java b/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java new file mode 100644 index 0000000000..60c41c642c --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/OnItemActivatedListener.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +public interface OnItemActivatedListener { + + /** + * 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 item, @NonNull MotionEvent e); +} diff --git a/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java b/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java new file mode 100644 index 0000000000..f5a4508d1a --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/OperationMonitor.java @@ -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). + * + *

+ * 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. + * + *

+ * 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 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(); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java b/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java new file mode 100644 index 0000000000..46ec5ddf10 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/PointerDragEventInterceptor.java @@ -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}. + * + *

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); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/Range.java b/app/src/main/java/androidx/recyclerview/selection/Range.java new file mode 100644 index 0000000000..35fb3a8ba5 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/Range.java @@ -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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/Selection.java b/app/src/main/java/androidx/recyclerview/selection/Selection.java new file mode 100644 index 0000000000..78c816fb15 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/Selection.java @@ -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. + * + *

+ * This class tracks selected items by managing two sets: + * + *

+ * Primary Selection + * + *

+ * 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. + * + *

+ * Provisional Selection + * + *

+ * Provisional selections are selections which are interim in nature. + * + *

+ * 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +public class Selection implements Iterable { + + // NOTE: Not currently private as DefaultSelectionTracker directly manipulates values. + final Set mSelection; + final Set mProvisionalSelection; + + Selection() { + mSelection = new HashSet<>(); + mProvisionalSelection = new HashSet<>(); + } + + /** + * Used by {@link StorageStrategy} when restoring selection. + */ + Selection(@NonNull Set 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 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 setProvisionalSelection(@NonNull Set newSelection) { + Map 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 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 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); + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java b/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java new file mode 100644 index 0000000000..1e13bdefb3 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/SelectionPredicates.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + * @return + */ + public static SelectionPredicate createSelectAnything() { + return new SelectionPredicate() { + @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 Selection key type. @see {@link StorageStrategy} for supported types. + * @return + */ + public static SelectionPredicate createSelectSingleAnything() { + return new SelectionPredicate() { + @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; + } + }; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java new file mode 100644 index 0000000000..f59bd9b692 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/SelectionTracker.java @@ -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. + * + *

+ * 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. + * + *

+ * Create an instance of SelectionTracker using {@link Builder SelectionTracker.Builder}. + * + *

+ * Inspecting the current selection + * + *

+ * The underlying selection is described by the {@link Selection} class. + * + *

+ * 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. + * + *

+ * To obtain a stable snapshot of the selection use {@link #copySelection(MutableSelection)}. + * + *

+ * Selection state for an individual item can be obtained using {@link #isSelected(Object)}. + * + *

+ * Provisional Selection + * + *

+ * 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). + * + *

+ * A provisional selection can be abandoned, or merged into the primary selection. + * + *

+ * Enforcing selection policies + * + *

+ * 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +public abstract class SelectionTracker { + + /** + * 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. + * + *

+ * 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 getSelection(); + + /** + * Updates {@code dest} to reflect the current selection. + */ + public abstract void copySelection(@NonNull MutableSelection 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. + * + *

+ * 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 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 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. + * + *

+ * 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 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ + public abstract static class SelectionObserver { + + /** + * 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 Selection key type. @see {@link StorageStrategy} for supported types. + */ + public abstract static class SelectionPredicate { + + /** + * 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. + * + *

+ * 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. + * + *

+ * Example usage: + *

SelectionTracker tracker = new SelectionTracker.Builder<>(
+     *        "my-uri-selection",
+     *        recyclerView,
+     *        new DemoStableIdProvider(recyclerView.getAdapter()),
+     *        new MyDetailsLookup(recyclerView),
+     *        StorageStrategy.createParcelableStorage(Uri.class))
+     *        .build();
+     *
+ * + *

+ * Restricting which items can be selected and limiting selection size + * + *

+ * {@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. + * + *

Configuring the tracker for single single selection support can be done + * by supplying {@link SelectionPredicates#createSelectSingleAnything()}. + * + * SelectionTracker tracker = new SelectionTracker.Builder<>( + * "my-string-selection", + * recyclerView, + * new DemoStableIdProvider(recyclerView.getAdapter()), + * new MyDetailsLookup(recyclerView), + * StorageStrategy.createStringStorage()) + * .withSelectionPredicate(SelectionPredicates#createSelectSingleAnything()) + * .build(); + * + *

+ * Retaining state across Android lifecycle events + * + *

+ * 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. + * + *

+ * Key Type + * + *

+ * 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}. + * + *

+ * {@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. + * + *

+ * {@link String}: Use String when a string based stable identifier is available. + * + *

+ * {@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. + * + *

+ * Usage: + * + *

+     * private SelectionTracker 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);
+     * }
+     * 
+ * + * @param 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 { + + final RecyclerView mRecyclerView; + private final RecyclerView.Adapter mAdapter; + private final Context mContext; + private final String mSelectionId; + private final StorageStrategy mStorage; + + SelectionPredicate mSelectionPredicate = + SelectionPredicates.createSelectAnything(); + private OperationMonitor mMonitor = new OperationMonitor(); + private ItemKeyProvider mKeyProvider; + private ItemDetailsLookup mDetailsLookup; + + private FocusDelegate mFocusDelegate = FocusDelegate.dummy(); + + private OnItemActivatedListener 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 keyProvider, + @NonNull ItemDetailsLookup detailsLookup, + @NonNull StorageStrategy 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 withSelectionPredicate( + @NonNull SelectionPredicate 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 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 withFocusDelegate(@NonNull FocusDelegate 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 withOnItemActivatedListener( + @NonNull OnItemActivatedListener 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 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 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 withGestureTooltypes(int... toolTypes) { + mGestureToolTypes = toolTypes; + return this; + } + + /** + * Replaces default band overlay. + * + * @param bandOverlayId + * @return this + */ + public Builder withBandOverlay(@DrawableRes int bandOverlayId) { + mBandOverlayId = bandOverlayId; + return this; + } + + /** + * Replaces default band predicate. + * @param bandPredicate + * @return this + */ + public Builder 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 withPointerTooltypes(int... toolTypes) { + mPointerToolTypes = toolTypes; + return this; + } + + /** + * Prepares and returns a SelectionTracker. + * + * @return this + */ + public SelectionTracker build() { + + SelectionTracker 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 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() { + @Override + public boolean onItemActivated( + @NonNull ItemDetailsLookup.ItemDetails 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 touchHandler = new TouchInputHandler( + 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 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; + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/Shared.java b/app/src/main/java/androidx/recyclerview/selection/Shared.java new file mode 100644 index 0000000000..bded1e4b90 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/Shared.java @@ -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() {} +} diff --git a/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java b/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java new file mode 100644 index 0000000000..b6c9a36a88 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/StableIdKeyProvider.java @@ -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. + * + *

+ * 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 { + + private final SparseArray mPositionToKey = new SparseArray<>(); + private final Map mKeyToPosition = new HashMap(); + 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; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java b/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java new file mode 100644 index 0000000000..a27752f159 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/StorageStrategy.java @@ -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)}. + * + *

+ * See + * {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder} + * for more detailed advice on which key type to use for your selection keys. + * + * @param 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 { + + @VisibleForTesting + static final String SELECTION_ENTRIES = "androidx.recyclerview.selection.entries"; + + @VisibleForTesting + static final String SELECTION_KEY_TYPE = "androidx.recyclerview.selection.type"; + + private final Class mType; + + /** + * Creates a new instance. + * + * @param type the key type class that is being used. + */ + public StorageStrategy(@NonNull Class 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 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 selection); + + String getKeyTypeName() { + return mType.getCanonicalName(); + } + + /** + * @return StorageStrategy suitable for use with {@link Parcelable} keys + * (like {@link android.net.Uri}). + */ + public static StorageStrategy createParcelableStorage(Class type) { + return new ParcelableStorageStrategy(type); + } + + /** + * @return StorageStrategy suitable for use with {@link String} keys. + */ + public static StorageStrategy createStringStorage() { + return new StringStorageStrategy(); + } + + /** + * @return StorageStrategy suitable for use with {@link Long} keys. + */ + public static StorageStrategy createLongStorage() { + return new LongStorageStrategy(); + } + + private static class StringStorageStrategy extends StorageStrategy { + + StringStorageStrategy() { + super(String.class); + } + + @Override + public @Nullable Selection asSelection(@NonNull Bundle state) { + + String keyType = state.getString(SELECTION_KEY_TYPE, null); + if (keyType == null || !keyType.equals(getKeyTypeName())) { + return null; + } + + @Nullable ArrayList stored = state.getStringArrayList(SELECTION_ENTRIES); + if (stored == null) { + return null; + } + + Selection selection = new Selection<>(); + selection.mSelection.addAll(stored); + return selection; + } + + @Override + public @NonNull Bundle asBundle(@NonNull Selection selection) { + + Bundle bundle = new Bundle(); + + bundle.putString(SELECTION_KEY_TYPE, getKeyTypeName()); + + ArrayList value = new ArrayList<>(selection.size()); + value.addAll(selection.mSelection); + bundle.putStringArrayList(SELECTION_ENTRIES, value); + + return bundle; + } + } + + private static class LongStorageStrategy extends StorageStrategy { + + LongStorageStrategy() { + super(Long.class); + } + + @Override + public @Nullable Selection 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 selection = new Selection<>(); + for (long key : stored) { + selection.mSelection.add(key); + } + return selection; + } + + @Override + public @NonNull Bundle asBundle(@NonNull Selection 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 + extends StorageStrategy { + + ParcelableStorageStrategy(Class type) { + super(type); + checkArgument(Parcelable.class.isAssignableFrom(type)); + } + + @Override + public @Nullable Selection asSelection(@NonNull Bundle state) { + + String keyType = state.getString(SELECTION_KEY_TYPE, null); + if (keyType == null || !keyType.equals(getKeyTypeName())) { + return null; + } + + @Nullable ArrayList stored = state.getParcelableArrayList(SELECTION_ENTRIES); + if (stored == null) { + return null; + } + + Selection selection = new Selection<>(); + selection.mSelection.addAll(stored); + return selection; + } + + @Override + public @NonNull Bundle asBundle(@NonNull Selection selection) { + + Bundle bundle = new Bundle(); + bundle.putString(SELECTION_KEY_TYPE, getKeyTypeName()); + + ArrayList value = new ArrayList<>(selection.size()); + value.addAll(selection.mSelection); + bundle.putParcelableArrayList(SELECTION_ENTRIES, value); + + return bundle; + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java b/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java new file mode 100644 index 0000000000..8fc82c6297 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ToolHandlerRegistry.java @@ -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 type of item being registered. + */ +final class ToolHandlerRegistry { + + // 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. + private static final int NUM_INPUT_TYPES = MotionEvent.TOOL_TYPE_ERASER + 1; + + private final List 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; + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/TouchEventRouter.java b/app/src/main/java/androidx/recyclerview/selection/TouchEventRouter.java new file mode 100644 index 0000000000..a5501da1d1 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/TouchEventRouter.java @@ -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. + * + *

+ * 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 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) {} +} diff --git a/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java b/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java new file mode 100644 index 0000000000..271ff3cc40 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/TouchInputHandler.java @@ -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 Selection key type. @see {@link StorageStrategy} for supported types. + */ +final class TouchInputHandler extends MotionInputHandler { + + private static final String TAG = "TouchInputDelegate"; + private static final boolean DEBUG = false; + + private final ItemDetailsLookup mDetailsLookup; + private final SelectionPredicate mSelectionPredicate; + private final OnItemActivatedListener mOnItemActivatedListener; + private final OnDragInitiatedListener mOnDragInitiatedListener; + private final Runnable mGestureStarter; + private final Runnable mHapticPerformer; + + TouchInputHandler( + @NonNull SelectionTracker selectionTracker, + @NonNull ItemKeyProvider keyProvider, + @NonNull ItemDetailsLookup detailsLookup, + @NonNull SelectionPredicate selectionPredicate, + @NonNull Runnable gestureStarter, + @NonNull OnDragInitiatedListener onDragInitiatedListener, + @NonNull OnItemActivatedListener onItemActivatedListener, + @NonNull FocusDelegate 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 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 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(); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java new file mode 100644 index 0000000000..34b43e22cf --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/ViewAutoScroller.java @@ -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(); + } + } +} diff --git a/app/src/main/java/androidx/recyclerview/selection/package-info.java b/app/src/main/java/androidx/recyclerview/selection/package-info.java new file mode 100644 index 0000000000..a3386ab9e2 --- /dev/null +++ b/app/src/main/java/androidx/recyclerview/selection/package-info.java @@ -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.) + * + *

+ * Want to add selection support to your RecyclerView? Here's how you do it: + * + *

+ * Determine which selection key type to use, then build your KeyProvider + * + *

+ * 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}. + * + *

+ * See + * {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder} + * for more detailed advice on which key type to use for your selection keys. + * + *

+ * Implement {@link androidx.recyclerview.selection.ItemDetailsLookup ItemDetailsLookup} + * + * + *

+ * 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. + * + *

+ * Update views used in RecyclerView to reflect selected state + * + *

+ * 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. + * + *

    + *
  • In Adapter#onBindViewHolder, set the "activated" status on view. + * Note that the status should be "activated" not "selected". + * See + * View.html#setActivated for details. + *
  • Update the styling of the view to represent the activated status. This can be done + * with a + * + * color state list. + *
+ * + *

+ * Use {@link androidx.appcompat.view.ActionMode ActionMode} when there is a selection + * + *

+ * 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. + * + *

+ * Interpreted secondary actions: Drag and Drop, and Item Activation + * + *

+ * 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. + * + *

+ * Assemble everything with + * {@link androidx.recyclerview.selection.SelectionTracker.Builder SelectionTracker.Builder} + * + * + *

+ * Example usage (with {@code Long} selection keys: + *

SelectionTracker tracker = new SelectionTracker.Builder<>(
+ *        "my-selection-id",
+ *        recyclerView,
+ *        new StableIdKeyProvider(recyclerView),
+ *        new MyDetailsLookup(recyclerView),
+ *        StorageStrategy.createLongStorage())
+ *        .build();
+ *
+ * + *

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. + * + *

+ * Include Selection in Activity lifecycle events + * + *

+ * In order to preserve state the author must the selection library in handling + * of Activity lifecycle events. See SelectionTracker#onSaveInstanceState + * and SelectionTracker#onRestoreInstanceState. + * + *

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; diff --git a/app/src/main/res/drawable/selection_band_overlay.xml b/app/src/main/res/drawable/selection_band_overlay.xml new file mode 100644 index 0000000000..f78017895f --- /dev/null +++ b/app/src/main/res/drawable/selection_band_overlay.xml @@ -0,0 +1,22 @@ + + + + + + +