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