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 
BandController( final RecyclerView view, DocumentsAdapter adapter, SelectionManager selectionManager, DirectoryReloadLock lock, IntPredicate gridItemTester)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
BandController( SelectionEnvironment env, DocumentsAdapter adapter, SelectionManager selectionManager, DirectoryReloadLock lock, IntPredicate gridItemTester)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
isActive()179     boolean isActive() {
180         return mModel != null;
181     }
182 
bindSelection(Selection selection)183     void bindSelection(Selection selection) {
184         mSelection = selection;
185     }
186 
onInterceptTouchEvent(InputEvent e)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      */
handleLayoutChanged()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 
shouldStart(InputEvent e)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 
shouldStop(InputEvent input)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      */
onTouchEvent(InputEvent input)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      */
startBandSelect(Point origin)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      */
scrollViewIfNecessary()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      */
resizeBandSelectRectangle()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      */
endBandSelect()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 
onSelectionChanged(Set<String> updatedSelection)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 
onBeforeItemStateChange(String id, boolean nextState)338     private boolean onBeforeItemStateChange(String id, boolean nextState) {
339         return mSelectionManager.canSetState(id, nextState);
340     }
341 
342     @Override
onScrolled(RecyclerView recyclerView, int dx, int dy)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 
GridModel(SelectionEnvironment helper, IntPredicate gridItemTester, DocumentsAdapter adapter)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          */
stopListening()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          */
startSelection(Point relativeOrigin)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
resizeSelection(Point relativePointer)461         void resizeSelection(Point relativePointer) {
462             mPointer = mHelper.createAbsolutePoint(relativePointer);
463             updateModel();
464         }
465 
466         /**
467          * Ends the band selection.
468          */
endSelection()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          */
getPositionNearestOrigin()477         int getPositionNearestOrigin() {
478             return mPositionNearestOrigin;
479         }
480 
481         @Override
onScrolled(RecyclerView recyclerView, int dx, int dy)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          */
recordVisibleChildren()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          */
isEmpty()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          */
recordItemData(Rect absoluteChildRect, int adapterPosition)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          */
recordLimits(List<GridModel.Limits> limitsList, GridModel.Limits limits)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          */
updateModel()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          */
computeCurrentSelection()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          */
notifyListeners()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          */
updateSelection(Rect rect)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          */
updateSelection( int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex)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          */
canSelect(String id)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          */
isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex, int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex)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 {
onSelectionChanged(Set<String> updatedSelection)701             public void onSelectionChanged(Set<String> updatedSelection);
onBeforeItemStateChange(String id, boolean nextState)702             public boolean onBeforeItemStateChange(String id, boolean nextState);
703         }
704 
addOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener)705         void addOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener) {
706             mOnSelectionChangedListeners.add(listener);
707         }
708 
removeOnSelectionChangedListener(GridModel.OnSelectionChangedListener listener)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 
Limits(int lowerLimit, int upperLimit)723             Limits(int lowerLimit, int upperLimit) {
724                 this.lowerLimit = lowerLimit;
725                 this.upperLimit = upperLimit;
726             }
727 
728             @Override
compareTo(GridModel.Limits other)729             public int compareTo(GridModel.Limits other) {
730                 return lowerLimit - other.lowerLimit;
731             }
732 
733             @Override
equals(Object other)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
toString()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              */
RelativeCoordinate(List<GridModel.Limits> limitsList, int value)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 
toComparisonValue()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
equals(Object other)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
compareTo(GridModel.RelativeCoordinate other)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 
RelativePoint(Point point)879             RelativePoint(Point point) {
880                 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
881                 this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
882             }
883 
884             @Override
equals(Object other)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          */
computeBounds()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          */
computeCornerNearestOrigin()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 
min(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second)944         private GridModel.RelativeCoordinate min(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second) {
945             return first.compareTo(second) < 0 ? first : second;
946         }
947 
max(GridModel.RelativeCoordinate first, GridModel.RelativeCoordinate second)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          */
getCoordinateValue(GridModel.RelativeCoordinate coordinate, List<GridModel.Limits> limitsList, boolean isStartOfRange)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 
areItemsCoveredByBand( RelativePoint first, RelativePoint second)976         private boolean areItemsCoveredByBand(
977                 RelativePoint first, RelativePoint second) {
978             return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
979                     doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
980         }
981 
doesCoordinateLocationCoverItems( GridModel.RelativeCoordinate pointerCoordinate, GridModel.RelativeCoordinate originCoordinate)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 {
showBand(Rect rect)1013         void showBand(Rect rect);
hideBand()1014         void hideBand();
addOnScrollListener(RecyclerView.OnScrollListener listener)1015         void addOnScrollListener(RecyclerView.OnScrollListener listener);
removeOnScrollListener(RecyclerView.OnScrollListener listener)1016         void removeOnScrollListener(RecyclerView.OnScrollListener listener);
getHeight()1017         int getHeight();
invalidateView()1018         void invalidateView();
createAbsolutePoint(Point relativePoint)1019         Point createAbsolutePoint(Point relativePoint);
getAbsoluteRectForChildViewAt(int index)1020         Rect getAbsoluteRectForChildViewAt(int index);
getAdapterPositionAt(int index)1021         int getAdapterPositionAt(int index);
getColumnCount()1022         int getColumnCount();
getChildCount()1023         int getChildCount();
getVisibleChildCount()1024         int getVisibleChildCount();
1025         /**
1026          * Items may be in the adapter, but without an attached view.
1027          */
hasView(int adapterPosition)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 
RuntimeSelectionEnvironment(RecyclerView view)1039         RuntimeSelectionEnvironment(RecyclerView view) {
1040             mView = view;
1041             mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
1042         }
1043 
1044         @Override
getAdapterPositionAt(int index)1045         public int getAdapterPositionAt(int index) {
1046             return mView.getChildAdapterPosition(mView.getChildAt(index));
1047         }
1048 
1049         @Override
addOnScrollListener(RecyclerView.OnScrollListener listener)1050         public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
1051             mView.addOnScrollListener(listener);
1052         }
1053 
1054         @Override
removeOnScrollListener(RecyclerView.OnScrollListener listener)1055         public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
1056             mView.removeOnScrollListener(listener);
1057         }
1058 
1059         @Override
createAbsolutePoint(Point relativePoint)1060         public Point createAbsolutePoint(Point relativePoint) {
1061             return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
1062                     relativePoint.y + mView.computeVerticalScrollOffset());
1063         }
1064 
1065         @Override
getAbsoluteRectForChildViewAt(int index)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
getChildCount()1078         public int getChildCount() {
1079             return mView.getAdapter().getItemCount();
1080         }
1081 
1082         @Override
getVisibleChildCount()1083         public int getVisibleChildCount() {
1084             return mView.getChildCount();
1085         }
1086 
1087         @Override
getColumnCount()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
getHeight()1099         public int getHeight() {
1100             return mView.getHeight();
1101         }
1102 
1103         @Override
invalidateView()1104         public void invalidateView() {
1105             mView.invalidate();
1106         }
1107 
1108         @Override
runAtNextFrame(Runnable r)1109         public void runAtNextFrame(Runnable r) {
1110             mView.postOnAnimation(r);
1111         }
1112 
1113         @Override
removeCallback(Runnable r)1114         public void removeCallback(Runnable r) {
1115             mView.removeCallbacks(r);
1116         }
1117 
1118         @Override
scrollBy(int dy)1119         public void scrollBy(int dy) {
1120             mView.scrollBy(0, dy);
1121         }
1122 
1123         @Override
showBand(Rect rect)1124         public void showBand(Rect rect) {
1125             mBand.setBounds(rect);
1126 
1127             if (!mIsOverlayShown) {
1128                 mView.getOverlay().add(mBand);
1129             }
1130         }
1131 
1132         @Override
hideBand()1133         public void hideBand() {
1134             mView.getOverlay().remove(mBand);
1135         }
1136 
1137         @Override
hasView(int pos)1138         public boolean hasView(int pos) {
1139             return mView.findViewHolderForAdapterPosition(pos) != null;
1140         }
1141     }
1142 }
1143