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