Home | History | Annotate | Download | only in selection
      1 /*
      2  * Copyright 2017 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package androidx.recyclerview.selection;
     18 
     19 import static androidx.core.util.Preconditions.checkArgument;
     20 import static androidx.core.util.Preconditions.checkState;
     21 import static androidx.recyclerview.selection.Shared.VERBOSE;
     22 
     23 import android.graphics.Point;
     24 import android.graphics.Rect;
     25 import android.util.Log;
     26 import android.view.MotionEvent;
     27 
     28 import androidx.annotation.DrawableRes;
     29 import androidx.annotation.NonNull;
     30 import androidx.annotation.Nullable;
     31 import androidx.annotation.VisibleForTesting;
     32 import androidx.recyclerview.selection.SelectionTracker.SelectionPredicate;
     33 import androidx.recyclerview.widget.RecyclerView;
     34 import androidx.recyclerview.widget.RecyclerView.OnItemTouchListener;
     35 import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
     36 
     37 import java.util.Set;
     38 
     39 /**
     40  * Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
     41  * instance. This class is responsible for rendering a band overlay and manipulating selection
     42  * status of the items it intersects with.
     43  *
     44  * <p>
     45  * Given the recycling nature of RecyclerView items that have scrolled off-screen would not
     46  * be selectable with a band that itself was partially rendered off-screen. To address this,
     47  * BandSelectionController builds a model of the list/grid information presented by RecyclerView as
     48  * the user interacts with items using their pointer (and the band). Selectable items that intersect
     49  * with the band, both on and off screen, are selected on pointer up.
     50  *
     51  * @see SelectionTracker.Builder#withPointerTooltypes(int...) for details on the specific
     52  *     tooltypes routed to this helper.
     53  *
     54  * @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
     55  */
     56 class BandSelectionHelper<K> implements OnItemTouchListener {
     57 
     58     static final String TAG = "BandSelectionHelper";
     59     static final boolean DEBUG = false;
     60 
     61     private final BandHost mHost;
     62     private final ItemKeyProvider<K> mKeyProvider;
     63     private final SelectionTracker<K> mSelectionTracker;
     64     private final BandPredicate mBandPredicate;
     65     private final FocusDelegate<K> mFocusDelegate;
     66     private final OperationMonitor mLock;
     67     private final AutoScroller mScroller;
     68     private final GridModel.SelectionObserver mGridObserver;
     69 
     70     private @Nullable Point mCurrentPosition;
     71     private @Nullable Point mOrigin;
     72     private @Nullable GridModel mModel;
     73 
     74     /**
     75      * See {@link BandSelectionHelper#create}.
     76      */
     77     BandSelectionHelper(
     78             @NonNull BandHost host,
     79             @NonNull AutoScroller scroller,
     80             @NonNull ItemKeyProvider<K> keyProvider,
     81             @NonNull SelectionTracker<K> selectionTracker,
     82             @NonNull BandPredicate bandPredicate,
     83             @NonNull FocusDelegate<K> focusDelegate,
     84             @NonNull OperationMonitor lock) {
     85 
     86         checkArgument(host != null);
     87         checkArgument(scroller != null);
     88         checkArgument(keyProvider != null);
     89         checkArgument(selectionTracker != null);
     90         checkArgument(bandPredicate != null);
     91         checkArgument(focusDelegate != null);
     92         checkArgument(lock != null);
     93 
     94         mHost = host;
     95         mKeyProvider = keyProvider;
     96         mSelectionTracker = selectionTracker;
     97         mBandPredicate = bandPredicate;
     98         mFocusDelegate = focusDelegate;
     99         mLock = lock;
    100 
    101         mHost.addOnScrollListener(
    102                 new OnScrollListener() {
    103                     @Override
    104                     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    105                         BandSelectionHelper.this.onScrolled(recyclerView, dx, dy);
    106                     }
    107                 });
    108 
    109         mScroller = scroller;
    110 
    111         mGridObserver = new GridModel.SelectionObserver<K>() {
    112             @Override
    113             public void onSelectionChanged(Set<K> updatedSelection) {
    114                 mSelectionTracker.setProvisionalSelection(updatedSelection);
    115             }
    116         };
    117     }
    118 
    119     /**
    120      * Creates a new instance.
    121      *
    122      * @return new BandSelectionHelper instance.
    123      */
    124     static <K> BandSelectionHelper create(
    125             @NonNull RecyclerView recyclerView,
    126             @NonNull AutoScroller scroller,
    127             @DrawableRes int bandOverlayId,
    128             @NonNull ItemKeyProvider<K> keyProvider,
    129             @NonNull SelectionTracker<K> selectionTracker,
    130             @NonNull SelectionPredicate<K> selectionPredicate,
    131             @NonNull BandPredicate bandPredicate,
    132             @NonNull FocusDelegate<K> focusDelegate,
    133             @NonNull OperationMonitor lock) {
    134 
    135         return new BandSelectionHelper<>(
    136                 new DefaultBandHost<>(recyclerView, bandOverlayId, keyProvider, selectionPredicate),
    137                 scroller,
    138                 keyProvider,
    139                 selectionTracker,
    140                 bandPredicate,
    141                 focusDelegate,
    142                 lock);
    143     }
    144 
    145     @VisibleForTesting
    146     boolean isActive() {
    147         boolean active = mModel != null;
    148         if (DEBUG && active) {
    149             mLock.checkStarted();
    150         }
    151         return active;
    152     }
    153 
    154     /**
    155      * Clients must call reset when there are any material changes to the layout of items
    156      * in RecyclerView.
    157      */
    158     void reset() {
    159         if (!isActive()) {
    160             return;
    161         }
    162 
    163         mHost.hideBand();
    164         if (mModel != null) {
    165             mModel.stopCapturing();
    166             mModel.onDestroy();
    167         }
    168 
    169         mModel = null;
    170         mOrigin = null;
    171 
    172         mScroller.reset();
    173         mLock.stop();
    174     }
    175 
    176     @VisibleForTesting
    177     boolean shouldStart(@NonNull MotionEvent e) {
    178         // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
    179         // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
    180         // mouse moves.
    181         return MotionEvents.isPrimaryMouseButtonPressed(e)
    182                 && MotionEvents.isActionMove(e)
    183                 && mBandPredicate.canInitiate(e)
    184                 && !isActive();
    185     }
    186 
    187     @VisibleForTesting
    188     boolean shouldStop(@NonNull MotionEvent e) {
    189         return isActive()
    190                 && (MotionEvents.isActionUp(e)
    191                 || MotionEvents.isActionPointerUp(e)
    192                 || MotionEvents.isActionCancel(e));
    193     }
    194 
    195     @Override
    196     public boolean onInterceptTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
    197         if (shouldStart(e)) {
    198             startBandSelect(e);
    199         } else if (shouldStop(e)) {
    200             endBandSelect();
    201         }
    202 
    203         return isActive();
    204     }
    205 
    206     /**
    207      * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
    208      */
    209     @Override
    210     public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
    211         if (shouldStop(e)) {
    212             endBandSelect();
    213             return;
    214         }
    215 
    216         // We shouldn't get any events in this method when band select is not active,
    217         // but it turns some guests show up late to the party.
    218         // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
    219         if (!isActive()) {
    220             return;
    221         }
    222 
    223         if (DEBUG) {
    224             checkArgument(MotionEvents.isActionMove(e));
    225             checkState(mModel != null);
    226         }
    227 
    228         mCurrentPosition = MotionEvents.getOrigin(e);
    229 
    230         mModel.resizeSelection(mCurrentPosition);
    231 
    232         resizeBand();
    233         mScroller.scroll(mCurrentPosition);
    234     }
    235 
    236     @Override
    237     public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    238     }
    239 
    240     /**
    241      * Starts band select by adding the drawable to the RecyclerView's overlay.
    242      */
    243     private void startBandSelect(@NonNull MotionEvent e) {
    244         checkState(!isActive());
    245 
    246         if (!MotionEvents.isCtrlKeyPressed(e)) {
    247             mSelectionTracker.clearSelection();
    248         }
    249 
    250         Point origin = MotionEvents.getOrigin(e);
    251         if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
    252 
    253         mModel = mHost.createGridModel();
    254         mModel.addOnSelectionChangedListener(mGridObserver);
    255 
    256         mLock.start();
    257         mFocusDelegate.clearFocus();
    258         mOrigin = origin;
    259         // NOTE: Pay heed that resizeBand modifies the y coordinates
    260         // in onScrolled. Not sure if model expects this. If not
    261         // it should be defending against this.
    262         mModel.startCapturing(mOrigin);
    263     }
    264 
    265     /**
    266      * Resizes the band select rectangle by using the origin and the current pointer position as
    267      * two opposite corners of the selection.
    268      */
    269     private void resizeBand() {
    270         Rect bounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
    271                 Math.min(mOrigin.y, mCurrentPosition.y),
    272                 Math.max(mOrigin.x, mCurrentPosition.x),
    273                 Math.max(mOrigin.y, mCurrentPosition.y));
    274 
    275         if (VERBOSE) Log.v(TAG, "Resizing band! " + bounds);
    276         mHost.showBand(bounds);
    277     }
    278 
    279     /**
    280      * Ends band select by removing the overlay.
    281      */
    282     private void endBandSelect() {
    283         if (DEBUG) {
    284             Log.d(TAG, "Ending band select.");
    285             checkState(mModel != null);
    286         }
    287 
    288         // TODO: Currently when a band select operation ends outside
    289         // of an item (e.g. in the empty area between items),
    290         // getPositionNearestOrigin may return an unselected item.
    291         // Since the point of this code is to establish the
    292         // anchor point for subsequent range operations (SHIFT+CLICK)
    293         // we really want to do a better job figuring out the last
    294         // item selected (and nearest to the cursor).
    295         int firstSelected = mModel.getPositionNearestOrigin();
    296         if (firstSelected != GridModel.NOT_SET
    297                 && mSelectionTracker.isSelected(mKeyProvider.getKey(firstSelected))) {
    298             // Establish the band selection point as range anchor. This
    299             // allows touch and keyboard based selection activities
    300             // to be based on the band selection anchor point.
    301             mSelectionTracker.anchorRange(firstSelected);
    302         }
    303 
    304         mSelectionTracker.mergeProvisionalSelection();
    305         reset();
    306     }
    307 
    308     /**
    309      * @see OnScrollListener
    310      */
    311     private void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
    312         if (!isActive()) {
    313             return;
    314         }
    315 
    316         // Adjust the y-coordinate of the origin the opposite number of pixels so that the
    317         // origin remains in the same place relative to the view's items.
    318         mOrigin.y -= dy;
    319         resizeBand();
    320     }
    321 
    322     /**
    323      * Provides functionality for BandController. Exists primarily to tests that are
    324      * fully isolated from RecyclerView.
    325      *
    326      * @param <K> Selection key type. @see {@link StorageStrategy} for supported types.
    327      */
    328     abstract static class BandHost<K> {
    329 
    330         /**
    331          * Returns a new GridModel instance.
    332          */
    333         abstract GridModel<K> createGridModel();
    334 
    335         /**
    336          * Show the band covering the bounds.
    337          *
    338          * @param bounds The boundaries of the band to show.
    339          */
    340         abstract void showBand(@NonNull Rect bounds);
    341 
    342         /**
    343          * Hide the band.
    344          */
    345         abstract void hideBand();
    346 
    347         /**
    348          * Add a listener to be notified on scroll events.
    349          *
    350          * @param listener
    351          */
    352         abstract void addOnScrollListener(@NonNull OnScrollListener listener);
    353     }
    354 }
    355