Home | History | Annotate | Download | only in selection
      1 /*
      2  * Copyright (C) 2015 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 com.android.documentsui.selection;
     18 
     19 import static android.support.v4.util.Preconditions.checkArgument;
     20 import static android.support.v4.util.Preconditions.checkState;
     21 
     22 import android.graphics.Point;
     23 import android.graphics.Rect;
     24 import android.os.Build;
     25 import android.support.annotation.Nullable;
     26 import android.support.annotation.VisibleForTesting;
     27 import android.support.v7.widget.RecyclerView;
     28 import android.support.v7.widget.RecyclerView.OnItemTouchListener;
     29 import android.support.v7.widget.RecyclerView.OnScrollListener;
     30 import android.util.Log;
     31 import android.view.MotionEvent;
     32 
     33 import com.android.documentsui.selection.SelectionHelper.SelectionPredicate;
     34 import com.android.documentsui.selection.SelectionHelper.StableIdProvider;
     35 import com.android.documentsui.selection.ViewAutoScroller.ScrollHost;
     36 import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks;
     37 
     38 import java.util.ArrayList;
     39 import java.util.List;
     40 import java.util.Set;
     41 
     42 /**
     43  * Provides mouse driven band-selection support when used in conjunction with a {@link RecyclerView}
     44  * instance. This class is responsible for rendering a band overlay and manipulating selection
     45  * status of the items it intersects with.
     46  *
     47  * <p> Given the recycling nature of RecyclerView items that have scrolled off-screen would not
     48  * be selectable with a band that itself was partially rendered off-screen. To address this,
     49  * BandSelectionController builds a model of the list/grid information presented by RecyclerView as
     50  * the user interacts with items using their pointer (and the band). Selectable items that intersect
     51  * with the band, both on and off screen, are selected on pointer up.
     52  */
     53 public class BandSelectionHelper implements OnItemTouchListener {
     54 
     55     static final boolean DEBUG = false;
     56     static final String TAG = "BandController";
     57 
     58     private final BandHost mHost;
     59     private final StableIdProvider mStableIds;
     60     private final RecyclerView.Adapter<?> mAdapter;
     61     private final SelectionHelper mSelectionHelper;
     62     private final SelectionPredicate mSelectionPredicate;
     63     private final BandPredicate mBandPredicate;
     64     private final ContentLock mLock;
     65     private final Runnable mViewScroller;
     66     private final GridModel.SelectionObserver mGridObserver;
     67     private final List<Runnable> mBandStartedListeners = new ArrayList<>();
     68 
     69     @Nullable private Rect mBounds;
     70     @Nullable private Point mCurrentPosition;
     71     @Nullable private Point mOrigin;
     72     @Nullable private GridModel mModel;
     73 
     74     public BandSelectionHelper(
     75             BandHost host,
     76             RecyclerView.Adapter<?> adapter,
     77             StableIdProvider stableIds,
     78             SelectionHelper selectionHelper,
     79             SelectionPredicate selectionPredicate,
     80             BandPredicate bandPredicate,
     81             ContentLock lock) {
     82 
     83         checkArgument(host != null);
     84         checkArgument(adapter != null);
     85         checkArgument(stableIds != null);
     86         checkArgument(selectionHelper != null);
     87         checkArgument(selectionPredicate != null);
     88         checkArgument(bandPredicate != null);
     89         checkArgument(lock != null);
     90 
     91         mHost = host;
     92         mStableIds = stableIds;
     93         mAdapter = adapter;
     94         mSelectionHelper = selectionHelper;
     95         mSelectionPredicate = selectionPredicate;
     96         mBandPredicate = bandPredicate;
     97         mLock = lock;
     98 
     99         mHost.addOnScrollListener(
    100                 new OnScrollListener() {
    101                     @Override
    102                     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    103                         BandSelectionHelper.this.onScrolled(recyclerView, dx, dy);
    104                     }
    105                 });
    106 
    107         mViewScroller = new ViewAutoScroller(
    108                 new ScrollHost() {
    109                     @Override
    110                     public Point getCurrentPosition() {
    111                         return mCurrentPosition;
    112                     }
    113 
    114                     @Override
    115                     public int getViewHeight() {
    116                         return mHost.getHeight();
    117                     }
    118 
    119                     @Override
    120                     public boolean isActive() {
    121                         return BandSelectionHelper.this.isActive();
    122                     }
    123                 },
    124                 host);
    125 
    126         mAdapter.registerAdapterDataObserver(
    127                 new RecyclerView.AdapterDataObserver() {
    128                     @Override
    129                     public void onChanged() {
    130                         if (isActive()) {
    131                             endBandSelect();
    132                         }
    133                     }
    134 
    135                     @Override
    136                     public void onItemRangeChanged(
    137                             int startPosition, int itemCount, Object payload) {
    138                         // No change in position. Ignoring.
    139                     }
    140 
    141                     @Override
    142                     public void onItemRangeInserted(int startPosition, int itemCount) {
    143                         if (isActive()) {
    144                             endBandSelect();
    145                         }
    146                     }
    147 
    148                     @Override
    149                     public void onItemRangeRemoved(int startPosition, int itemCount) {
    150                         assert(startPosition >= 0);
    151                         assert(itemCount > 0);
    152 
    153                         // TODO: Should update grid model.
    154                     }
    155 
    156                     @Override
    157                     public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
    158                         throw new UnsupportedOperationException();
    159                     }
    160                 });
    161 
    162         mGridObserver = new GridModel.SelectionObserver() {
    163                 @Override
    164                 public void onSelectionChanged(Set<String> updatedSelection) {
    165                     mSelectionHelper.setProvisionalSelection(updatedSelection);
    166                 }
    167             };
    168     }
    169 
    170     @VisibleForTesting
    171     boolean isActive() {
    172         boolean active = mModel != null;
    173         if (Build.IS_DEBUGGABLE && active) {
    174             mLock.checkLocked();
    175         }
    176         return active;
    177     }
    178 
    179     /**
    180      * Adds a new listener to be notified when band is created.
    181      */
    182     public void addOnBandStartedListener(Runnable listener) {
    183         checkArgument(listener != null);
    184 
    185         mBandStartedListeners.add(listener);
    186     }
    187 
    188     /**
    189      * Removes listener. No-op if listener was not previously installed.
    190      */
    191     public void removeOnBandStartedListener(Runnable listener) {
    192         mBandStartedListeners.remove(listener);
    193     }
    194 
    195     /**
    196      * Clients must call reset when there are any material changes to the layout of items
    197      * in RecyclerView.
    198      */
    199     public void reset() {
    200         if (!isActive()) {
    201             return;
    202         }
    203 
    204         mHost.hideBand();
    205         mModel.stopCapturing();
    206         mModel.onDestroy();
    207         mModel = null;
    208         mOrigin = null;
    209         mLock.unblock();
    210     }
    211 
    212     boolean shouldStart(MotionEvent e) {
    213         // Don't start, or extend bands on non-left clicks.
    214         if (!MotionEvents.isPrimaryButtonPressed(e)) {
    215             return false;
    216         }
    217 
    218         // TODO: Refactor to NOT have side-effects on this "should" method.
    219         // Weird things happen if we keep up band select
    220         // when touch events happen.
    221         if (isActive() && !MotionEvents.isMouseEvent(e)) {
    222             endBandSelect();
    223             return false;
    224         }
    225 
    226         // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
    227         // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
    228         // mouse moves, or else starting band selection on mouse down can cause problems as events
    229         // don't get routed correctly to onTouchEvent.
    230         return !isActive()
    231                 && MotionEvents.isActionMove(e)
    232                 // the initial button move via mouse-touch (ie. down press)
    233                 // The adapter inserts items for UI layout purposes that aren't
    234                 // associated with files. Checking against actual modelIds count
    235                 // effectively ignores those UI layout items.
    236                 && !mStableIds.getStableIds().isEmpty()
    237                 && mBandPredicate.canInitiate(e);
    238     }
    239 
    240     public boolean shouldStop(MotionEvent e) {
    241         return isActive()
    242                 && MotionEvents.isMouseEvent(e)
    243                 && (MotionEvents.isActionUp(e)
    244                         || MotionEvents.isActionPointerUp(e)
    245                         || MotionEvents.isActionCancel(e));
    246     }
    247 
    248     @Override
    249     public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) {
    250         if (shouldStart(e)) {
    251             if (!MotionEvents.isCtrlKeyPressed(e)) {
    252                 mSelectionHelper.clearSelection();
    253             }
    254 
    255             startBandSelect(MotionEvents.getOrigin(e));
    256             return isActive();
    257         }
    258 
    259         if (shouldStop(e)) {
    260             endBandSelect();
    261             checkState(mModel == null);
    262             // fall through to return false, because the band eeess done!
    263         }
    264 
    265         return false;
    266     }
    267 
    268     /**
    269      * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
    270      * @param input
    271      */
    272     @Override
    273     public void onTouchEvent(RecyclerView unused, MotionEvent e) {
    274         if (shouldStop(e)) {
    275             endBandSelect();
    276             return;
    277         }
    278 
    279         // We shouldn't get any events in this method when band select is not active,
    280         // but it turns some guests show up late to the party.
    281         // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
    282         if (!isActive()) {
    283             return;
    284         }
    285 
    286         assert MotionEvents.isActionMove(e);
    287 
    288         mCurrentPosition = MotionEvents.getOrigin(e);
    289         mModel.resizeSelection(mCurrentPosition);
    290 
    291         scrollViewIfNecessary();
    292         resizeBand();
    293     }
    294 
    295     @Override
    296     public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
    297 
    298     /**
    299      * Starts band select by adding the drawable to the RecyclerView's overlay.
    300      */
    301     private void startBandSelect(Point origin) {
    302         if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
    303 
    304         reset();
    305         mModel = new GridModel(mHost, mStableIds, mSelectionPredicate);
    306         mModel.addOnSelectionChangedListener(mGridObserver);
    307 
    308         mLock.block();
    309         notifyBandStarted();
    310         mOrigin = origin;
    311         mModel.startCapturing(mOrigin);
    312     }
    313 
    314     private void notifyBandStarted() {
    315         for (Runnable listener : mBandStartedListeners) {
    316             listener.run();
    317         }
    318     }
    319 
    320     private void scrollViewIfNecessary() {
    321         mHost.removeCallback(mViewScroller);
    322         mViewScroller.run();
    323         mHost.invalidateView();
    324     }
    325 
    326     /**
    327      * Resizes the band select rectangle by using the origin and the current pointer position as
    328      * two opposite corners of the selection.
    329      */
    330     private void resizeBand() {
    331         mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
    332                 Math.min(mOrigin.y, mCurrentPosition.y),
    333                 Math.max(mOrigin.x, mCurrentPosition.x),
    334                 Math.max(mOrigin.y, mCurrentPosition.y));
    335 
    336         mHost.showBand(mBounds);
    337     }
    338 
    339     /**
    340      * Ends band select by removing the overlay.
    341      */
    342     private void endBandSelect() {
    343         if (DEBUG) Log.d(TAG, "Ending band select.");
    344 
    345         // TODO: Currently when a band select operation ends outside
    346         // of an item (e.g. in the empty area between items),
    347         // getPositionNearestOrigin may return an unselected item.
    348         // Since the point of this code is to establish the
    349         // anchor point for subsequent range operations (SHIFT+CLICK)
    350         // we really want to do a better job figuring out the last
    351         // item selected (and nearest to the cursor).
    352         int firstSelected = mModel.getPositionNearestOrigin();
    353         if (firstSelected != GridModel.NOT_SET
    354                 && mSelectionHelper.isSelected(mStableIds.getStableId(firstSelected))) {
    355             // Establish the band selection point as range anchor. This
    356             // allows touch and keyboard based selection activities
    357             // to be based on the band selection anchor point.
    358             mSelectionHelper.anchorRange(firstSelected);
    359         }
    360 
    361         mSelectionHelper.mergeProvisionalSelection();
    362         reset();
    363     }
    364 
    365     /**
    366      * @see RecyclerView.OnScrollListener
    367      */
    368     private void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    369         if (!isActive()) {
    370             return;
    371         }
    372 
    373         // Adjust the y-coordinate of the origin the opposite number of pixels so that the
    374         // origin remains in the same place relative to the view's items.
    375         mOrigin.y -= dy;
    376         resizeBand();
    377     }
    378 
    379     /**
    380      * Provides functionality for BandController. Exists primarily to tests that are
    381      * fully isolated from RecyclerView.
    382      */
    383     public static abstract class BandHost extends ScrollerCallbacks {
    384         public abstract void showBand(Rect rect);
    385         public abstract void hideBand();
    386         public abstract void addOnScrollListener(RecyclerView.OnScrollListener listener);
    387         public abstract void removeOnScrollListener(RecyclerView.OnScrollListener listener);
    388         public abstract int getHeight();
    389         public abstract void invalidateView();
    390         public abstract Point createAbsolutePoint(Point relativePoint);
    391         public abstract Rect getAbsoluteRectForChildViewAt(int index);
    392         public abstract int getAdapterPositionAt(int index);
    393         public abstract int getColumnCount();
    394         public abstract int getChildCount();
    395         public abstract int getVisibleChildCount();
    396         /**
    397          * @return true if the item at adapter position is attached to a view.
    398          */
    399         public abstract boolean hasView(int adapterPosition);
    400     }
    401 }
    402