blob: 8a55cc36aba33db98683da5db2e78eda56ca8c11 [file] [log] [blame]
Ben Lin42728802016-08-19 16:07:39 -07001/*
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
Steve McKay0aa72072017-09-29 09:43:40 -070017package com.android.documentsui.selection;
Ben Lin42728802016-08-19 16:07:39 -070018
Steve McKay5a620372017-09-11 12:18:56 -070019import static android.support.v4.util.Preconditions.checkArgument;
20import static android.support.v4.util.Preconditions.checkState;
Steve McKay5a620372017-09-11 12:18:56 -070021
Ben Lin42728802016-08-19 16:07:39 -070022import android.graphics.Point;
Steve McKay5bf51ec2017-09-29 13:43:48 -070023import android.os.Build;
Louis Chang34032522018-06-08 09:33:48 +080024import android.support.annotation.NonNull;
Ben Linabbb6e02016-08-24 14:12:37 -070025import android.support.annotation.VisibleForTesting;
Ben Lin42728802016-08-19 16:07:39 -070026import android.support.v7.widget.RecyclerView;
Steve McKay07be1252017-09-22 10:58:15 -070027import android.support.v7.widget.RecyclerView.OnItemTouchListener;
Louis Chang34032522018-06-08 09:33:48 +080028
Steve McKay5bf51ec2017-09-29 13:43:48 -070029import android.util.Log;
Steve McKay57c559c2017-08-25 12:46:57 -070030import android.view.MotionEvent;
Ben Lin42728802016-08-19 16:07:39 -070031import android.view.View;
32
Steve McKay0aa72072017-09-29 09:43:40 -070033import com.android.documentsui.selection.ViewAutoScroller.ScrollHost;
34import com.android.documentsui.selection.ViewAutoScroller.ScrollerCallbacks;
Ben Lin42728802016-08-19 16:07:39 -070035
Steve McKay5bf51ec2017-09-29 13:43:48 -070036/**
37 * GestureSelectionHelper provides logic that interprets a combination
38 * of motions and gestures in order to provide gesture driven selection support
39 * when used in conjunction with RecyclerView and other classes in the ReyclerView
40 * selection support package.
Ben Lin42728802016-08-19 16:07:39 -070041 */
Steve McKay6373bfd2017-09-27 12:37:00 -070042public final class GestureSelectionHelper extends ScrollHost implements OnItemTouchListener {
Steve McKay53c8e802017-08-21 12:27:10 -070043
Steve McKay5bf51ec2017-09-29 13:43:48 -070044 private static final String TAG = "GestureSelectionHelper";
Ben Lin42728802016-08-19 16:07:39 -070045
Steve McKay365e3cb2017-08-31 10:27:08 -070046 private final SelectionHelper mSelectionMgr;
Steve McKay5a620372017-09-11 12:18:56 -070047 private final Runnable mScroller;
Steve McKay57c559c2017-08-25 12:46:57 -070048 private final ViewDelegate mView;
Steve McKay12394522017-08-24 14:14:10 -070049 private final ContentLock mLock;
Steve McKayb9373d02018-05-07 15:56:35 -070050 private final ItemDetailsLookup mItemLookup;
Steve McKay57c559c2017-08-25 12:46:57 -070051
Louis Chang34032522018-06-08 09:33:48 +080052 private int mLastTouchedItemPosition = RecyclerView.NO_POSITION;
Ben Lin42728802016-08-19 16:07:39 -070053 private boolean mStarted = false;
54 private Point mLastInterceptedPoint;
55
Steve McKay5bf51ec2017-09-29 13:43:48 -070056 /**
57 * See {@link #create(SelectionHelper, RecyclerView, ContentLock)} for convenience
58 * method.
59 */
60 @VisibleForTesting
Steve McKay365e3cb2017-08-31 10:27:08 -070061 GestureSelectionHelper(
Steve McKay5a620372017-09-11 12:18:56 -070062 SelectionHelper selectionHelper,
Steve McKay57c559c2017-08-25 12:46:57 -070063 ViewDelegate view,
Steve McKayb9373d02018-05-07 15:56:35 -070064 ContentLock lock,
65 ItemDetailsLookup itemLookup) {
Steve McKay12394522017-08-24 14:14:10 -070066
Steve McKay5a620372017-09-11 12:18:56 -070067 checkArgument(selectionHelper != null);
68 checkArgument(view != null);
69 checkArgument(lock != null);
Steve McKayb9373d02018-05-07 15:56:35 -070070 checkArgument(itemLookup != null);
Steve McKay5a620372017-09-11 12:18:56 -070071
72 mSelectionMgr = selectionHelper;
Steve McKay57c559c2017-08-25 12:46:57 -070073 mView = view;
Ben Lin9fea3122016-10-10 18:32:26 -070074 mLock = lock;
Steve McKayb9373d02018-05-07 15:56:35 -070075 mItemLookup = itemLookup;
Ben Lin42728802016-08-19 16:07:39 -070076
Steve McKay5a620372017-09-11 12:18:56 -070077 mScroller = new ViewAutoScroller(this, mView);
Steve McKay57c559c2017-08-25 12:46:57 -070078 }
79
80 /**
81 * Explicitly kicks off a gesture multi-select.
82 *
83 * @return true if started.
84 */
Steve McKay5bf51ec2017-09-29 13:43:48 -070085 public void start() {
86 checkState(!mStarted);
Steve McKay88336c62018-04-04 11:44:18 -070087 // See: b/70518185. It appears start() is being called via onLongPress
88 // even though we never received an intial handleInterceptedDownEvent
89 // where we would usually initialize mLastStartedItemPos.
Louis Chang34032522018-06-08 09:33:48 +080090 if (mLastTouchedItemPosition == RecyclerView.NO_POSITION) {
91 Log.w(TAG, "Illegal state. Can't start without valid mLastStartedItemPos.");
92 return;
Steve McKay88336c62018-04-04 11:44:18 -070093 }
Steve McKay5bf51ec2017-09-29 13:43:48 -070094
95 // Partner code in MotionInputHandler ensures items
96 // are selected and range established prior to
97 // start being called.
98 // Verify the truth of that statement here
99 // to make the implicit coupling less of a time bomb.
100 checkState(mSelectionMgr.isRangeActive());
101
102 mLock.checkUnlocked();
Steve McKay6373bfd2017-09-27 12:37:00 -0700103
Ben Lin42728802016-08-19 16:07:39 -0700104 mStarted = true;
Steve McKay6373bfd2017-09-27 12:37:00 -0700105 mLock.block();
Ben Lin42728802016-08-19 16:07:39 -0700106 }
107
Steve McKay6373bfd2017-09-27 12:37:00 -0700108 @Override
109 public boolean onInterceptTouchEvent(RecyclerView unused, MotionEvent e) {
Steve McKay57c559c2017-08-25 12:46:57 -0700110 if (MotionEvents.isMouseEvent(e)) {
Steve McKay5bf51ec2017-09-29 13:43:48 -0700111 if (Shared.DEBUG) Log.w(TAG, "Unexpected Mouse event. Check configuration.");
Ben Lin42728802016-08-19 16:07:39 -0700112 }
113
Louis Chang34032522018-06-08 09:33:48 +0800114 // TODO(b/109808552): It seems that mLastStartedItemPos should likely be set as a method
115 // parameter in start().
116 if (e.getActionMasked() == MotionEvent.ACTION_DOWN) {
117 if (mItemLookup.getItemDetails(e) != null) {
118 mLastTouchedItemPosition = mView.getItemUnder(e);
119 }
120 }
121
122 // See handleTouch(MotionEvent) javadoc for explanation as to why this is correct.
123 return handleTouch(e);
124 }
125
126 @Override
127 /** @hide */
128 public void onTouchEvent(@NonNull RecyclerView unused, @NonNull MotionEvent e) {
129 // See handleTouch(MotionEvent) javadoc for explanation as to why this is correct.
130 handleTouch(e);
131 }
132
133 /**
134 * If selection has started, will handle all appropriate types of MotionEvents and will return
135 * true if this OnItemTouchListener should start intercepting the rest of the MotionEvents.
136 *
137 * <p>This code, and the fact that this method is used by both OnInterceptTouchEvent and
138 * OnTouchEvent, is correct and valid because:
139 * <ol>
140 * <li>MotionEvents that aren't ACTION_DOWN are only ever passed to either onInterceptTouchEvent
141 * or onTouchEvent; never to both. The MotionEvents we are handling in this method are not
142 * ACTION_DOWN, and therefore, its appropriate that both the onInterceptTouchEvent and
143 * onTouchEvent code paths cross this method.
144 * <li>This method returns true when we want to intercept MotionEvents. OnInterceptTouchEvent
145 * uses that information to determine its own return, and OnMotionEvent doesn't have a return
146 * so this methods return value is irrelevant to it.
147 * </ol>
148 */
149 private boolean handleTouch(MotionEvent e) {
150 if (!mStarted) {
151 return false;
152 }
153
Steve McKay6373bfd2017-09-27 12:37:00 -0700154 switch (e.getActionMasked()) {
Steve McKay6373bfd2017-09-27 12:37:00 -0700155 case MotionEvent.ACTION_MOVE:
Louis Chang34032522018-06-08 09:33:48 +0800156 handleMoveEvent(e);
157 return true;
158 case MotionEvent.ACTION_UP:
159 handleUpEvent();
160 return true;
161 case MotionEvent.ACTION_CANCEL:
162 handleCancelEvent();
163 return true;
Ben Lin42728802016-08-19 16:07:39 -0700164 }
165
Steve McKay6373bfd2017-09-27 12:37:00 -0700166 return false;
Ben Lin42728802016-08-19 16:07:39 -0700167 }
168
Steve McKay6373bfd2017-09-27 12:37:00 -0700169 @Override
Louis Chang34032522018-06-08 09:33:48 +0800170 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
Ben Lin42728802016-08-19 16:07:39 -0700171 }
172
Garfield Tanf7df7152016-10-27 16:55:14 -0700173 // Called when ACTION_UP event is to be handled.
174 // Essentially, since this means all gesture movement is over, reset everything and apply
175 // provisional selection.
Louis Chang34032522018-06-08 09:33:48 +0800176 private void handleUpEvent() {
Steve McKay88456592017-08-24 10:09:01 -0700177 mSelectionMgr.mergeProvisionalSelection();
Garfield Tanf7df7152016-10-27 16:55:14 -0700178 endSelection();
Louis Chang34032522018-06-08 09:33:48 +0800179 if (mLastTouchedItemPosition != RecyclerView.NO_POSITION) {
Steve McKayb9373d02018-05-07 15:56:35 -0700180 mSelectionMgr.startRange(mLastTouchedItemPosition);
Steve McKay6373bfd2017-09-27 12:37:00 -0700181 }
Garfield Tanf7df7152016-10-27 16:55:14 -0700182 }
183
184 // Called when ACTION_CANCEL event is to be handled.
185 // This means this gesture selection is aborted, so reset everything and abandon provisional
186 // selection.
Louis Chang34032522018-06-08 09:33:48 +0800187 private void handleCancelEvent() {
Steve McKay88456592017-08-24 10:09:01 -0700188 mSelectionMgr.clearProvisionalSelection();
Garfield Tanf7df7152016-10-27 16:55:14 -0700189 endSelection();
190 }
191
192 private void endSelection() {
Steve McKay5a620372017-09-11 12:18:56 -0700193 checkState(mStarted);
194
Louis Chang34032522018-06-08 09:33:48 +0800195 mLastTouchedItemPosition = RecyclerView.NO_POSITION;
Garfield Tanf7df7152016-10-27 16:55:14 -0700196 mStarted = false;
197 mLock.unblock();
Ben Lin42728802016-08-19 16:07:39 -0700198 }
199
200 // Call when an intercepted ACTION_MOVE event is passed down.
201 // At this point, we are sure user wants to gesture multi-select.
Steve McKay5bf51ec2017-09-29 13:43:48 -0700202 private void handleMoveEvent(MotionEvent e) {
Steve McKay57c559c2017-08-25 12:46:57 -0700203 mLastInterceptedPoint = MotionEvents.getOrigin(e);
Ben Lin42728802016-08-19 16:07:39 -0700204
Steve McKay57c559c2017-08-25 12:46:57 -0700205 int lastGlidedItemPos = mView.getLastGlidedItemPosition(e);
Ben Lin35f99e02016-08-31 12:46:04 -0700206 if (lastGlidedItemPos != RecyclerView.NO_POSITION) {
207 doGestureMultiSelect(lastGlidedItemPos);
208 }
Ben Lin3dbd3b12016-09-27 14:03:04 -0700209 scrollIfNecessary();
Ben Lin42728802016-08-19 16:07:39 -0700210 }
211
212 // It's possible for events to go over the top/bottom of the RecyclerView.
213 // We want to get a Y-coordinate within the RecyclerView so we can find the childView underneath
214 // correctly.
Ben Linabbb6e02016-08-24 14:12:37 -0700215 private static float getInboundY(float max, float y) {
Ben Lin42728802016-08-19 16:07:39 -0700216 if (y < 0f) {
217 return 0f;
218 } else if (y > max) {
219 return max;
220 }
221 return y;
222 }
223
Ben Lin42728802016-08-19 16:07:39 -0700224 /* Given the end position, select everything in-between.
225 * @param endPos The adapter position of the end item.
226 */
227 private void doGestureMultiSelect(int endPos) {
Steve McKay365e3cb2017-08-31 10:27:08 -0700228 mSelectionMgr.extendProvisionalRange(endPos);
Ben Lin42728802016-08-19 16:07:39 -0700229 }
230
Ben Lin3dbd3b12016-09-27 14:03:04 -0700231 private void scrollIfNecessary() {
Steve McKay5a620372017-09-11 12:18:56 -0700232 mScroller.run();
Ben Lin42728802016-08-19 16:07:39 -0700233 }
Ben Lin35f99e02016-08-31 12:46:04 -0700234
Steve McKay5a620372017-09-11 12:18:56 -0700235 @Override
236 public Point getCurrentPosition() {
237 return mLastInterceptedPoint;
238 }
239
240 @Override
241 public int getViewHeight() {
242 return mView.getHeight();
243 }
244
245 @Override
246 public boolean isActive() {
247 return mStarted && mSelectionMgr.hasSelection();
248 }
249
Steve McKay5bf51ec2017-09-29 13:43:48 -0700250 /**
Steve McKayb9373d02018-05-07 15:56:35 -0700251 * Returns a new instance of GestureSelectionHelper, wrapping the
252 * RecyclerView in a test friendly wrapper.
Steve McKay5bf51ec2017-09-29 13:43:48 -0700253 */
Steve McKay5a620372017-09-11 12:18:56 -0700254 public static GestureSelectionHelper create(
Steve McKayb9373d02018-05-07 15:56:35 -0700255 SelectionHelper selectionMgr,
256 RecyclerView recycler,
257 ContentLock lock,
258 ItemDetailsLookup itemLookup) {
Steve McKay5a620372017-09-11 12:18:56 -0700259
Steve McKay5bf51ec2017-09-29 13:43:48 -0700260 return new GestureSelectionHelper(
Steve McKayb9373d02018-05-07 15:56:35 -0700261 selectionMgr, new RecyclerViewDelegate(recycler), lock, itemLookup);
Steve McKay5a620372017-09-11 12:18:56 -0700262 }
263
Steve McKay5bf51ec2017-09-29 13:43:48 -0700264 @VisibleForTesting
265 static abstract class ViewDelegate extends ScrollerCallbacks {
Steve McKay5a620372017-09-11 12:18:56 -0700266 abstract int getHeight();
Louis Chang34032522018-06-08 09:33:48 +0800267
Steve McKay5a620372017-09-11 12:18:56 -0700268 abstract int getItemUnder(MotionEvent e);
Louis Chang34032522018-06-08 09:33:48 +0800269
Steve McKay5a620372017-09-11 12:18:56 -0700270 abstract int getLastGlidedItemPosition(MotionEvent e);
271 }
272
273 @VisibleForTesting
274 static final class RecyclerViewDelegate extends ViewDelegate {
275
276 private final RecyclerView mView;
277
278 RecyclerViewDelegate(RecyclerView view) {
279 checkArgument(view != null);
280 mView = view;
281 }
282
283 @Override
Steve McKay5bf51ec2017-09-29 13:43:48 -0700284 int getHeight() {
Steve McKay5a620372017-09-11 12:18:56 -0700285 return mView.getHeight();
286 }
287
288 @Override
Steve McKay5bf51ec2017-09-29 13:43:48 -0700289 int getItemUnder(MotionEvent e) {
Steve McKay5a620372017-09-11 12:18:56 -0700290 View child = mView.findChildViewUnder(e.getX(), e.getY());
291 return child != null
292 ? mView.getChildAdapterPosition(child)
293 : RecyclerView.NO_POSITION;
294 }
295
296 @Override
Steve McKay5bf51ec2017-09-29 13:43:48 -0700297 int getLastGlidedItemPosition(MotionEvent e) {
Steve McKay5a620372017-09-11 12:18:56 -0700298 // If user has moved his pointer to the bottom-right empty pane (ie. to the right of the
299 // last item of the recycler view), we would want to set that as the currentItemPos
300 View lastItem = mView.getLayoutManager()
301 .getChildAt(mView.getLayoutManager().getChildCount() - 1);
302 int direction =
303 mView.getContext().getResources().getConfiguration().getLayoutDirection();
304 final boolean pastLastItem = isPastLastItem(lastItem.getTop(),
305 lastItem.getLeft(),
306 lastItem.getRight(),
307 e,
308 direction);
309
310 // Since views get attached & detached from RecyclerView,
311 // {@link LayoutManager#getChildCount} can return a different number from the actual
312 // number
313 // of items in the adapter. Using the adapter is the for sure way to get the actual last
314 // item position.
315 final float inboundY = getInboundY(mView.getHeight(), e.getY());
316 return (pastLastItem) ? mView.getAdapter().getItemCount() - 1
317 : mView.getChildAdapterPosition(mView.findChildViewUnder(e.getX(), inboundY));
318 }
319
320 /*
321 * Check to see if MotionEvent if past a particular item, i.e. to the right or to the bottom
322 * of the item.
323 * For RTL, it would to be to the left or to the bottom of the item.
324 */
325 @VisibleForTesting
326 static boolean isPastLastItem(int top, int left, int right, MotionEvent e, int direction) {
327 if (direction == View.LAYOUT_DIRECTION_LTR) {
328 return e.getX() > right && e.getY() > top;
329 } else {
330 return e.getX() < left && e.getY() > top;
331 }
332 }
333
334 @Override
335 public void scrollBy(int dy) {
336 mView.scrollBy(0, dy);
337 }
338
339 @Override
340 public void runAtNextFrame(Runnable r) {
341 mView.postOnAnimation(r);
342 }
343
344 @Override
345 public void removeCallback(Runnable r) {
346 mView.removeCallbacks(r);
347 }
Ben Lin35f99e02016-08-31 12:46:04 -0700348 }
Steve McKay57c559c2017-08-25 12:46:57 -0700349}