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 com.android.documentsui.base.Shared.DEBUG;
     20 import static com.android.documentsui.ui.ViewAutoScroller.NOT_SET;
     21 
     22 import android.graphics.Point;
     23 import android.graphics.Rect;
     24 import android.graphics.drawable.Drawable;
     25 import android.support.annotation.Nullable;
     26 import android.support.annotation.VisibleForTesting;
     27 import android.support.v7.widget.GridLayoutManager;
     28 import android.support.v7.widget.RecyclerView;
     29 import android.support.v7.widget.RecyclerView.OnScrollListener;
     30 import android.util.Log;
     31 import android.util.SparseArray;
     32 import android.util.SparseBooleanArray;
     33 import android.util.SparseIntArray;
     34 import android.view.View;
     35 
     36 import com.android.documentsui.DirectoryReloadLock;
     37 import com.android.documentsui.R;
     38 import com.android.documentsui.base.Events.InputEvent;
     39 import com.android.documentsui.dirlist.DocumentsAdapter;
     40 import com.android.documentsui.ui.ViewAutoScroller;
     41 import com.android.documentsui.ui.ViewAutoScroller.ScrollActionDelegate;
     42 import com.android.documentsui.ui.ViewAutoScroller.ScrollDistanceDelegate;
     43 
     44 import java.util.ArrayList;
     45 import java.util.Collections;
     46 import java.util.HashSet;
     47 import java.util.List;
     48 import java.util.Map;
     49 import java.util.Set;
     50 import java.util.function.IntPredicate;
     51 
     52 /**
     53  * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView}
     54  * and {@link SelectionManager}. This class is responsible for rendering the band select
     55  * overlay and selecting overlaid items via SelectionManager.
     56  */
     57 public class BandController extends OnScrollListener {
     58 
     59     private static final String TAG = "BandController";
     60 
     61     private final Runnable mModelBuilder;
     62     private final SelectionEnvironment mEnvironment;
     63     private final DocumentsAdapter mAdapter;
     64     private final SelectionManager mSelectionManager;
     65     private final DirectoryReloadLock mLock;
     66     private final Runnable mViewScroller;
     67     private final GridModel.OnSelectionChangedListener mGridListener;
     68 
     69     @Nullable private Rect mBounds;
     70     @Nullable private Point mCurrentPosition;
     71     @Nullable private Point mOrigin;
     72     @Nullable private BandController.GridModel mModel;
     73 
     74     private Selection mSelection;
     75 
     76     public BandController(
     77             final RecyclerView view,
     78             DocumentsAdapter adapter,
     79             SelectionManager selectionManager,
     80             DirectoryReloadLock lock,
     81             IntPredicate gridItemTester) {
     82         this(new RuntimeSelectionEnvironment(view), adapter, selectionManager, lock, gridItemTester);
     83     }
     84 
     85     @VisibleForTesting
     86     BandController(
     87             SelectionEnvironment env,
     88             DocumentsAdapter adapter,
     89             SelectionManager selectionManager,
     90             DirectoryReloadLock lock,
     91             IntPredicate gridItemTester) {
     92 
     93         mLock = lock;
     94         selectionManager.bindContoller(this);
     95 
     96         mEnvironment = env;
     97         mAdapter = adapter;
     98         mSelectionManager = selectionManager;
     99 
    100         mEnvironment.addOnScrollListener(this);
    101         mViewScroller = new ViewAutoScroller(
    102                 new ScrollDistanceDelegate() {
    103                     @Override
    104                     public Point getCurrentPosition() {
    105                         return mCurrentPosition;
    106                     }
    107 
    108                     @Override
    109                     public int getViewHeight() {
    110                         return mEnvironment.getHeight();
    111                     }
    112 
    113                     @Override
    114                     public boolean isActive() {
    115                         return BandController.this.isActive();
    116                     }
    117                 },
    118                 env);
    119 
    120         mAdapter.registerAdapterDataObserver(
    121                 new RecyclerView.AdapterDataObserver() {
    122                     @Override
    123                     public void onChanged() {
    124                         if (isActive()) {
    125                             endBandSelect();
    126                         }
    127                     }
    128 
    129                     @Override
    130                     public void onItemRangeChanged(
    131                             int startPosition, int itemCount, Object payload) {
    132                         // No change in position. Ignoring.
    133                     }
    134 
    135                     @Override
    136                     public void onItemRangeInserted(int startPosition, int itemCount) {
    137                         if (isActive()) {
    138                             endBandSelect();
    139                         }
    140                     }
    141 
    142                     @Override
    143                     public void onItemRangeRemoved(int startPosition, int itemCount) {
    144                         assert(startPosition >= 0);
    145                         assert(itemCount > 0);
    146 
    147                         // TODO: Should update grid model.
    148                     }
    149 
    150                     @Override
    151                     public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
    152                         throw new UnsupportedOperationException();
    153                     }
    154                 });
    155 
    156         mGridListener = new GridModel.OnSelectionChangedListener() {
    157 
    158             @Override
    159             public void onSelectionChanged(Set<String> updatedSelection) {
    160                 BandController.this.onSelectionChanged(updatedSelection);
    161             }
    162 
    163             @Override
    164             public boolean onBeforeItemStateChange(String id, boolean nextState) {
    165                 return BandController.this.onBeforeItemStateChange(id, nextState);
    166             }
    167         };
    168 
    169         mModelBuilder = new Runnable() {
    170             @Override
    171             public void run() {
    172                 mModel = new GridModel(mEnvironment, gridItemTester, mAdapter);
    173                 mModel.addOnSelectionChangedListener(mGridListener);
    174             }
    175         };
    176     }
    177 
    178     @VisibleForTesting
    179     boolean isActive() {
    180         return mModel != null;
    181     }
    182 
    183     void bindSelection(Selection selection) {
    184         mSelection = selection;
    185     }
    186 
    187     public boolean onInterceptTouchEvent(InputEvent e) {
    188         if (shouldStart(e)) {
    189             if (!e.isCtrlKeyDown()) {
    190                 mSelectionManager.clearSelection();
    191             }
    192             startBandSelect(e.getOrigin());
    193         } else if (shouldStop(e)) {
    194             endBandSelect();
    195         }
    196 
    197         return isActive();
    198     }
    199 
    200     /**
    201      * Handle a change in layout by cleaning up and getting rid of the old model and creating
    202      * a new model which will track the new layout.
    203      */
    204     public void handleLayoutChanged() {
    205         if (mModel != null) {
    206             mModel.removeOnSelectionChangedListener(mGridListener);
    207             mModel.stopListening();
    208 
    209             // build a new model, all fresh and happy.
    210             mModelBuilder.run();
    211         }
    212     }
    213 
    214     public boolean shouldStart(InputEvent e) {
    215         // Don't start, or extend bands on non-left clicks.
    216         if (!e.isPrimaryButtonPressed()) {
    217             return false;
    218         }
    219 
    220         if (!e.isMouseEvent() && isActive()) {
    221             // Weird things happen if we keep up band select
    222             // when touch events happen.
    223             endBandSelect();
    224             return false;
    225         }
    226 
    227         // b/30146357 && b/23793622. onInterceptTouchEvent does not dispatch events to onTouchEvent
    228         // unless the event is != ACTION_DOWN. Thus, we need to actually start band selection when
    229         // mouse moves, or else starting band selection on mouse down can cause problems as events
    230         // don't get routed correctly to onTouchEvent.
    231         return !isActive()
    232                 && e.isActionMove() // the initial button move via mouse-touch (ie. down press)
    233                 && mAdapter.hasModelIds() // we want to check against actual modelIds count to
    234                                           // avoid dummy view count from the AdapterWrapper
    235                 && !e.isOverDragHotspot();
    236 
    237     }
    238 
    239     public boolean shouldStop(InputEvent input) {
    240         return isActive()
    241                 && input.isMouseEvent()
    242                 && (input.isActionUp() || input.isMultiPointerActionUp() || input.isActionCancel());
    243     }
    244 
    245     /**
    246      * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
    247      * @param input
    248      */
    249     public void onTouchEvent(InputEvent input) {
    250         assert(input.isMouseEvent());
    251 
    252         if (shouldStop(input)) {
    253             endBandSelect();
    254             return;
    255         }
    256 
    257         // We shouldn't get any events in this method when band select is not active,
    258         // but it turns some guests show up late to the party.
    259         // Probably happening when a re-layout is happening to the ReyclerView (ie. Pull-To-Refresh)
    260         if (!isActive()) {
    261             return;
    262         }
    263 
    264         assert(input.isActionMove());
    265         mCurrentPosition = input.getOrigin();
    266         mModel.resizeSelection(input.getOrigin());
    267         scrollViewIfNecessary();
    268         resizeBandSelectRectangle();
    269     }
    270 
    271     /**
    272      * Starts band select by adding the drawable to the RecyclerView's overlay.
    273      */
    274     private void startBandSelect(Point origin) {
    275         if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
    276 
    277         mLock.block();
    278         mOrigin = origin;
    279         mModelBuilder.run();  // Creates a new selection model.
    280         mModel.startSelection(mOrigin);
    281     }
    282 
    283     /**
    284      * Scrolls the view if necessary.
    285      */
    286     private void scrollViewIfNecessary() {
    287         mEnvironment.removeCallback(mViewScroller);
    288         mViewScroller.run();
    289         mEnvironment.invalidateView();
    290     }
    291 
    292     /**
    293      * Resizes the band select rectangle by using the origin and the current pointer position as
    294      * two opposite corners of the selection.
    295      */
    296     private void resizeBandSelectRectangle() {
    297         mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
    298                 Math.min(mOrigin.y, mCurrentPosition.y),
    299                 Math.max(mOrigin.x, mCurrentPosition.x),
    300                 Math.max(mOrigin.y, mCurrentPosition.y));
    301         mEnvironment.showBand(mBounds);
    302     }
    303 
    304     /**
    305      * Ends band select by removing the overlay.
    306      */
    307     private void endBandSelect() {
    308         if (DEBUG) Log.d(TAG, "Ending band select.");
    309 
    310         mEnvironment.hideBand();
    311         mSelection.applyProvisionalSelection();
    312         mModel.endSelection();
    313         int firstSelected = mModel.getPositionNearestOrigin();
    314         if (firstSelected != NOT_SET) {
    315             if (mSelection.contains(mAdapter.getModelId(firstSelected))) {
    316                 // TODO: firstSelected should really be lastSelected, we want to anchor the item
    317                 // where the mouse-up occurred.
    318                 mSelectionManager.setSelectionRangeBegin(firstSelected);
    319             } else {
    320                 // TODO: Check if this is really happening.
    321                 Log.w(TAG, "First selected by band is NOT in selection!");
    322             }
    323         }
    324 
    325         mModel = null;
    326         mOrigin = null;
    327         mLock.unblock();
    328     }
    329 
    330     private void onSelectionChanged(Set<String> updatedSelection) {
    331         Map<String, Boolean> delta = mSelection.setProvisionalSelection(updatedSelection);
    332         for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
    333             mSelectionManager.notifyItemStateChanged(entry.getKey(), entry.getValue());
    334         }
    335         mSelectionManager.notifySelectionChanged();
    336     }
    337 
    338     private boolean onBeforeItemStateChange(String id, boolean nextState) {
    339         return mSelectionManager.canSetState(id, nextState);
    340     }
    341 
    342     @Override
    343     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    344         if (!isActive()) {
    345             return;
    346         }
    347 
    348         // Adjust the y-coordinate of the origin the opposite number of pixels so that the
    349         // origin remains in the same place relative to the view's items.
    350         mOrigin.y -= dy;
    351         resizeBandSelectRectangle();
    352     }
    353 
    354     /**
    355      * Provides a band selection item model for views within a RecyclerView. This class queries the
    356      * RecyclerView to determine where its items are placed; then, once band selection is underway,
    357      * it alerts listeners of which items are covered by the selections.
    358      */
    359     @VisibleForTesting
    360     static final class GridModel extends RecyclerView.OnScrollListener {
    361 
    362         public static final int NOT_SET = -1;
    363 
    364         // Enum values used to determine the corner at which the origin is located within the
    365         private static final int UPPER = 0x00;
    366         private static final int LOWER = 0x01;
    367         private static final int LEFT = 0x00;
    368         private static final int RIGHT = 0x02;
    369         private static final int UPPER_LEFT = UPPER | LEFT;
    370         private static final int UPPER_RIGHT = UPPER | RIGHT;
    371         private static final int LOWER_LEFT = LOWER | LEFT;
    372         private static final int LOWER_RIGHT = LOWER | RIGHT;
    373 
    374         private final SelectionEnvironment mHelper;
    375         private final IntPredicate mGridItemTester;
    376         private final DocumentsAdapter mAdapter;
    377 
    378         private final List<GridModel.OnSelectionChangedListener> mOnSelectionChangedListeners =
    379                 new ArrayList<>();
    380 
    381         // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed
    382         // by their y-offset. For example, if the first column of the view starts at an x-value of 5,
    383         // mColumns.get(5) would return an array of positions in that column. Within that array, the
    384         // value for key y is the adapter position for the item whose y-offset is y.
    385         private final SparseArray<SparseIntArray> mColumns = new SparseArray<>();
    386 
    387         // List of limits along the x-axis (columns).
    388         // This list is sorted from furthest left to furthest right.
    389         private final List<GridModel.Limits> mColumnBounds = new ArrayList<>();
    390 
    391         // List of limits along the y-axis (rows). Note that this list only contains items which
    392         // have been in the viewport.
    393         private final List<GridModel.Limits> mRowBounds = new ArrayList<>();
    394 
    395         // The adapter positions which have been recorded so far.
    396         private final SparseBooleanArray mKnownPositions = new SparseBooleanArray();
    397 
    398         // Array passed to registered OnSelectionChangedListeners. One array is created and reused
    399         // throughout the lifetime of the object.
    400         private final Set<String> mSelection = new HashSet<>();
    401 
    402         // The current pointer (in absolute positioning from the top of the view).
    403         private Point mPointer = null;
    404 
    405         // The bounds of the band selection.
    406         private RelativePoint mRelativeOrigin;
    407         private RelativePoint mRelativePointer;
    408 
    409         private boolean mIsActive;
    410 
    411         // Tracks where the band select originated from. This is used to determine where selections
    412         // should expand from when Shift+click is used.
    413         private int mPositionNearestOrigin = NOT_SET;
    414 
    415         GridModel(SelectionEnvironment helper, IntPredicate gridItemTester, DocumentsAdapter adapter) {
    416             mHelper = helper;
    417             mAdapter = adapter;
    418             mGridItemTester = gridItemTester;
    419             mHelper.addOnScrollListener(this);
    420         }
    421 
    422         /**
    423          * Stops listening to the view's scrolls. Call this function before discarding a
    424          * BandSelecModel object to prevent memory leaks.
    425          */
    426         void stopListening() {
    427             mHelper.removeOnScrollListener(this);
    428         }
    429 
    430         /**
    431          * Start a band select operation at the given point.
    432          * @param relativeOrigin The origin of the band select operation, relative to the viewport.
    433          *     For example, if the view is scrolled to the bottom, the top-left of the viewport
    434          *     would have a relative origin of (0, 0), even though its absolute point has a higher
    435          *     y-value.
    436          */
    437         void startSelection(Point relativeOrigin) {
    438             recordVisibleChildren();
    439             if (isEmpty()) {
    440                 // The selection band logic works only if there is at least one visible child.
    441                 return;
    442             }
    443 
    444             mIsActive = true;
    445             mPointer = mHelper.createAbsolutePoint(relativeOrigin);
    446             mRelativeOrigin = new RelativePoint(mPointer);
    447             mRelativePointer = new RelativePoint(mPointer);
    448             computeCurrentSelection();
    449             notifyListeners();
    450         }
    451 
    452         /**
    453          * Resizes the selection by adjusting the pointer (i.e., the corner of the selection
    454          * opposite the origin.
    455          * @param relativePointer The pointer (opposite of the origin) of the band select operation,
    456          *     relative to the viewport. For example, if the view is scrolled to the bottom, the
    457          *     top-left of the viewport would have a relative origin of (0, 0), even though its
    458          *     absolute point has a higher y-value.
    459          */
    460         @VisibleForTesting
    461         void resizeSelection(Point relativePointer) {
    462             mPointer = mHelper.createAbsolutePoint(relativePointer);
    463             updateModel();
    464         }
    465 
    466         /**
    467          * Ends the band selection.
    468          */
    469         void endSelection() {
    470             mIsActive = false;
    471         }
    472 
    473         /**
    474          * @return The adapter position for the item nearest the origin corresponding to the latest
    475          *         band select operation, or NOT_SET if the selection did not cover any items.
    476          */
    477         int getPositionNearestOrigin() {
    478             return mPositionNearestOrigin;
    479         }
    480 
    481         @Override
    482         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    483             if (!mIsActive) {
    484                 return;
    485             }
    486 
    487             mPointer.x += dx;
    488             mPointer.y += dy;
    489             recordVisibleChildren();
    490             updateModel();
    491         }
    492 
    493         /**
    494          * Queries the view for all children and records their location metadata.
    495          */
    496         private void recordVisibleChildren() {
    497             for (int i = 0; i < mHelper.getVisibleChildCount(); i++) {
    498                 int adapterPosition = mHelper.getAdapterPositionAt(i);
    499                 // Sometimes the view is not attached, as we notify the multi selection manager
    500                 // synchronously, while views are attached asynchronously. As a result items which
    501                 // are in the adapter may not actually have a corresponding view (yet).
    502                 if (mHelper.hasView(adapterPosition) &&
    503                         mGridItemTester.test(adapterPosition) &&
    504                         !mKnownPositions.get(adapterPosition)) {
    505                     mKnownPositions.put(adapterPosition, true);
    506                     recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition);
    507                 }
    508             }
    509         }
    510 
    511         /**
    512          * Checks if there are any recorded children.
    513          */
    514         private boolean isEmpty() {
    515             return mColumnBounds.size() == 0 || mRowBounds.size() == 0;
    516         }
    517 
    518         /**
    519          * Updates the limits lists and column map with the given item metadata.
    520          * @param absoluteChildRect The absolute rectangle for the child view being processed.
    521          * @param adapterPosition The position of the child view being processed.
    522          */
    523         private void recordItemData(Rect absoluteChildRect, int adapterPosition) {
    524             if (mColumnBounds.size() != mHelper.getColumnCount()) {
    525                 // If not all x-limits have been recorded, record this one.
    526                 recordLimits(
    527                         mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right));
    528             }
    529 
    530             recordLimits(mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom));
    531 
    532             SparseIntArray columnList = mColumns.get(absoluteChildRect.left);
    533             if (columnList == null) {
    534                 columnList = new SparseIntArray();
    535                 mColumns.put(absoluteChildRect.left, columnList);
    536             }
    537             columnList.put(absoluteChildRect.top, adapterPosition);
    538         }
    539 
    540         /**
    541          * Ensures limits exists within the sorted list limitsList, and adds it to the list if it
    542          * does not exist.
    543          */
    544         private void recordLimits(List<GridModel.Limits> limitsList, GridModel.Limits limits) {
    545             int index = Collections.binarySearch(limitsList, limits);
    546             if (index < 0) {
    547                 limitsList.add(~index, limits);
    548             }
    549         }
    550 
    551         /**
    552          * Handles a moved pointer; this function determines whether the pointer movement resulted
    553          * in a selection change and, if it has, notifies listeners of this change.
    554          */
    555         private void updateModel() {
    556             RelativePoint old = mRelativePointer;
    557             mRelativePointer = new RelativePoint(mPointer);
    558             if (old != null && mRelativePointer.equals(old)) {
    559                 return;
    560             }
    561 
    562             computeCurrentSelection();
    563             notifyListeners();
    564         }
    565 
    566         /**
    567          * Computes the currently-selected items.
    568          */
    569         private void computeCurrentSelection() {
    570             if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) {
    571                 updateSelection(computeBounds());
    572             } else {
    573                 mSelection.clear();
    574                 mPositionNearestOrigin = NOT_SET;
    575             }
    576         }
    577 
    578         /**
    579          * Notifies all listeners of a selection change. Note that this function simply passes
    580          * mSelection, so computeCurrentSelection() should be called before this
    581          * function.
    582          */
    583         private void notifyListeners() {
    584             for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
    585                 listener.onSelectionChanged(mSelection);
    586             }
    587         }
    588 
    589         /**
    590          * @param rect Rectangle including all covered items.
    591          */
    592         private void updateSelection(Rect rect) {
    593             int columnStart =
    594                     Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left));
    595             assert(columnStart >= 0);
    596             int columnEnd = columnStart;
    597 
    598             for (int i = columnStart; i < mColumnBounds.size()
    599                     && mColumnBounds.get(i).lowerLimit <= rect.right; i++) {
    600                 columnEnd = i;
    601             }
    602 
    603             int rowStart = Collections.binarySearch(mRowBounds, new Limits(rect.top, rect.top));
    604             if (rowStart < 0) {
    605                 mPositionNearestOrigin = NOT_SET;
    606                 return;
    607             }
    608 
    609             int rowEnd = rowStart;
    610             for (int i = rowStart; i < mRowBounds.size()
    611                     && mRowBounds.get(i).lowerLimit <= rect.bottom; i++) {
    612                 rowEnd = i;
    613             }
    614 
    615             updateSelection(columnStart, columnEnd, rowStart, rowEnd);
    616         }
    617 
    618         /**
    619          * Computes the selection given the previously-computed start- and end-indices for each
    620          * row and column.
    621          */
    622         private void updateSelection(
    623                 int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) {
    624             if (DEBUG) Log.d(TAG, String.format("updateSelection: %d, %d, %d, %d",
    625                     columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex));
    626 
    627             mSelection.clear();
    628             for (int column = columnStartIndex; column <= columnEndIndex; column++) {
    629                 SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit);
    630                 for (int row = rowStartIndex; row <= rowEndIndex; row++) {
    631                     // The default return value for SparseIntArray.get is 0, which is a valid
    632                     // position. Use a sentry value to prevent erroneously selecting item 0.
    633                     final int rowKey = mRowBounds.get(row).lowerLimit;
    634                     int position = items.get(rowKey, NOT_SET);
    635                     if (position != NOT_SET) {
    636                         String id = mAdapter.getModelId(position);
    637                         if (id != null) {
    638                             // The adapter inserts items for UI layout purposes that aren't associated
    639                             // with files.  Those will have a null model ID.  Don't select them.
    640                             if (canSelect(id)) {
    641                                 mSelection.add(id);
    642                             }
    643                         }
    644                         if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex,
    645                                 row, rowStartIndex, rowEndIndex)) {
    646                             // If this is the position nearest the origin, record it now so that it
    647                             // can be returned by endSelection() later.
    648                             mPositionNearestOrigin = position;
    649                         }
    650                     }
    651                 }
    652             }
    653         }
    654 
    655         /**
    656          * @return True if the item is selectable.
    657          */
    658         private boolean canSelect(String id) {
    659             // TODO: Simplify the logic, so the check whether we can select is done in one place.
    660             // Consider injecting ActivityConfig, or move the checks from MultiSelectManager to
    661             // Selection.
    662             for (GridModel.OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
    663                 if (!listener.onBeforeItemStateChange(id, true)) {
    664                     return false;
    665                 }
    666             }
    667             return true;
    668         }
    669 
    670         /**
    671          * @return Returns true if the position is the nearest to the origin, or, in the case of the
    672          *     lower-right corner, whether it is possible that the position is the nearest to the
    673          *     origin. See comment below for reasoning for this special case.
    674          */
    675         private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex,
    676                 int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) {
    677             int corner = computeCornerNearestOrigin();
    678             switch (corner) {
    679                 case UPPER_LEFT:
    680                     return columnIndex == columnStartIndex && rowIndex == rowStartIndex;
    681                 case UPPER_RIGHT:
    682                     return columnIndex == columnEndIndex && rowIndex == rowStartIndex;
    683                 case LOWER_LEFT:
    684                     return columnIndex == columnStartIndex && rowIndex == rowEndIndex;
    685                 case LOWER_RIGHT:
    686                     // Note that in some cases, the last row will not have as many items as there
    687                     // are columns (e.g., if there are 4 items and 3 columns, the second row will
    688                     // only have one item in the first column). This function is invoked for each
    689                     // position from left to right, so return true for any position in the bottom
    690                     // row and only the right-most position in the bottom row will be recorded.
    691                     return rowIndex == rowEndIndex;
    692                 default:
    693                     throw new RuntimeException("Invalid corner type.");
    694             }
    695         }
    696 
    697         /**
    698          * Listener for changes in which items have been band selected.
    699          */
    700         static interface OnSelectionChangedListener {
    701             public void onSelectionChanged(Set<String> updatedSelection);
    702             public boolean onBeforeItemStateChange(String id, boolean nextState);
    703         }
    704 
    705         void addOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) {
    706             mOnSelectionChangedListeners.add(listener);
    707         }
    708 
    709         void removeOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) {
    710             mOnSelectionChangedListeners.remove(listener);
    711         }
    712 
    713         /**
    714          * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side
    715          * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides
    716          * of item columns and the top- and bottom sides of item rows so that it can be determined
    717          * whether the pointer is located within the bounds of an item.
    718          */
    719         private static class Limits implements Comparable<GridModel.Limits> {
    720             int lowerLimit;
    721             int upperLimit;
    722 
    723             Limits(int lowerLimit, int upperLimit) {
    724                 this.lowerLimit = lowerLimit;
    725                 this.upperLimit = upperLimit;
    726             }
    727 
    728             @Override
    729             public int compareTo(GridModel.Limits other) {
    730                 return lowerLimit - other.lowerLimit;
    731             }
    732 
    733             @Override
    734             public boolean equals(Object other) {
    735                 if (!(other instanceof GridModel.Limits)) {
    736                     return false;
    737                 }
    738 
    739                 return ((GridModel.Limits) other).lowerLimit == lowerLimit &&
    740                         ((GridModel.Limits) other).upperLimit == upperLimit;
    741             }
    742 
    743             @Override
    744             public String toString() {
    745                 return "(" + lowerLimit + ", " + upperLimit + ")";
    746             }
    747         }
    748 
    749         /**
    750          * The location of a coordinate relative to items. This class represents a general area of the
    751          * view as it relates to band selection rather than an explicit point. For example, two
    752          * different points within an item are considered to have the same "location" because band
    753          * selection originating within the item would select the same items no matter which point
    754          * was used. Same goes for points between items as well as those at the very beginning or end
    755          * of the view.
    756          *
    757          * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the
    758          * advantage of tying the value to the Limits of items along that axis. This allows easy
    759          * selection of items within those Limits as opposed to a search through every item to see if a
    760          * given coordinate value falls within those Limits.
    761          */
    762         private static class RelativeCoordinate
    763                 implements Comparable<GridModel.RelativeCoordinate> {
    764             /**
    765              * Location describing points after the last known item.
    766              */
    767             static final int AFTER_LAST_ITEM = 0;
    768 
    769             /**
    770              * Location describing points before the first known item.
    771              */
    772             static final int BEFORE_FIRST_ITEM = 1;
    773 
    774             /**
    775              * Location describing points between two items.
    776              */
    777             static final int BETWEEN_TWO_ITEMS = 2;
    778 
    779             /**
    780              * Location describing points within the limits of one item.
    781              */
    782             static final int WITHIN_LIMITS = 3;
    783 
    784             /**
    785              * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM,
    786              * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS.
    787              */
    788             final int type;
    789 
    790             /**
    791              * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type ==
    792              * BETWEEN_TWO_ITEMS.
    793              */
    794             GridModel.Limits limitsBeforeCoordinate;
    795 
    796             /**
    797              * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS.
    798              */
    799             GridModel.Limits limitsAfterCoordinate;
    800 
    801             // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM.
    802             GridModel.Limits mFirstKnownItem;
    803             // Limits of the last known item; only populated when type == AFTER_LAST_ITEM.
    804             GridModel.Limits mLastKnownItem;
    805 
    806             /**
    807              * @param limitsList The sorted limits list for the coordinate type. If this
    808              *     CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise,
    809              *     mYLimitsList should be pased.
    810              * @param value The coordinate value.
    811              */
    812             RelativeCoordinate(List<GridModel.Limits> limitsList, int value) {
    813                 int index = Collections.binarySearch(limitsList, new Limits(value, value));
    814 
    815                 if (index >= 0) {
    816                     this.type = WITHIN_LIMITS;
    817                     this.limitsBeforeCoordinate = limitsList.get(index);
    818                 } else if (~index == 0) {
    819                     this.type = BEFORE_FIRST_ITEM;
    820                     this.mFirstKnownItem = limitsList.get(0);
    821                 } else if (~index == limitsList.size()) {
    822                     GridModel.Limits lastLimits = limitsList.get(limitsList.size() - 1);
    823                     if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) {
    824                         this.type = WITHIN_LIMITS;
    825                         this.limitsBeforeCoordinate = lastLimits;
    826                     } else {
    827                         this.type = AFTER_LAST_ITEM;
    828                         this.mLastKnownItem = lastLimits;
    829                     }
    830                 } else {
    831                     GridModel.Limits limitsBeforeIndex = limitsList.get(~index - 1);
    832                     if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) {
    833                         this.type = WITHIN_LIMITS;
    834                         this.limitsBeforeCoordinate = limitsList.get(~index - 1);
    835                     } else {
    836                         this.type = BETWEEN_TWO_ITEMS;
    837                         this.limitsBeforeCoordinate = limitsList.get(~index - 1);
    838                         this.limitsAfterCoordinate = limitsList.get(~index);
    839                     }
    840                 }
    841             }
    842 
    843             int toComparisonValue() {
    844                 if (type == BEFORE_FIRST_ITEM) {
    845                     return mFirstKnownItem.lowerLimit - 1;
    846                 } else if (type == AFTER_LAST_ITEM) {
    847                     return mLastKnownItem.upperLimit + 1;
    848                 } else if (type == BETWEEN_TWO_ITEMS) {
    849                     return limitsBeforeCoordinate.upperLimit + 1;
    850                 } else {
    851                     return limitsBeforeCoordinate.lowerLimit;
    852                 }
    853             }
    854 
    855             @Override
    856             public boolean equals(Object other) {
    857                 if (!(other instanceof GridModel.RelativeCoordinate)) {
    858                     return false;
    859                 }
    860 
    861                 GridModel.RelativeCoordinate otherCoordinate = (GridModel.RelativeCoordinate) other;
    862                 return toComparisonValue() == otherCoordinate.toComparisonValue();
    863             }
    864 
    865             @Override
    866             public int compareTo(GridModel.RelativeCoordinate other) {
    867                 return toComparisonValue() - other.toComparisonValue();
    868             }
    869         }
    870 
    871         /**
    872          * The location of a point relative to the Limits of nearby items; consists of both an x- and
    873          * y-RelativeCoordinateLocation.
    874          */
    875         private class RelativePoint {
    876             final GridModel.RelativeCoordinate xLocation;
    877             final GridModel.RelativeCoordinate yLocation;
    878 
    879             RelativePoint(Point point) {
    880                 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
    881                 this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
    882             }
    883 
    884             @Override
    885             public boolean equals(Object other) {
    886                 if (!(other instanceof RelativePoint)) {
    887                     return false;
    888                 }
    889 
    890                 RelativePoint otherPoint = (RelativePoint) other;
    891                 return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation);
    892             }
    893         }
    894 
    895         /**
    896          * Generates a rectangle which contains the items selected by the pointer and origin.
    897          * @return The rectangle, or null if no items were selected.
    898          */
    899         private Rect computeBounds() {
    900             Rect rect = new Rect();
    901             rect.left = getCoordinateValue(
    902                     min(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
    903                     mColumnBounds,
    904                     true);
    905             rect.right = getCoordinateValue(
    906                     max(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
    907                     mColumnBounds,
    908                     false);
    909             rect.top = getCoordinateValue(
    910                     min(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
    911                     mRowBounds,
    912                     true);
    913             rect.bottom = getCoordinateValue(
    914                     max(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
    915                     mRowBounds,
    916                     false);
    917             return rect;
    918         }
    919 
    920         /**
    921          * Computes the corner of the selection nearest the origin.
    922          * @return
    923          */
    924         private int computeCornerNearestOrigin() {
    925             int cornerValue = 0;
    926 
    927             if (mRelativeOrigin.yLocation ==
    928                     min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) {
    929                 cornerValue |= UPPER;
    930             } else {
    931                 cornerValue |= LOWER;
    932             }
    933 
    934             if (mRelativeOrigin.xLocation ==
    935                     min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) {
    936                 cornerValue |= LEFT;
    937             } else {
    938                 cornerValue |= RIGHT;
    939             }
    940 
    941             return cornerValue;
    942         }
    943 
    944         private GridModel.RelativeCoordinate min(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) {
    945             return first.compareTo(second) < 0 ? first : second;
    946         }
    947 
    948         private GridModel.RelativeCoordinate max(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) {
    949             return first.compareTo(second) > 0 ? first : second;
    950         }
    951 
    952         /**
    953          * @return The absolute coordinate (i.e., the x- or y-value) of the given relative
    954          *     coordinate.
    955          */
    956         private int getCoordinateValue(GridModel.RelativeCoordinate coordinate,
    957                 List<GridModel.Limits> limitsList, boolean isStartOfRange) {
    958             switch (coordinate.type) {
    959                 case RelativeCoordinate.BEFORE_FIRST_ITEM:
    960                     return limitsList.get(0).lowerLimit;
    961                 case RelativeCoordinate.AFTER_LAST_ITEM:
    962                     return limitsList.get(limitsList.size() - 1).upperLimit;
    963                 case RelativeCoordinate.BETWEEN_TWO_ITEMS:
    964                     if (isStartOfRange) {
    965                         return coordinate.limitsAfterCoordinate.lowerLimit;
    966                     } else {
    967                         return coordinate.limitsBeforeCoordinate.upperLimit;
    968                     }
    969                 case RelativeCoordinate.WITHIN_LIMITS:
    970                     return coordinate.limitsBeforeCoordinate.lowerLimit;
    971             }
    972 
    973             throw new RuntimeException("Invalid coordinate value.");
    974         }
    975 
    976         private boolean areItemsCoveredByBand(
    977                 RelativePoint first, RelativePoint second) {
    978             return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
    979                     doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
    980         }
    981 
    982         private boolean doesCoordinateLocationCoverItems(
    983                 GridModel.RelativeCoordinate pointerCoordinate,
    984                 GridModel.RelativeCoordinate originCoordinate) {
    985             if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM &&
    986                     originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) {
    987                 return false;
    988             }
    989 
    990             if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM &&
    991                     originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) {
    992                 return false;
    993             }
    994 
    995             if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
    996                     originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
    997                     pointerCoordinate.limitsBeforeCoordinate.equals(
    998                             originCoordinate.limitsBeforeCoordinate) &&
    999                     pointerCoordinate.limitsAfterCoordinate.equals(
   1000                             originCoordinate.limitsAfterCoordinate)) {
   1001                 return false;
   1002             }
   1003 
   1004             return true;
   1005         }
   1006     }
   1007 
   1008     /**
   1009      * Provides functionality for BandController. Exists primarily to tests that are
   1010      * fully isolated from RecyclerView.
   1011      */
   1012     interface SelectionEnvironment extends ScrollActionDelegate {
   1013         void showBand(Rect rect);
   1014         void hideBand();
   1015         void addOnScrollListener(RecyclerView.OnScrollListener listener);
   1016         void removeOnScrollListener(RecyclerView.OnScrollListener listener);
   1017         int getHeight();
   1018         void invalidateView();
   1019         Point createAbsolutePoint(Point relativePoint);
   1020         Rect getAbsoluteRectForChildViewAt(int index);
   1021         int getAdapterPositionAt(int index);
   1022         int getColumnCount();
   1023         int getChildCount();
   1024         int getVisibleChildCount();
   1025         /**
   1026          * Items may be in the adapter, but without an attached view.
   1027          */
   1028         boolean hasView(int adapterPosition);
   1029     }
   1030 
   1031     /** Recycler view facade implementation backed by good ol' RecyclerView. */
   1032     private static final class RuntimeSelectionEnvironment implements SelectionEnvironment {
   1033 
   1034         private final RecyclerView mView;
   1035         private final Drawable mBand;
   1036 
   1037         private boolean mIsOverlayShown = false;
   1038 
   1039         RuntimeSelectionEnvironment(RecyclerView view) {
   1040             mView = view;
   1041             mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
   1042         }
   1043 
   1044         @Override
   1045         public int getAdapterPositionAt(int index) {
   1046             return mView.getChildAdapterPosition(mView.getChildAt(index));
   1047         }
   1048 
   1049         @Override
   1050         public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
   1051             mView.addOnScrollListener(listener);
   1052         }
   1053 
   1054         @Override
   1055         public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
   1056             mView.removeOnScrollListener(listener);
   1057         }
   1058 
   1059         @Override
   1060         public Point createAbsolutePoint(Point relativePoint) {
   1061             return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
   1062                     relativePoint.y + mView.computeVerticalScrollOffset());
   1063         }
   1064 
   1065         @Override
   1066         public Rect getAbsoluteRectForChildViewAt(int index) {
   1067             final View child = mView.getChildAt(index);
   1068             final Rect childRect = new Rect();
   1069             child.getHitRect(childRect);
   1070             childRect.left += mView.computeHorizontalScrollOffset();
   1071             childRect.right += mView.computeHorizontalScrollOffset();
   1072             childRect.top += mView.computeVerticalScrollOffset();
   1073             childRect.bottom += mView.computeVerticalScrollOffset();
   1074             return childRect;
   1075         }
   1076 
   1077         @Override
   1078         public int getChildCount() {
   1079             return mView.getAdapter().getItemCount();
   1080         }
   1081 
   1082         @Override
   1083         public int getVisibleChildCount() {
   1084             return mView.getChildCount();
   1085         }
   1086 
   1087         @Override
   1088         public int getColumnCount() {
   1089             RecyclerView.LayoutManager layoutManager = mView.getLayoutManager();
   1090             if (layoutManager instanceof GridLayoutManager) {
   1091                 return ((GridLayoutManager) layoutManager).getSpanCount();
   1092             }
   1093 
   1094             // Otherwise, it is a list with 1 column.
   1095             return 1;
   1096         }
   1097 
   1098         @Override
   1099         public int getHeight() {
   1100             return mView.getHeight();
   1101         }
   1102 
   1103         @Override
   1104         public void invalidateView() {
   1105             mView.invalidate();
   1106         }
   1107 
   1108         @Override
   1109         public void runAtNextFrame(Runnable r) {
   1110             mView.postOnAnimation(r);
   1111         }
   1112 
   1113         @Override
   1114         public void removeCallback(Runnable r) {
   1115             mView.removeCallbacks(r);
   1116         }
   1117 
   1118         @Override
   1119         public void scrollBy(int dy) {
   1120             mView.scrollBy(0, dy);
   1121         }
   1122 
   1123         @Override
   1124         public void showBand(Rect rect) {
   1125             mBand.setBounds(rect);
   1126 
   1127             if (!mIsOverlayShown) {
   1128                 mView.getOverlay().add(mBand);
   1129             }
   1130         }
   1131 
   1132         @Override
   1133         public void hideBand() {
   1134             mView.getOverlay().remove(mBand);
   1135         }
   1136 
   1137         @Override
   1138         public boolean hasView(int pos) {
   1139             return mView.findViewHolderForAdapterPosition(pos) != null;
   1140         }
   1141     }
   1142 }
   1143