blob: a725dfd044389317fc0c3fea28259085b04b68fe [file] [log] [blame]
Steve McKayef280152015-06-11 10:10:49 -07001/*
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
17package com.android.documentsui;
18
Steve McKay9459a7c2015-07-24 13:14:20 -070019import static com.android.internal.util.Preconditions.checkNotNull;
20import static com.android.internal.util.Preconditions.checkState;
21
Steve McKayef280152015-06-11 10:10:49 -070022import android.support.v7.widget.RecyclerView;
23import android.support.v7.widget.RecyclerView.Adapter;
24import android.support.v7.widget.RecyclerView.AdapterDataObserver;
25import android.util.Log;
26import android.util.SparseBooleanArray;
27import android.view.GestureDetector;
28import android.view.GestureDetector.OnGestureListener;
29import android.view.MotionEvent;
30import android.view.View;
31
32import com.google.common.annotations.VisibleForTesting;
33
34import java.util.ArrayList;
35import java.util.List;
36
37/**
38 * MultiSelectManager adds traditional multi-item selection support to RecyclerView.
39 */
40public final class MultiSelectManager {
41
42 private static final String TAG = "MultiSelectManager";
43 private static final boolean DEBUG = false;
44
45 private final Selection mSelection = new Selection();
46 // Only created when selection is cleared.
47 private Selection mIntermediateSelection;
48
49 private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
50
51 private Adapter<?> mAdapter;
52 private RecyclerViewHelper mHelper;
53
54 /**
55 * @param recyclerView
56 * @param gestureDelegate Option delage gesture listener.
57 */
58 public MultiSelectManager(final RecyclerView recyclerView, OnGestureListener gestureDelegate) {
59 this(
60 recyclerView.getAdapter(),
61 new RecyclerViewHelper() {
62 @Override
63 public int findEventPosition(MotionEvent e) {
64 View view = recyclerView.findChildViewUnder(e.getX(), e.getY());
65 return view != null
66 ? recyclerView.getChildAdapterPosition(view)
67 : RecyclerView.NO_POSITION;
68 }
69 });
70
71 GestureDetector.SimpleOnGestureListener listener =
72 new GestureDetector.SimpleOnGestureListener() {
73 @Override
74 public boolean onSingleTapUp(MotionEvent e) {
75 return MultiSelectManager.this.onSingleTapUp(e);
76 }
77 @Override
78 public void onLongPress(MotionEvent e) {
79 MultiSelectManager.this.onLongPress(e);
80 }
81 };
82
83 final GestureDetector detector = new GestureDetector(
84 recyclerView.getContext(),
85 gestureDelegate == null
86 ? listener
87 : new CompositeOnGestureListener(listener, gestureDelegate));
88
89 recyclerView.addOnItemTouchListener(
90 new RecyclerView.OnItemTouchListener() {
91 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
92 detector.onTouchEvent(e);
93 return false;
94 }
95 public void onTouchEvent(RecyclerView rv, MotionEvent e) {}
96 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
97 });
98 }
99
Steve McKay9459a7c2015-07-24 13:14:20 -0700100 /**
101 * Constructs a new instance with {@code adapter} and {@code helper}.
102 * @param adapter
103 * @param helper
104 * @hide
105 */
106 @VisibleForTesting
Steve McKayef280152015-06-11 10:10:49 -0700107 MultiSelectManager(Adapter<?> adapter, RecyclerViewHelper helper) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700108 checkNotNull(adapter, "'adapter' cannot be null.");
109 checkNotNull(helper, "'helper' cannot be null.");
Steve McKayef280152015-06-11 10:10:49 -0700110
111 mHelper = helper;
112 mAdapter = adapter;
113
114 mAdapter.registerAdapterDataObserver(
115 new AdapterDataObserver() {
116
117 @Override
118 public void onChanged() {
119 mSelection.clear();
120 }
121
122 @Override
123 public void onItemRangeChanged(
124 int positionStart, int itemCount, Object payload) {
125 // No change in position. Ignoring.
126 }
127
128 @Override
129 public void onItemRangeInserted(int positionStart, int itemCount) {
130 mSelection.expand(positionStart, itemCount);
131 }
132
133 @Override
134 public void onItemRangeRemoved(int positionStart, int itemCount) {
135 mSelection.collapse(positionStart, itemCount);
136 }
137
138 @Override
139 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
140 throw new UnsupportedOperationException();
141 }
142 });
143 }
144
Steve McKay9459a7c2015-07-24 13:14:20 -0700145 /**
146 * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
147 * events occur.
148 *
149 * @param callback
150 */
Steve McKayef280152015-06-11 10:10:49 -0700151 public void addCallback(MultiSelectManager.Callback callback) {
152 mCallbacks.add(callback);
153 }
154
155 /**
156 * Returns a Selection object that provides a live view
157 * on the current selection. Callers wishing to get
158 *
159 * @see #getSelectionSnapshot() on how to get a snapshot
160 * of the selection that will not reflect future changes
161 * to selection.
162 *
163 * @return The current seleciton.
164 */
165 public Selection getSelection() {
166 return mSelection;
167 }
168
169 /**
170 * Updates {@code dest} to reflect the current selection.
171 * @param dest
172 *
173 * @return The Selection instance passed in, for convenience.
174 */
175 public Selection getSelection(Selection dest) {
176 dest.copyFrom(mSelection);
177 return dest;
178 }
179
Steve McKay9459a7c2015-07-24 13:14:20 -0700180 /**
181 * Causes item at {@code position} in adapter to be selected.
182 *
183 * @param position Adapter position
184 * @param selected
185 * @return True if the selection state of the item changed.
186 */
187 public boolean setItemSelected(int position, boolean selected) {
188 boolean changed = (selected)
189 ? mSelection.add(position)
190 : mSelection.remove(position);
Steve McKayef280152015-06-11 10:10:49 -0700191
Steve McKay9459a7c2015-07-24 13:14:20 -0700192 if (changed) {
193 notifyItemStateChanged(position, true);
Steve McKayef280152015-06-11 10:10:49 -0700194 }
Steve McKay9459a7c2015-07-24 13:14:20 -0700195 return changed;
Steve McKayef280152015-06-11 10:10:49 -0700196 }
197
Steve McKay9459a7c2015-07-24 13:14:20 -0700198 /**
199 * @param position
200 * @param length
201 * @param selected
202 * @return True if the selection state of any of the items changed.
203 */
204 public boolean setItemsSelected(int position, int length, boolean selected) {
205 boolean changed = false;
206 for (int i = position; i < position + length; i++) {
207 changed |= setItemSelected(i, selected);
208 }
209 return changed;
210 }
211
212 /**
213 * Clears the selection.
214 */
Steve McKayef280152015-06-11 10:10:49 -0700215 public void clearSelection() {
Steve McKay9459a7c2015-07-24 13:14:20 -0700216 if (mSelection.isEmpty()) {
217 return;
218 }
Steve McKayef280152015-06-11 10:10:49 -0700219 if (mIntermediateSelection == null) {
220 mIntermediateSelection = new Selection();
221 }
222 getSelection(mIntermediateSelection);
223 mSelection.clear();
224
225 for (int i = 0; i < mIntermediateSelection.size(); i++) {
226 int position = mIntermediateSelection.get(i);
Steve McKayef280152015-06-11 10:10:49 -0700227 notifyItemStateChanged(position, false);
228 }
229 }
230
Steve McKay9459a7c2015-07-24 13:14:20 -0700231 /**
232 * @param e
233 * @return true if the event was consumed.
234 */
235 private boolean onSingleTapUp(MotionEvent e) {
Steve McKayef280152015-06-11 10:10:49 -0700236 if (DEBUG) Log.d(TAG, "Handling tap event.");
Steve McKay9459a7c2015-07-24 13:14:20 -0700237 if (mSelection.isEmpty()) {
Steve McKayef280152015-06-11 10:10:49 -0700238 return false;
239 }
240
241 return onSingleTapUp(mHelper.findEventPosition(e));
242 }
243
244 /**
Steve McKay9459a7c2015-07-24 13:14:20 -0700245 * TODO: Roll this into {@link #onSingleTapUp(MotionEvent)} once MotionEvent
246 * can be mocked.
247 *
Steve McKayef280152015-06-11 10:10:49 -0700248 * @param position
Steve McKay9459a7c2015-07-24 13:14:20 -0700249 * @return true if the event was consumed.
Steve McKayef280152015-06-11 10:10:49 -0700250 * @hide
251 */
252 @VisibleForTesting
253 boolean onSingleTapUp(int position) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700254 if (mSelection.isEmpty()) {
Steve McKayef280152015-06-11 10:10:49 -0700255 return false;
256 }
257
258 if (position == RecyclerView.NO_POSITION) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700259 if (DEBUG) Log.d(TAG, "View is null. Canceling selection.");
260 clearSelection();
261 return true;
Steve McKayef280152015-06-11 10:10:49 -0700262 }
263
264 toggleSelection(position);
265 return true;
266 }
267
Steve McKay9459a7c2015-07-24 13:14:20 -0700268 private void onLongPress(MotionEvent e) {
Steve McKayef280152015-06-11 10:10:49 -0700269 if (DEBUG) Log.d(TAG, "Handling long press event.");
270
271 int position = mHelper.findEventPosition(e);
272 if (position == RecyclerView.NO_POSITION) {
273 if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
274 }
275
276 toggleSelection(position);
277 }
278
279 /**
Steve McKay9459a7c2015-07-24 13:14:20 -0700280 * TODO: Roll this back into {@link #onLongPress(MotionEvent)} once MotionEvent
281 * can be mocked.
282 *
Steve McKayef280152015-06-11 10:10:49 -0700283 * @param position
284 * @hide
285 */
286 @VisibleForTesting
287 void onLongPress(int position) {
288 if (position == RecyclerView.NO_POSITION) {
289 if (DEBUG) Log.i(TAG, "View is null. Cannot handle tap event.");
290 }
291
292 toggleSelection(position);
293 }
294
295 private void toggleSelection(int position) {
296 // Position may be special "no position" during certain
297 // transitional phases. If so, skip handling of the event.
298 if (position == RecyclerView.NO_POSITION) {
299 if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
300 return;
301 }
302
303 if (DEBUG) Log.d(TAG, "Handling long press on view: " + position);
304 boolean nextState = !mSelection.contains(position);
305 if (notifyBeforeItemStateChange(position, nextState)) {
306 boolean selected = mSelection.flip(position);
307 notifyItemStateChanged(position, selected);
Steve McKayef280152015-06-11 10:10:49 -0700308 if (DEBUG) Log.d(TAG, "Selection after long press: " + mSelection);
309 } else {
310 Log.i(TAG, "Selection change cancelled by listener.");
311 }
312 }
313
314 private boolean notifyBeforeItemStateChange(int position, boolean nextState) {
315 int lastListener = mCallbacks.size() - 1;
316 for (int i = lastListener; i > -1; i--) {
317 if (!mCallbacks.get(i).onBeforeItemStateChange(position, nextState)) {
318 return false;
319 }
320 }
321 return true;
322 }
323
324 /**
325 * Notifies registered listeners when a selection changes.
326 *
327 * @param position
328 * @param selected
329 */
330 private void notifyItemStateChanged(int position, boolean selected) {
331 int lastListener = mCallbacks.size() - 1;
332 for (int i = lastListener; i > -1; i--) {
333 mCallbacks.get(i).onItemStateChanged(position, selected);
334 }
Steve McKay9459a7c2015-07-24 13:14:20 -0700335 mAdapter.notifyItemChanged(position);
Steve McKayef280152015-06-11 10:10:49 -0700336 }
337
338 /**
Steve McKay9459a7c2015-07-24 13:14:20 -0700339 * Object representing the current selection. Provides read only access
340 * public access, and private write access.
Steve McKayef280152015-06-11 10:10:49 -0700341 */
Steve McKayef280152015-06-11 10:10:49 -0700342 public static final class Selection {
343
344 private SparseBooleanArray mSelection;
345
346 public Selection() {
347 mSelection = new SparseBooleanArray();
348 }
349
350 /**
351 * @param position
352 * @return true if the position is currently selected.
353 */
354 public boolean contains(int position) {
355 return mSelection.get(position);
356 }
357
358 /**
359 * Useful for iterating over selection. Please note that
360 * iteration should be done over a copy of the selection,
361 * not the live selection.
362 *
363 * @see #copyTo(MultiSelectManager.Selection)
364 *
365 * @param index
366 * @return the position value stored at specified index.
367 */
368 public int get(int index) {
369 return mSelection.keyAt(index);
370 }
371
372 /**
373 * @return size of the selection.
374 */
375 public int size() {
376 return mSelection.size();
377 }
378
Steve McKay9459a7c2015-07-24 13:14:20 -0700379 /**
380 * @return true if the selection is empty.
381 */
382 public boolean isEmpty() {
383 return mSelection.size() == 0;
384 }
385
Steve McKayef280152015-06-11 10:10:49 -0700386 private boolean flip(int position) {
387 if (contains(position)) {
388 remove(position);
389 return false;
390 } else {
391 add(position);
392 return true;
393 }
394 }
395
396 /** @hide */
397 @VisibleForTesting
Steve McKay9459a7c2015-07-24 13:14:20 -0700398 boolean add(int position) {
399 if (!mSelection.get(position)) {
400 mSelection.put(position, true);
401 return true;
402 }
403 return false;
Steve McKayef280152015-06-11 10:10:49 -0700404 }
405
406 /** @hide */
407 @VisibleForTesting
Steve McKay9459a7c2015-07-24 13:14:20 -0700408 boolean remove(int position) {
409 if (mSelection.get(position)) {
410 mSelection.delete(position);
411 return true;
412 }
413 return false;
Steve McKayef280152015-06-11 10:10:49 -0700414 }
415
416 /**
417 * Adjusts the selection range to reflect the existence of newly inserted values at
418 * the specified positions. This has the effect of adjusting all existing selected
419 * positions within the specified range accordingly.
420 *
421 * @param startPosition
422 * @param count
423 * @hide
424 */
425 @VisibleForTesting
426 void expand(int startPosition, int count) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700427 checkState(startPosition >= 0);
428 checkState(count > 0);
Steve McKayef280152015-06-11 10:10:49 -0700429
430 for (int i = 0; i < mSelection.size(); i++) {
431 int itemPosition = mSelection.keyAt(i);
432 if (itemPosition >= startPosition) {
433 mSelection.setKeyAt(i, itemPosition + count);
434 }
435 }
436 }
437
438 /**
439 * Adjusts the selection range to reflect the removal specified positions. This has
440 * the effect of adjusting all existing selected positions within the specified range
441 * accordingly.
442 *
443 * @param startPosition
444 * @param count The length of the range to collapse. Must be greater than 0.
445 * @hide
446 */
447 @VisibleForTesting
448 void collapse(int startPosition, int count) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700449 checkState(startPosition >= 0);
450 checkState(count > 0);
Steve McKayef280152015-06-11 10:10:49 -0700451
452 int endPosition = startPosition + count - 1;
453
454 SparseBooleanArray newSelection = new SparseBooleanArray();
455 for (int i = 0; i < mSelection.size(); i++) {
456 int itemPosition = mSelection.keyAt(i);
457 if (itemPosition < startPosition) {
458 newSelection.append(itemPosition, true);
459 } else if (itemPosition > endPosition) {
460 newSelection.append(itemPosition - count, true);
461 }
462 }
463 mSelection = newSelection;
464 }
465
466 /** @hide */
467 @VisibleForTesting
468 void clear() {
469 mSelection.clear();
470 }
471
472 /** @hide */
473 @VisibleForTesting
474 void copyFrom(Selection source) {
475 mSelection = source.mSelection.clone();
476 }
477
478 @Override
479 public String toString() {
480 if (size() <= 0) {
481 return "size=0, items=[]";
482 }
483
484 StringBuilder buffer = new StringBuilder(mSelection.size() * 28);
485 buffer.append(String.format("{size=%d, ", mSelection.size()));
486 buffer.append("items=[");
487 for (int i=0; i < mSelection.size(); i++) {
488 if (i > 0) {
489 buffer.append(", ");
490 }
491 buffer.append(mSelection.keyAt(i));
492 }
493 buffer.append("]}");
494 return buffer.toString();
495 }
496
497 @Override
Steve McKayf4c06ab2015-07-22 11:42:14 -0700498 public int hashCode() {
499 return mSelection.hashCode();
500 }
501
502 @Override
Steve McKayef280152015-06-11 10:10:49 -0700503 public boolean equals(Object that) {
504 if (this == that) {
505 return true;
506 }
507
Steve McKayf4c06ab2015-07-22 11:42:14 -0700508 if (!(that instanceof Selection)) {
509 return false;
Steve McKayef280152015-06-11 10:10:49 -0700510 }
Steve McKayf4c06ab2015-07-22 11:42:14 -0700511
512 return mSelection.equals(((Selection) that).mSelection);
Steve McKayef280152015-06-11 10:10:49 -0700513 }
514 }
515
516 interface RecyclerViewHelper {
517 int findEventPosition(MotionEvent e);
518 }
519
520 public interface Callback {
521 /**
522 * Called when an item is selected or unselected while in selection mode.
523 *
524 * @param position Adapter position of the item that was checked or unchecked
525 * @param selected <code>true</code> if the item is now selected, <code>false</code>
526 * if the item is now unselected.
527 */
528 public void onItemStateChanged(int position, boolean selected);
529
530 /**
531 * @param position
532 * @param selected
533 * @return false to cancel the change.
534 */
535 public boolean onBeforeItemStateChange(int position, boolean selected);
536 }
537
538 /**
539 * A composite {@code OnGestureDetector} that allows us to delegate unhandled
Steve McKay9459a7c2015-07-24 13:14:20 -0700540 * events to an outside party (presumably DirectoryFragment).
Steve McKayef280152015-06-11 10:10:49 -0700541 */
542 private static final class CompositeOnGestureListener implements OnGestureListener {
543
544 private OnGestureListener[] mListeners;
545
546 public CompositeOnGestureListener(OnGestureListener... listeners) {
547 mListeners = listeners;
548 }
549
550 @Override
551 public boolean onDown(MotionEvent e) {
552 for (int i = 0; i < mListeners.length; i++) {
553 if (mListeners[i].onDown(e)) {
554 return true;
555 }
556 }
557 return false;
558 }
559
560 @Override
561 public void onShowPress(MotionEvent e) {
562 for (int i = 0; i < mListeners.length; i++) {
563 mListeners[i].onShowPress(e);
564 }
565 }
566
567 @Override
568 public boolean onSingleTapUp(MotionEvent e) {
569 for (int i = 0; i < mListeners.length; i++) {
570 if (mListeners[i].onSingleTapUp(e)) {
571 return true;
572 }
573 }
574 return false;
575 }
576
577 @Override
578 public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
579 for (int i = 0; i < mListeners.length; i++) {
580 if (mListeners[i].onScroll(e1, e2, distanceX, distanceY)) {
581 return true;
582 }
583 }
584 return false;
585 }
586
587 @Override
588 public void onLongPress(MotionEvent e) {
589 for (int i = 0; i < mListeners.length; i++) {
590 mListeners[i].onLongPress(e);
591 }
592 }
593
594 @Override
595 public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
596 for (int i = 0; i < mListeners.length; i++) {
597 if (mListeners[i].onFling(e1, e2, velocityX, velocityY)) {
598 return true;
599 }
600 }
601 return false;
602 }
603 }
604}