• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2016 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.os.Build;
24 import android.support.annotation.VisibleForTesting;
25 import android.support.v7.widget.RecyclerView;
26 import android.support.v7.widget.RecyclerView.OnItemTouchListener;
27 import android.util.Log;
28 import android.view.MotionEvent;
29 import android.view.View;
30 
31 import com.android.documentsui.selection.ViewAutoScroller.ScrollHost;
32 import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks;
33 
34 /**
35  * GestureSelectionHelper provides logic that interprets a combination
36  * of motions and gestures in order to provide gesture driven selection support
37  * when used in conjunction with RecyclerView and other classes in the ReyclerView
38  * selection support package.
39  */
40 public final class GestureSelectionHelper extends ScrollHost implements OnItemTouchListener {
41 
42     private static final String TAG = "GestureSelectionHelper";
43 
44     private final SelectionHelper mSelectionMgr;
45     private final Runnable mScroller;
46     private final ViewDelegate mView;
47     private final ContentLock mLock;
48     private final ItemDetailsLookup mItemLookup;
49 
50     private int mLastTouchedItemPosition = -1;
51     private boolean mStarted = false;
52     private Point mLastInterceptedPoint;
53 
54     /**
55      * See {@link #create(SelectionHelper, RecyclerView, ContentLock)} for convenience
56      * method.
57      */
58     @VisibleForTesting
GestureSelectionHelper( SelectionHelper selectionHelper, ViewDelegate view, ContentLock lock, ItemDetailsLookup itemLookup)59     GestureSelectionHelper(
60             SelectionHelper selectionHelper,
61             ViewDelegate view,
62             ContentLock lock,
63             ItemDetailsLookup itemLookup) {
64 
65         checkArgument(selectionHelper != null);
66         checkArgument(view != null);
67         checkArgument(lock != null);
68         checkArgument(itemLookup != null);
69 
70         mSelectionMgr = selectionHelper;
71         mView = view;
72         mLock = lock;
73         mItemLookup = itemLookup;
74 
75         mScroller = new ViewAutoScroller(this, mView);
76     }
77 
78     /**
79      * Explicitly kicks off a gesture multi-select.
80      *
81      * @return true if started.
82      */
start()83     public void start() {
84         checkState(!mStarted);
85         // See: b/70518185. It appears start() is being called via onLongPress
86         // even though we never received an intial handleInterceptedDownEvent
87         // where we would usually initialize mLastStartedItemPos.
88         if (mLastTouchedItemPosition < 0){
89           Log.w(TAG, "Illegal state. Can't start without valid mLastStartedItemPos.");
90           return;
91         }
92 
93         // Partner code in MotionInputHandler ensures items
94         // are selected and range established prior to
95         // start being called.
96         // Verify the truth of that statement here
97         // to make the implicit coupling less of a time bomb.
98         checkState(mSelectionMgr.isRangeActive());
99 
100         mLock.checkUnlocked();
101 
102         mStarted = true;
103         mLock.block();
104     }
105 
106     @Override
onInterceptTouchEvent(RecyclerView unused, MotionEvent e)107     public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) {
108         if (MotionEvents.isMouseEvent(e)) {
109             if (Shared.DEBUG) Log.w(TAG, "Unexpected Mouse event. Check configuration.");
110         }
111 
112         switch (e.getActionMasked()) {
113             case MotionEvent.ACTION_DOWN:
114                 // NOTE: Unlike events with other actions, RecyclerView eats
115                 // "DOWN" events. So even if we return true here we'll
116                 // never see an event w/ ACTION_DOWN passed to onTouchEvent.
117                 return handleInterceptedDownEvent(e);
118             case MotionEvent.ACTION_MOVE:
119                 return mStarted;
120         }
121 
122         return false;
123     }
124 
125     @Override
onTouchEvent(RecyclerView unused, MotionEvent e)126     public void onTouchEvent(RecyclerView unused, MotionEvent e) {
127         // Note: There were a couple times I as this check firing
128         // after combinations of mouse + touch + rotation.
129         // But after further investigation I couldn't repro.
130         // For that reason we guard this check (for now) w/ IS_DEBUGGABLE.
131         if (Build.IS_DEBUGGABLE) checkState(mStarted);
132 
133         switch (e.getActionMasked()) {
134             case MotionEvent.ACTION_MOVE:
135                 handleMoveEvent(e);
136                 break;
137             case MotionEvent.ACTION_UP:
138                 handleUpEvent(e);
139                 break;
140             case MotionEvent.ACTION_CANCEL:
141                 handleCancelEvent(e);
142                 break;
143         }
144     }
145 
146     @Override
onRequestDisallowInterceptTouchEvent(boolean disallowIntercept)147     public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
148 
149     // Called when an ACTION_DOWN event is intercepted. See onInterceptTouchEvent
150     // for additional notes.
151     // If down event happens on an item, we mark that item's position as last started.
handleInterceptedDownEvent(MotionEvent e)152     private boolean handleInterceptedDownEvent(MotionEvent e) {
153         // Ignore events where details provider doesn't return details.
154         // These objects don't participate in selection.
155         if (mItemLookup.getItemDetails(e) == null) {
156             return false;
157         }
158         mLastTouchedItemPosition = mView.getItemUnder(e);
159         return mLastTouchedItemPosition != RecyclerView.NO_POSITION;
160     }
161 
162     // Called when ACTION_UP event is to be handled.
163     // Essentially, since this means all gesture movement is over, reset everything and apply
164     // provisional selection.
handleUpEvent(MotionEvent e)165     private void handleUpEvent(MotionEvent e) {
166         mSelectionMgr.mergeProvisionalSelection();
167         endSelection();
168         if (mLastTouchedItemPosition > -1) {
169             mSelectionMgr.startRange(mLastTouchedItemPosition);
170         }
171     }
172 
173     // Called when ACTION_CANCEL event is to be handled.
174     // This means this gesture selection is aborted, so reset everything and abandon provisional
175     // selection.
handleCancelEvent(MotionEvent unused)176     private void handleCancelEvent(MotionEvent unused) {
177         mSelectionMgr.clearProvisionalSelection();
178         endSelection();
179     }
180 
endSelection()181     private void endSelection() {
182         checkState(mStarted);
183 
184         mLastTouchedItemPosition = -1;
185         mStarted = false;
186         mLock.unblock();
187     }
188 
189     // Call when an intercepted ACTION_MOVE event is passed down.
190     // At this point, we are sure user wants to gesture multi-select.
handleMoveEvent(MotionEvent e)191     private void handleMoveEvent(MotionEvent e) {
192         mLastInterceptedPoint = MotionEvents.getOrigin(e);
193 
194         int lastGlidedItemPos = mView.getLastGlidedItemPosition(e);
195         if (lastGlidedItemPos != RecyclerView.NO_POSITION) {
196             doGestureMultiSelect(lastGlidedItemPos);
197         }
198         scrollIfNecessary();
199     }
200 
201     // It's possible for events to go over the top/bottom of the RecyclerView.
202     // We want to get a Y-coordinate within the RecyclerView so we can find the childView underneath
203     // correctly.
getInboundY(float max, float y)204     private static float getInboundY(float max, float y) {
205         if (y < 0f) {
206             return 0f;
207         } else if (y > max) {
208             return max;
209         }
210         return y;
211     }
212 
213     /* Given the end position, select everything in-between.
214      * @param endPos  The adapter position of the end item.
215      */
doGestureMultiSelect(int endPos)216     private void doGestureMultiSelect(int endPos) {
217         mSelectionMgr.extendProvisionalRange(endPos);
218     }
219 
scrollIfNecessary()220     private void scrollIfNecessary() {
221         mScroller.run();
222     }
223 
224     @Override
getCurrentPosition()225     public Point getCurrentPosition() {
226         return mLastInterceptedPoint;
227     }
228 
229     @Override
getViewHeight()230     public int getViewHeight() {
231         return mView.getHeight();
232     }
233 
234     @Override
isActive()235     public boolean isActive() {
236         return mStarted && mSelectionMgr.hasSelection();
237     }
238 
239     /**
240      * Returns a new instance of GestureSelectionHelper, wrapping the
241      * RecyclerView in a test friendly wrapper.
242      */
create( SelectionHelper selectionMgr, RecyclerView recycler, ContentLock lock, ItemDetailsLookup itemLookup)243     public static GestureSelectionHelper create(
244             SelectionHelper selectionMgr,
245             RecyclerView recycler,
246             ContentLock lock,
247             ItemDetailsLookup itemLookup) {
248 
249         return new GestureSelectionHelper(
250                 selectionMgr, new RecyclerViewDelegate(recycler), lock, itemLookup);
251     }
252 
253     @VisibleForTesting
254     static abstract class ViewDelegate extends ScrollerCallbacks {
getHeight()255         abstract int getHeight();
getItemUnder(MotionEvent e)256         abstract int getItemUnder(MotionEvent e);
getLastGlidedItemPosition(MotionEvent e)257         abstract int getLastGlidedItemPosition(MotionEvent e);
258     }
259 
260     @VisibleForTesting
261     static final class RecyclerViewDelegate extends ViewDelegate {
262 
263         private final RecyclerView mView;
264 
RecyclerViewDelegate(RecyclerView view)265         RecyclerViewDelegate(RecyclerView view) {
266             checkArgument(view != null);
267             mView = view;
268         }
269 
270         @Override
getHeight()271         int getHeight() {
272             return mView.getHeight();
273         }
274 
275         @Override
getItemUnder(MotionEvent e)276         int getItemUnder(MotionEvent e) {
277             View child = mView.findChildViewUnder(e.getX(), e.getY());
278             return child != null
279                     ? mView.getChildAdapterPosition(child)
280                     : RecyclerView.NO_POSITION;
281         }
282 
283         @Override
getLastGlidedItemPosition(MotionEvent e)284         int getLastGlidedItemPosition(MotionEvent e) {
285             // If user has moved his pointer to the bottom-right empty pane (ie. to the right of the
286             // last item of the recycler view), we would want to set that as the currentItemPos
287             View lastItem = mView.getLayoutManager()
288                     .getChildAt(mView.getLayoutManager().getChildCount() - 1);
289             int direction =
290                     mView.getContext().getResources().getConfiguration().getLayoutDirection();
291             final boolean pastLastItem = isPastLastItem(lastItem.getTop(),
292                     lastItem.getLeft(),
293                     lastItem.getRight(),
294                     e,
295                     direction);
296 
297             // Since views get attached & detached from RecyclerView,
298             // {@link LayoutManager#getChildCount} can return a different number from the actual
299             // number
300             // of items in the adapter. Using the adapter is the for sure way to get the actual last
301             // item position.
302             final float inboundY = getInboundY(mView.getHeight(), e.getY());
303             return (pastLastItem) ? mView.getAdapter().getItemCount() - 1
304                     : mView.getChildAdapterPosition(mView.findChildViewUnder(e.getX(), inboundY));
305         }
306 
307         /*
308          * Check to see if MotionEvent if past a particular item, i.e. to the right or to the bottom
309          * of the item.
310          * For RTL, it would to be to the left or to the bottom of the item.
311          */
312         @VisibleForTesting
isPastLastItem(int top, int left, int right, MotionEvent e, int direction)313         static boolean isPastLastItem(int top, int left, int right, MotionEvent e, int direction) {
314             if (direction == View.LAYOUT_DIRECTION_LTR) {
315                 return e.getX() > right && e.getY() > top;
316             } else {
317                 return e.getX() < left && e.getY() > top;
318             }
319         }
320 
321         @Override
scrollBy(int dy)322         public void scrollBy(int dy) {
323             mView.scrollBy(0, dy);
324         }
325 
326         @Override
runAtNextFrame(Runnable r)327         public void runAtNextFrame(Runnable r) {
328             mView.postOnAnimation(r);
329         }
330 
331         @Override
removeCallback(Runnable r)332         public void removeCallback(Runnable r) {
333             mView.removeCallbacks(r);
334         }
335     }
336 }
337