blob: ef53d53c2f784794968b2fc63dae2021d02a2b28 [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 McKay093d5e72015-09-03 16:49:51 -070019import static com.android.documentsui.Shared.DEBUG;
Steve McKay8c1b8c82015-07-28 19:20:01 -070020import static com.android.internal.util.Preconditions.checkArgument;
Steve McKay9459a7c2015-07-24 13:14:20 -070021import static com.android.internal.util.Preconditions.checkNotNull;
22import static com.android.internal.util.Preconditions.checkState;
23
Steve McKay093d5e72015-09-03 16:49:51 -070024import android.graphics.Point;
25import android.graphics.Rect;
26import android.graphics.drawable.Drawable;
27import android.support.annotation.Nullable;
28import android.support.annotation.VisibleForTesting;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -070029import android.support.v7.widget.GridLayoutManager;
Steve McKayef280152015-06-11 10:10:49 -070030import android.support.v7.widget.RecyclerView;
31import android.support.v7.widget.RecyclerView.Adapter;
32import android.support.v7.widget.RecyclerView.AdapterDataObserver;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -070033import android.support.v7.widget.RecyclerView.LayoutManager;
34import android.support.v7.widget.RecyclerView.OnScrollListener;
Steve McKayef280152015-06-11 10:10:49 -070035import android.util.Log;
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -070036import android.util.SparseArray;
Steve McKayef280152015-06-11 10:10:49 -070037import android.util.SparseBooleanArray;
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -070038import android.util.SparseIntArray;
Steve McKayef280152015-06-11 10:10:49 -070039import android.view.GestureDetector;
Ben Kwaa017bc12015-10-07 14:15:12 -070040import android.view.KeyEvent;
Steve McKayef280152015-06-11 10:10:49 -070041import android.view.MotionEvent;
42import android.view.View;
Steve McKay093d5e72015-09-03 16:49:51 -070043
44import com.android.documentsui.Events.InputEvent;
45import com.android.documentsui.Events.MotionInputEvent;
Steve McKayef280152015-06-11 10:10:49 -070046
47import java.util.ArrayList;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -070048import java.util.Collections;
Steve McKayef280152015-06-11 10:10:49 -070049import java.util.List;
50
51/**
Steve McKay57394872015-08-12 14:48:34 -070052 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
53 * Additionally it can be configured to restrict selection to a single element, @see
54 * #setSelectMode.
Steve McKayef280152015-06-11 10:10:49 -070055 */
Ben Kwaa017bc12015-10-07 14:15:12 -070056public final class MultiSelectManager implements View.OnKeyListener {
Steve McKayef280152015-06-11 10:10:49 -070057
Steve McKay57394872015-08-12 14:48:34 -070058 /** Selection mode for multiple select. **/
59 public static final int MODE_MULTIPLE = 0;
60
61 /** Selection mode for multiple select. **/
62 public static final int MODE_SINGLE = 1;
63
Steve McKayef280152015-06-11 10:10:49 -070064 private static final String TAG = "MultiSelectManager";
Steve McKayef280152015-06-11 10:10:49 -070065
66 private final Selection mSelection = new Selection();
Steve McKay8c1b8c82015-07-28 19:20:01 -070067
Steve McKayef280152015-06-11 10:10:49 -070068 // Only created when selection is cleared.
69 private Selection mIntermediateSelection;
70
Steve McKay93d8ef42015-07-30 12:27:44 -070071 private Range mRanger;
Ben Kwaa017bc12015-10-07 14:15:12 -070072 private SelectionEnvironment mEnvironment;
73
Steve McKayef280152015-06-11 10:10:49 -070074 private final List<MultiSelectManager.Callback> mCallbacks = new ArrayList<>(1);
75
76 private Adapter<?> mAdapter;
Steve McKay57394872015-08-12 14:48:34 -070077 private boolean mSingleSelect;
Steve McKaybe629f32015-09-04 14:51:16 -070078
Steve McKay37550162015-09-08 17:15:25 -070079 @Nullable private BandController mBandManager;
Steve McKayef280152015-06-11 10:10:49 -070080
81 /**
82 * @param recyclerView
Steve McKay57394872015-08-12 14:48:34 -070083 * @param mode Selection mode
Steve McKayef280152015-06-11 10:10:49 -070084 */
Steve McKay9eabc502015-10-19 12:04:21 -070085 public MultiSelectManager(final RecyclerView recyclerView, int mode) {
86 this(recyclerView.getAdapter(), mode);
Steve McKayef280152015-06-11 10:10:49 -070087
Ben Kwaa017bc12015-10-07 14:15:12 -070088 mEnvironment = new RuntimeSelectionEnvironment(recyclerView);
89
Steve McKaybe629f32015-09-04 14:51:16 -070090 if (mode == MODE_MULTIPLE) {
Steve McKay9eabc502015-10-19 12:04:21 -070091 mBandManager = new BandController();
Steve McKaybe629f32015-09-04 14:51:16 -070092 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -070093
Steve McKayef280152015-06-11 10:10:49 -070094 GestureDetector.SimpleOnGestureListener listener =
95 new GestureDetector.SimpleOnGestureListener() {
96 @Override
97 public boolean onSingleTapUp(MotionEvent e) {
Steve McKay093d5e72015-09-03 16:49:51 -070098 return MultiSelectManager.this.onSingleTapUp(
99 new MotionInputEvent(e, recyclerView));
Steve McKayef280152015-06-11 10:10:49 -0700100 }
101 @Override
102 public void onLongPress(MotionEvent e) {
Steve McKay093d5e72015-09-03 16:49:51 -0700103 MultiSelectManager.this.onLongPress(
104 new MotionInputEvent(e, recyclerView));
Steve McKayef280152015-06-11 10:10:49 -0700105 }
106 };
107
Steve McKay9eabc502015-10-19 12:04:21 -0700108 final GestureDetector detector = new GestureDetector(recyclerView.getContext(), listener);
109 detector.setOnDoubleTapListener(listener);
Steve McKayef280152015-06-11 10:10:49 -0700110
111 recyclerView.addOnItemTouchListener(
112 new RecyclerView.OnItemTouchListener() {
Steve McKay093d5e72015-09-03 16:49:51 -0700113 @Override
Steve McKayef280152015-06-11 10:10:49 -0700114 public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
115 detector.onTouchEvent(e);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700116
Steve McKay9eabc502015-10-19 12:04:21 -0700117 if (mBandManager != null) {
118 return mBandManager.handleEvent(new MotionInputEvent(e, recyclerView));
Steve McKaybe629f32015-09-04 14:51:16 -0700119 }
Steve McKay9eabc502015-10-19 12:04:21 -0700120 return false;
Steve McKayef280152015-06-11 10:10:49 -0700121 }
Steve McKay093d5e72015-09-03 16:49:51 -0700122
123 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700124 public void onTouchEvent(RecyclerView rv, MotionEvent e) {
Steve McKay9eabc502015-10-19 12:04:21 -0700125 mBandManager.processInputEvent(
Steve McKay093d5e72015-09-03 16:49:51 -0700126 new MotionInputEvent(e, recyclerView));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700127 }
Steve McKay093d5e72015-09-03 16:49:51 -0700128 @Override
Steve McKayef280152015-06-11 10:10:49 -0700129 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {}
130 });
131 }
132
Steve McKay9459a7c2015-07-24 13:14:20 -0700133 /**
134 * Constructs a new instance with {@code adapter} and {@code helper}.
Steve McKay9459a7c2015-07-24 13:14:20 -0700135 * @hide
136 */
137 @VisibleForTesting
Steve McKay9eabc502015-10-19 12:04:21 -0700138 MultiSelectManager(Adapter<?> adapter, int mode) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700139 checkNotNull(adapter, "'adapter' cannot be null.");
Steve McKayef280152015-06-11 10:10:49 -0700140
Steve McKay57394872015-08-12 14:48:34 -0700141 mSingleSelect = mode == MODE_SINGLE;
142
Steve McKayef280152015-06-11 10:10:49 -0700143 mAdapter = adapter;
144
145 mAdapter.registerAdapterDataObserver(
146 new AdapterDataObserver() {
147
148 @Override
149 public void onChanged() {
150 mSelection.clear();
151 }
152
153 @Override
154 public void onItemRangeChanged(
155 int positionStart, int itemCount, Object payload) {
156 // No change in position. Ignoring.
157 }
158
159 @Override
160 public void onItemRangeInserted(int positionStart, int itemCount) {
161 mSelection.expand(positionStart, itemCount);
162 }
163
164 @Override
165 public void onItemRangeRemoved(int positionStart, int itemCount) {
166 mSelection.collapse(positionStart, itemCount);
167 }
168
169 @Override
170 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
171 throw new UnsupportedOperationException();
172 }
173 });
174 }
175
Steve McKay9459a7c2015-07-24 13:14:20 -0700176 /**
177 * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
178 * events occur.
179 *
180 * @param callback
181 */
Steve McKayef280152015-06-11 10:10:49 -0700182 public void addCallback(MultiSelectManager.Callback callback) {
183 mCallbacks.add(callback);
184 }
185
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700186 public boolean hasSelection() {
187 return !mSelection.isEmpty();
188 }
189
Steve McKayef280152015-06-11 10:10:49 -0700190 /**
191 * Returns a Selection object that provides a live view
Ben Kwa24be5d32015-08-27 16:04:46 -0700192 * on the current selection.
Steve McKayef280152015-06-11 10:10:49 -0700193 *
Ben Kwa24be5d32015-08-27 16:04:46 -0700194 * @see #getSelection(Selection) on how to get a snapshot
Steve McKayef280152015-06-11 10:10:49 -0700195 * of the selection that will not reflect future changes
196 * to selection.
197 *
Ben Kwa24be5d32015-08-27 16:04:46 -0700198 * @return The current selection.
Steve McKayef280152015-06-11 10:10:49 -0700199 */
200 public Selection getSelection() {
201 return mSelection;
202 }
203
204 /**
205 * Updates {@code dest} to reflect the current selection.
206 * @param dest
207 *
208 * @return The Selection instance passed in, for convenience.
209 */
210 public Selection getSelection(Selection dest) {
211 dest.copyFrom(mSelection);
212 return dest;
213 }
214
Steve McKay9459a7c2015-07-24 13:14:20 -0700215 /**
216 * Causes item at {@code position} in adapter to be selected.
217 *
218 * @param position Adapter position
219 * @param selected
220 * @return True if the selection state of the item changed.
221 */
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700222 @VisibleForTesting
Steve McKay9459a7c2015-07-24 13:14:20 -0700223 public boolean setItemSelected(int position, boolean selected) {
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700224 if (mSingleSelect && hasSelection()) {
Steve McKay57394872015-08-12 14:48:34 -0700225 clearSelectionQuietly();
Steve McKayef280152015-06-11 10:10:49 -0700226 }
Steve McKay57394872015-08-12 14:48:34 -0700227 return setItemsSelected(position, 1, selected);
Steve McKayef280152015-06-11 10:10:49 -0700228 }
229
Steve McKay9459a7c2015-07-24 13:14:20 -0700230 /**
Steve McKay57394872015-08-12 14:48:34 -0700231 * Sets the selected state of the specified items. Note that the callback will NOT
232 * be consulted to see if an item can be selected.
233 *
Steve McKay9459a7c2015-07-24 13:14:20 -0700234 * @return True if the selection state of any of the items changed.
235 */
236 public boolean setItemsSelected(int position, int length, boolean selected) {
237 boolean changed = false;
238 for (int i = position; i < position + length; i++) {
Steve McKay57394872015-08-12 14:48:34 -0700239 boolean itemChanged = selected ? mSelection.add(i) : mSelection.remove(i);
240 if (itemChanged) {
241 notifyItemStateChanged(i, selected);
242 }
243 changed |= itemChanged;
Steve McKay9459a7c2015-07-24 13:14:20 -0700244 }
Steve McKay57394872015-08-12 14:48:34 -0700245
246 notifySelectionChanged();
Steve McKay9459a7c2015-07-24 13:14:20 -0700247 return changed;
248 }
249
250 /**
Steve McKay57394872015-08-12 14:48:34 -0700251 * Clears the selection and notifies (even if nothing changes).
Steve McKay9459a7c2015-07-24 13:14:20 -0700252 */
Steve McKayef280152015-06-11 10:10:49 -0700253 public void clearSelection() {
Steve McKay57394872015-08-12 14:48:34 -0700254 clearSelectionQuietly();
255 notifySelectionChanged();
256 }
257
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -0700258 public void handleLayoutChanged() {
Steve McKaybe629f32015-09-04 14:51:16 -0700259 if (mBandManager != null) {
260 mBandManager.handleLayoutChanged();
261 }
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -0700262 }
263
Steve McKay57394872015-08-12 14:48:34 -0700264 /**
265 * Clears the selection, without notifying anyone.
266 */
267 private void clearSelectionQuietly() {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700268 mRanger = null;
269
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700270 if (!hasSelection()) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700271 return;
272 }
Steve McKayef280152015-06-11 10:10:49 -0700273 if (mIntermediateSelection == null) {
274 mIntermediateSelection = new Selection();
275 }
276 getSelection(mIntermediateSelection);
277 mSelection.clear();
278
279 for (int i = 0; i < mIntermediateSelection.size(); i++) {
280 int position = mIntermediateSelection.get(i);
Steve McKayef280152015-06-11 10:10:49 -0700281 notifyItemStateChanged(position, false);
282 }
283 }
284
Steve McKay093d5e72015-09-03 16:49:51 -0700285 @VisibleForTesting
286 void onLongPress(InputEvent input) {
Steve McKayef280152015-06-11 10:10:49 -0700287 if (DEBUG) Log.d(TAG, "Handling long press event.");
288
Steve McKay093d5e72015-09-03 16:49:51 -0700289 if (!input.isOverItem()) {
290 if (DEBUG) Log.i(TAG, "Cannot handle tap. No adapter position available.");
Steve McKayef280152015-06-11 10:10:49 -0700291 }
292
Steve McKay093d5e72015-09-03 16:49:51 -0700293 handleAdapterEvent(input);
Steve McKayef280152015-06-11 10:10:49 -0700294 }
295
Steve McKayef280152015-06-11 10:10:49 -0700296 @VisibleForTesting
Steve McKay093d5e72015-09-03 16:49:51 -0700297 boolean onSingleTapUp(InputEvent input) {
298 if (DEBUG) Log.d(TAG, "Processing tap event.");
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700299 if (!hasSelection()) {
Steve McKay93d8ef42015-07-30 12:27:44 -0700300 // if this is a mouse click on an item, start selection mode.
Steve McKay093d5e72015-09-03 16:49:51 -0700301 // TODO: && input.isPrimaryButtonPressed(), but it is returning false.
302 if (input.isOverItem() && input.isMouseEvent()) {
303 toggleSelection(input.getItemPosition());
Steve McKay93d8ef42015-07-30 12:27:44 -0700304 }
305 return false;
306 }
307
Steve McKay093d5e72015-09-03 16:49:51 -0700308 if (!input.isOverItem()) {
309 if (DEBUG) Log.d(TAG, "Activity has no position. Canceling selection.");
Steve McKay93d8ef42015-07-30 12:27:44 -0700310 clearSelection();
311 return false;
312 }
313
Steve McKay093d5e72015-09-03 16:49:51 -0700314 handleAdapterEvent(input);
Steve McKay5c865462015-09-02 15:59:28 -0700315 return true;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700316 }
317
318 /**
319 * Handles a change caused by a click on the item with the given position. If the Shift key is
320 * held down, this performs a range select; otherwise, it simply toggles the item's selection
321 * state.
322 */
Steve McKay093d5e72015-09-03 16:49:51 -0700323 private void handleAdapterEvent(InputEvent input) {
324 if (mRanger != null && input.isShiftKeyDown()) {
325 mRanger.snapSelection(input.getItemPosition());
Steve McKay93d8ef42015-07-30 12:27:44 -0700326
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700327 // We're being lazy here notifying even when something might not have changed.
328 // To make this more correct, we'd need to update the Ranger class to return
329 // information about what has changed.
330 notifySelectionChanged();
Ben Kwa14a96ce2015-09-25 07:47:56 -0700331 } else {
332 toggleSelection(input.getItemPosition());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700333 }
Steve McKay93d8ef42015-07-30 12:27:44 -0700334 }
335
336 /**
Steve McKay8c1b8c82015-07-28 19:20:01 -0700337 * Toggles the selection state at position. If an item does end up selected
338 * a new Ranger (range selection manager) at that point is created.
339 *
340 * @param position
Steve McKay8c1b8c82015-07-28 19:20:01 -0700341 */
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700342 void toggleSelection(int position) {
Steve McKayef280152015-06-11 10:10:49 -0700343 // Position may be special "no position" during certain
344 // transitional phases. If so, skip handling of the event.
345 if (position == RecyclerView.NO_POSITION) {
346 if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
Ben Kwa14a96ce2015-09-25 07:47:56 -0700347 return;
Steve McKayef280152015-06-11 10:10:49 -0700348 }
349
Steve McKay57394872015-08-12 14:48:34 -0700350 boolean changed = false;
Steve McKay8c1b8c82015-07-28 19:20:01 -0700351 if (mSelection.contains(position)) {
Steve McKay57394872015-08-12 14:48:34 -0700352 changed = attemptDeselect(position);
Steve McKayef280152015-06-11 10:10:49 -0700353 } else {
Steve McKay57394872015-08-12 14:48:34 -0700354 boolean canSelect = notifyBeforeItemStateChange(position, true);
355 if (!canSelect) {
Ben Kwa14a96ce2015-09-25 07:47:56 -0700356 return;
Steve McKay57394872015-08-12 14:48:34 -0700357 }
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700358 if (mSingleSelect && hasSelection()) {
Steve McKay57394872015-08-12 14:48:34 -0700359 clearSelectionQuietly();
360 }
361
Steve McKay8c1b8c82015-07-28 19:20:01 -0700362 // Here we're already in selection mode. In that case
363 // When a simple click/tap (without SHIFT) creates causes
364 // an item to be selected.
365 // By recreating Ranger at this point, we allow the user to create
366 // multiple separate contiguous ranges with SHIFT+Click & Click.
Steve McKay57394872015-08-12 14:48:34 -0700367 selectAndNotify(position);
368 setSelectionFocusBegin(position);
369 changed = true;
Steve McKay8c1b8c82015-07-28 19:20:01 -0700370 }
Steve McKay57394872015-08-12 14:48:34 -0700371
Ben Kwa14a96ce2015-09-25 07:47:56 -0700372 if (changed) {
373 notifySelectionChanged();
374 }
Steve McKay8c1b8c82015-07-28 19:20:01 -0700375 }
376
377 /**
Steve McKay93d8ef42015-07-30 12:27:44 -0700378 * Sets the magic location at which a selection range begins. This
379 * value is consulted when determining how to extend, and modify
380 * selection ranges.
381 *
382 * @throws IllegalStateException if {@code position} is not already be selected
383 * @param position
384 */
385 void setSelectionFocusBegin(int position) {
386 checkState(mSelection.contains(position));
387 mRanger = new Range(position);
388 }
389
390 /**
Steve McKay8c1b8c82015-07-28 19:20:01 -0700391 * Try to select all elements in range. Not that callbacks can cancel selection
392 * of specific items, so some or even all items may not reflect the desired
393 * state after the update is complete.
394 *
395 * @param begin inclusive
396 * @param end inclusive
397 * @param selected
398 */
399 private void updateRange(int begin, int end, boolean selected) {
400 checkState(end >= begin);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700401 for (int i = begin; i <= end; i++) {
402 if (selected) {
Steve McKay57394872015-08-12 14:48:34 -0700403 boolean canSelect = notifyBeforeItemStateChange(i, true);
404 if (canSelect) {
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700405 if (mSingleSelect && hasSelection()) {
Steve McKay57394872015-08-12 14:48:34 -0700406 clearSelectionQuietly();
407 }
408 selectAndNotify(i);
409 }
Steve McKay8c1b8c82015-07-28 19:20:01 -0700410 } else {
411 attemptDeselect(i);
412 }
413 }
414 }
415
416 /**
417 * @param position
418 * @return True if the update was applied.
419 */
Steve McKay57394872015-08-12 14:48:34 -0700420 private boolean selectAndNotify(int position) {
421 boolean changed = mSelection.add(position);
422 if (changed) {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700423 notifyItemStateChanged(position, true);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700424 }
Steve McKay57394872015-08-12 14:48:34 -0700425 return changed;
Steve McKay8c1b8c82015-07-28 19:20:01 -0700426 }
427
428 /**
429 * @param position
430 * @return True if the update was applied.
431 */
432 private boolean attemptDeselect(int position) {
433 if (notifyBeforeItemStateChange(position, false)) {
434 mSelection.remove(position);
435 notifyItemStateChanged(position, false);
436 if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
437 return true;
438 } else {
439 if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
440 return false;
Steve McKayef280152015-06-11 10:10:49 -0700441 }
442 }
443
444 private boolean notifyBeforeItemStateChange(int position, boolean nextState) {
445 int lastListener = mCallbacks.size() - 1;
446 for (int i = lastListener; i > -1; i--) {
447 if (!mCallbacks.get(i).onBeforeItemStateChange(position, nextState)) {
448 return false;
449 }
450 }
451 return true;
452 }
453
454 /**
Steve McKay57394872015-08-12 14:48:34 -0700455 * Notifies registered listeners when the selection status of a single item
456 * (identified by {@code position}) changes.
Steve McKayef280152015-06-11 10:10:49 -0700457 */
458 private void notifyItemStateChanged(int position, boolean selected) {
459 int lastListener = mCallbacks.size() - 1;
460 for (int i = lastListener; i > -1; i--) {
461 mCallbacks.get(i).onItemStateChanged(position, selected);
462 }
Steve McKay9459a7c2015-07-24 13:14:20 -0700463 mAdapter.notifyItemChanged(position);
Steve McKayef280152015-06-11 10:10:49 -0700464 }
465
466 /**
Steve McKay57394872015-08-12 14:48:34 -0700467 * Notifies registered listeners when the selection has changed. This
468 * notification should be sent only once a full series of changes
469 * is complete, e.g. clearingSelection, or updating the single
470 * selection from one item to another.
471 */
472 private void notifySelectionChanged() {
473 int lastListener = mCallbacks.size() - 1;
474 for (int i = lastListener; i > -1; i--) {
475 mCallbacks.get(i).onSelectionChanged();
476 }
477 }
478
479 /**
Steve McKay8c1b8c82015-07-28 19:20:01 -0700480 * Class providing support for managing range selections.
481 */
Steve McKay93d8ef42015-07-30 12:27:44 -0700482 private final class Range {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700483 private static final int UNDEFINED = -1;
484
485 final int mBegin;
486 int mEnd = UNDEFINED;
487
Steve McKay93d8ef42015-07-30 12:27:44 -0700488 public Range(int begin) {
Steve McKay57394872015-08-12 14:48:34 -0700489 if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700490 mBegin = begin;
491 }
492
Steve McKay93d8ef42015-07-30 12:27:44 -0700493 private void snapSelection(int position) {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700494 checkState(mRanger != null);
495 checkArgument(position != RecyclerView.NO_POSITION);
496
497 if (mEnd == UNDEFINED || mEnd == mBegin) {
498 // Reset mEnd so it can be established in establishRange.
499 mEnd = UNDEFINED;
500 establishRange(position);
501 } else {
502 reviseRange(position);
503 }
504 }
505
506 private void establishRange(int position) {
507 checkState(mRanger.mEnd == UNDEFINED);
508
509 if (position == mBegin) {
510 mEnd = position;
511 }
512
513 if (position > mBegin) {
514 updateRange(mBegin + 1, position, true);
515 } else if (position < mBegin) {
516 updateRange(position, mBegin - 1, true);
517 }
518
519 mEnd = position;
520 }
521
522 private void reviseRange(int position) {
523 checkState(mEnd != UNDEFINED);
524 checkState(mBegin != mEnd);
525
526 if (position == mEnd) {
527 if (DEBUG) Log.i(TAG, "Skipping no-op revision click on mEndRange.");
528 }
529
530 if (mEnd > mBegin) {
531 reviseAscendingRange(position);
532 } else if (mEnd < mBegin) {
533 reviseDescendingRange(position);
534 }
535 // the "else" case is covered by checkState at beginning of method.
536
537 mEnd = position;
538 }
539
540 /**
541 * Updates an existing ascending seleciton.
542 * @param position
543 */
544 private void reviseAscendingRange(int position) {
545 // Reducing or reversing the range....
546 if (position < mEnd) {
547 if (position < mBegin) {
548 updateRange(mBegin + 1, mEnd, false);
549 updateRange(position, mBegin -1, true);
550 } else {
551 updateRange(position + 1, mEnd, false);
552 }
553 }
554
555 // Extending the range...
556 else if (position > mEnd) {
557 updateRange(mEnd + 1, position, true);
558 }
559 }
560
561 private void reviseDescendingRange(int position) {
562 // Reducing or reversing the range....
563 if (position > mEnd) {
564 if (position > mBegin) {
565 updateRange(mEnd, mBegin - 1, false);
566 updateRange(mBegin + 1, position, true);
567 } else {
568 updateRange(mEnd, position - 1, false);
569 }
570 }
571
572 // Extending the range...
573 else if (position < mEnd) {
574 updateRange(position, mEnd - 1, true);
575 }
576 }
577 }
578
579 /**
Steve McKay9459a7c2015-07-24 13:14:20 -0700580 * Object representing the current selection. Provides read only access
581 * public access, and private write access.
Steve McKayef280152015-06-11 10:10:49 -0700582 */
Steve McKayef280152015-06-11 10:10:49 -0700583 public static final class Selection {
584
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700585 // This class tracks selected positions by managing two arrays: the saved selection, and
586 // the total selection. Saved selections are those which have been completed by tapping an
587 // item or by completing a band select operation. Provisional selections are selections
588 // which have been temporarily created by an in-progress band select operation (once the
589 // user releases the mouse button during a band select operation, the selected items
590 // become saved). The total selection is the combination of both the saved selection and
591 // the provisional selection. Tracking both separately is necessary to ensure that saved
592 // selections do not become deselected when they are removed from the provisional selection;
593 // for example, if item A is tapped (and selected), then an in-progress band select covers A
594 // then uncovers A, A should still be selected as it has been saved. To ensure this
595 // behavior, the saved selection must be tracked separately.
596 private SparseBooleanArray mSavedSelection;
597 private SparseBooleanArray mTotalSelection;
Steve McKayef280152015-06-11 10:10:49 -0700598
599 public Selection() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700600 mSavedSelection = new SparseBooleanArray();
601 mTotalSelection = new SparseBooleanArray();
Steve McKayef280152015-06-11 10:10:49 -0700602 }
603
Steve McKay68a8bb82015-10-21 14:38:54 -0700604 @VisibleForTesting
605 public Selection(int... positions) {
606 this();
607 for (int i = 0; i < positions.length; i++) {
608 add(positions[i]);
609 }
610 }
611
Steve McKayef280152015-06-11 10:10:49 -0700612 /**
613 * @param position
614 * @return true if the position is currently selected.
615 */
616 public boolean contains(int position) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700617 return mTotalSelection.get(position);
Steve McKayef280152015-06-11 10:10:49 -0700618 }
619
620 /**
621 * Useful for iterating over selection. Please note that
622 * iteration should be done over a copy of the selection,
623 * not the live selection.
624 *
625 * @see #copyTo(MultiSelectManager.Selection)
626 *
627 * @param index
628 * @return the position value stored at specified index.
629 */
630 public int get(int index) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700631 return mTotalSelection.keyAt(index);
Steve McKayef280152015-06-11 10:10:49 -0700632 }
633
634 /**
Steve McKay68a8bb82015-10-21 14:38:54 -0700635 * Returns an unordered array of selected positions.
636 */
637 public int[] getAll() {
638 final int size = size();
639 int[] positions = new int[size];
640 for (int i = 0; i < size; i++) {
641 positions[i] = get(i);
642 }
643 return positions;
644 }
645
646 /**
Steve McKayef280152015-06-11 10:10:49 -0700647 * @return size of the selection.
648 */
649 public int size() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700650 return mTotalSelection.size();
Steve McKayef280152015-06-11 10:10:49 -0700651 }
652
Steve McKay9459a7c2015-07-24 13:14:20 -0700653 /**
654 * @return true if the selection is empty.
655 */
656 public boolean isEmpty() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700657 return mTotalSelection.size() == 0;
658 }
659
660 /**
661 * Sets the provisional selection, which is a temporary selection that can be saved,
662 * canceled, or adjusted at a later time. When a new provision selection is applied, the old
663 * one (if it exists) is abandoned.
664 * @return Array with entry for each position added or removed. Entries which were added
665 * contain a value of true, and entries which were removed contain a value of false.
666 */
667 @VisibleForTesting
668 protected SparseBooleanArray setProvisionalSelection(
669 SparseBooleanArray provisionalSelection) {
670 SparseBooleanArray delta = new SparseBooleanArray();
671
672 for (int i = 0; i < mTotalSelection.size(); i++) {
673 int position = mTotalSelection.keyAt(i);
674 if (!provisionalSelection.get(position) && !mSavedSelection.get(position)) {
675 // Remove each item that used to be in the selection but is unsaved and not in
676 // the new provisional selection.
677 delta.put(position, false);
678 }
679 }
680
681 for (int i = 0; i < provisionalSelection.size(); i++) {
682 int position = provisionalSelection.keyAt(i);
683 if (!mTotalSelection.get(position)) {
684 // Add each item that was not previously in the selection but is in the
685 // new provisional selection.
686 delta.put(position, true);
687 }
688 }
689
690 // Now, iterate through the changes and actually add/remove them to/from
691 // mCurrentSelection. This could not be done in the previous loops because changing the
692 // size of the selection mid-iteration changes iteration order erroneously.
693 for (int i = 0; i < delta.size(); i++) {
694 int position = delta.keyAt(i);
695 if (delta.get(position)) {
696 mTotalSelection.put(position, true);
697 } else {
698 mTotalSelection.delete(position);
699 }
700 }
701
702 return delta;
703 }
704
705 /**
706 * Saves the existing provisional selection. Once the provisional selection is saved,
707 * subsequent provisional selections which are different from this existing one cannot
708 * cause items in this existing provisional selection to become deselected.
709 */
710 @VisibleForTesting
711 protected void applyProvisionalSelection() {
712 mSavedSelection = mTotalSelection.clone();
713 }
714
715 /**
716 * Abandons the existing provisional selection so that all items provisionally selected are
717 * now deselected.
718 */
719 @VisibleForTesting
720 protected void cancelProvisionalSelection() {
721 mTotalSelection = mSavedSelection.clone();
Steve McKay9459a7c2015-07-24 13:14:20 -0700722 }
723
Steve McKayef280152015-06-11 10:10:49 -0700724 /** @hide */
725 @VisibleForTesting
Steve McKay9459a7c2015-07-24 13:14:20 -0700726 boolean add(int position) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700727 if (!mTotalSelection.get(position)) {
728 mTotalSelection.put(position, true);
729 mSavedSelection.put(position, true);
Steve McKay9459a7c2015-07-24 13:14:20 -0700730 return true;
731 }
732 return false;
Steve McKayef280152015-06-11 10:10:49 -0700733 }
734
735 /** @hide */
736 @VisibleForTesting
Steve McKay9459a7c2015-07-24 13:14:20 -0700737 boolean remove(int position) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700738 if (mTotalSelection.get(position)) {
739 mTotalSelection.delete(position);
740 mSavedSelection.delete(position);
Steve McKay9459a7c2015-07-24 13:14:20 -0700741 return true;
742 }
743 return false;
Steve McKayef280152015-06-11 10:10:49 -0700744 }
745
746 /**
747 * Adjusts the selection range to reflect the existence of newly inserted values at
748 * the specified positions. This has the effect of adjusting all existing selected
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700749 * positions within the specified range accordingly. Note that this function discards any
750 * provisional selections which may have been applied.
Steve McKayef280152015-06-11 10:10:49 -0700751 *
752 * @param startPosition
753 * @param count
754 * @hide
755 */
756 @VisibleForTesting
757 void expand(int startPosition, int count) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700758 checkState(startPosition >= 0);
759 checkState(count > 0);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700760 cancelProvisionalSelection();
Steve McKayef280152015-06-11 10:10:49 -0700761
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700762 for (int i = 0; i < mTotalSelection.size(); i++) {
763 int itemPosition = mTotalSelection.keyAt(i);
Steve McKayef280152015-06-11 10:10:49 -0700764 if (itemPosition >= startPosition) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700765 mTotalSelection.setKeyAt(i, itemPosition + count);
766 mSavedSelection.setKeyAt(i, itemPosition + count);
Steve McKayef280152015-06-11 10:10:49 -0700767 }
768 }
769 }
770
771 /**
772 * Adjusts the selection range to reflect the removal specified positions. This has
773 * the effect of adjusting all existing selected positions within the specified range
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700774 * accordingly. Note that this function discards any provisional selections which may have
775 * been applied.
Steve McKayef280152015-06-11 10:10:49 -0700776 *
777 * @param startPosition
778 * @param count The length of the range to collapse. Must be greater than 0.
779 * @hide
780 */
781 @VisibleForTesting
782 void collapse(int startPosition, int count) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700783 checkState(startPosition >= 0);
784 checkState(count > 0);
Steve McKayef280152015-06-11 10:10:49 -0700785
786 int endPosition = startPosition + count - 1;
787
788 SparseBooleanArray newSelection = new SparseBooleanArray();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700789 for (int i = 0; i < mSavedSelection.size(); i++) {
790 int itemPosition = mSavedSelection.keyAt(i);
Steve McKayef280152015-06-11 10:10:49 -0700791 if (itemPosition < startPosition) {
792 newSelection.append(itemPosition, true);
793 } else if (itemPosition > endPosition) {
794 newSelection.append(itemPosition - count, true);
795 }
796 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700797 mSavedSelection = newSelection;
798 cancelProvisionalSelection();
Steve McKayef280152015-06-11 10:10:49 -0700799 }
800
801 /** @hide */
802 @VisibleForTesting
803 void clear() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700804 mSavedSelection.clear();
805 mTotalSelection.clear();
Steve McKayef280152015-06-11 10:10:49 -0700806 }
807
808 /** @hide */
809 @VisibleForTesting
810 void copyFrom(Selection source) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700811 mSavedSelection = source.mSavedSelection.clone();
812 mTotalSelection = source.mTotalSelection.clone();
Steve McKayef280152015-06-11 10:10:49 -0700813 }
814
815 @Override
816 public String toString() {
817 if (size() <= 0) {
818 return "size=0, items=[]";
819 }
820
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700821 StringBuilder buffer = new StringBuilder(mTotalSelection.size() * 28);
Steve McKay57394872015-08-12 14:48:34 -0700822 buffer.append("{size=")
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700823 .append(mTotalSelection.size())
Steve McKay57394872015-08-12 14:48:34 -0700824 .append(", ")
825 .append("items=[");
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700826 for (int i=0; i < mTotalSelection.size(); i++) {
Steve McKayef280152015-06-11 10:10:49 -0700827 if (i > 0) {
828 buffer.append(", ");
829 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700830 buffer.append(mTotalSelection.keyAt(i));
Steve McKayef280152015-06-11 10:10:49 -0700831 }
832 buffer.append("]}");
833 return buffer.toString();
834 }
835
836 @Override
Steve McKayf4c06ab2015-07-22 11:42:14 -0700837 public int hashCode() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700838 return mSavedSelection.hashCode() ^ mTotalSelection.hashCode();
Steve McKayf4c06ab2015-07-22 11:42:14 -0700839 }
840
841 @Override
Steve McKayef280152015-06-11 10:10:49 -0700842 public boolean equals(Object that) {
843 if (this == that) {
844 return true;
845 }
846
Steve McKayf4c06ab2015-07-22 11:42:14 -0700847 if (!(that instanceof Selection)) {
848 return false;
Steve McKayef280152015-06-11 10:10:49 -0700849 }
Steve McKayf4c06ab2015-07-22 11:42:14 -0700850
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700851 return mSavedSelection.equals(((Selection) that).mSavedSelection) &&
852 mTotalSelection.equals(((Selection) that).mTotalSelection);
Steve McKayef280152015-06-11 10:10:49 -0700853 }
854 }
855
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700856 /**
Steve McKay37550162015-09-08 17:15:25 -0700857 * Provides functionality for BandController. Exists primarily to tests that are
858 * fully isolated from RecyclerView.
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700859 */
Ben Kwaa017bc12015-10-07 14:15:12 -0700860 interface SelectionEnvironment {
Steve McKay37550162015-09-08 17:15:25 -0700861 void showBand(Rect rect);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700862 void hideBand();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700863 void addOnScrollListener(RecyclerView.OnScrollListener listener);
Steve McKay37550162015-09-08 17:15:25 -0700864 void removeOnScrollListener(RecyclerView.OnScrollListener listener);
865 void scrollBy(int dy);
866 int getHeight();
867 void invalidateView();
868 void runAtNextFrame(Runnable r);
869 void removeCallback(Runnable r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700870 Point createAbsolutePoint(Point relativePoint);
871 Rect getAbsoluteRectForChildViewAt(int index);
872 int getAdapterPositionAt(int index);
Ben Kwaa017bc12015-10-07 14:15:12 -0700873 int getAdapterPositionForChildView(View view);
Steve McKay37550162015-09-08 17:15:25 -0700874 int getColumnCount();
875 int getRowCount();
876 int getChildCount();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700877 int getVisibleChildCount();
Ben Kwaa017bc12015-10-07 14:15:12 -0700878 void focusItem(int position);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700879 }
880
Steve McKay37550162015-09-08 17:15:25 -0700881 /** RvFacade implementation backed by good ol' RecyclerView. */
Ben Kwaa017bc12015-10-07 14:15:12 -0700882 private static final class RuntimeSelectionEnvironment implements SelectionEnvironment {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700883
Steve McKay37550162015-09-08 17:15:25 -0700884 private final RecyclerView mView;
885 private final Drawable mBand;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700886
887 private boolean mIsOverlayShown = false;
888
Ben Kwaa017bc12015-10-07 14:15:12 -0700889 RuntimeSelectionEnvironment(RecyclerView rv) {
Steve McKay37550162015-09-08 17:15:25 -0700890 mView = rv;
891 mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700892 }
893
894 @Override
Ben Kwaa017bc12015-10-07 14:15:12 -0700895 public int getAdapterPositionForChildView(View view) {
896 if (view.getParent() == mView) {
897 return mView.getChildAdapterPosition(view);
898 } else {
899 return RecyclerView.NO_POSITION;
900 }
901 }
902
903 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700904 public int getAdapterPositionAt(int index) {
Ben Kwaa017bc12015-10-07 14:15:12 -0700905 return getAdapterPositionForChildView(mView.getChildAt(index));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700906 }
907
908 @Override
909 public void addOnScrollListener(OnScrollListener listener) {
Steve McKay37550162015-09-08 17:15:25 -0700910 mView.addOnScrollListener(listener);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700911 }
912
913 @Override
914 public void removeOnScrollListener(OnScrollListener listener) {
Steve McKay37550162015-09-08 17:15:25 -0700915 mView.removeOnScrollListener(listener);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700916 }
917
918 @Override
919 public Point createAbsolutePoint(Point relativePoint) {
Steve McKay37550162015-09-08 17:15:25 -0700920 return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
921 relativePoint.y + mView.computeVerticalScrollOffset());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700922 }
923
924 @Override
925 public Rect getAbsoluteRectForChildViewAt(int index) {
Steve McKay37550162015-09-08 17:15:25 -0700926 final View child = mView.getChildAt(index);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700927 final Rect childRect = new Rect();
928 child.getHitRect(childRect);
Steve McKay37550162015-09-08 17:15:25 -0700929 childRect.left += mView.computeHorizontalScrollOffset();
930 childRect.right += mView.computeHorizontalScrollOffset();
931 childRect.top += mView.computeVerticalScrollOffset();
932 childRect.bottom += mView.computeVerticalScrollOffset();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700933 return childRect;
934 }
935
936 @Override
Steve McKay37550162015-09-08 17:15:25 -0700937 public int getChildCount() {
938 return mView.getAdapter().getItemCount();
939 }
940
941 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700942 public int getVisibleChildCount() {
Steve McKay37550162015-09-08 17:15:25 -0700943 return mView.getChildCount();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700944 }
945
946 @Override
Steve McKay37550162015-09-08 17:15:25 -0700947 public int getColumnCount() {
948 LayoutManager layoutManager = mView.getLayoutManager();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700949 if (layoutManager instanceof GridLayoutManager) {
950 return ((GridLayoutManager) layoutManager).getSpanCount();
951 }
952
953 // Otherwise, it is a list with 1 column.
954 return 1;
955 }
956
957 @Override
Steve McKay37550162015-09-08 17:15:25 -0700958 public int getRowCount() {
959 int numFullColumns = getChildCount() / getColumnCount();
960 boolean hasPartiallyFullColumn = getChildCount() % getColumnCount() != 0;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700961 return numFullColumns + (hasPartiallyFullColumn ? 1 : 0);
962 }
963
964 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700965 public int getHeight() {
Steve McKay37550162015-09-08 17:15:25 -0700966 return mView.getHeight();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700967 }
968
969 @Override
970 public void invalidateView() {
Steve McKay37550162015-09-08 17:15:25 -0700971 mView.invalidate();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700972 }
973
974 @Override
Steve McKay37550162015-09-08 17:15:25 -0700975 public void runAtNextFrame(Runnable r) {
976 mView.postOnAnimation(r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700977 }
978
979 @Override
980 public void removeCallback(Runnable r) {
Steve McKay37550162015-09-08 17:15:25 -0700981 mView.removeCallbacks(r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700982 }
983
984 @Override
985 public void scrollBy(int dy) {
Steve McKay37550162015-09-08 17:15:25 -0700986 mView.scrollBy(0, dy);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700987 }
988
989 @Override
Steve McKay37550162015-09-08 17:15:25 -0700990 public void showBand(Rect rect) {
991 mBand.setBounds(rect);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700992
993 if (!mIsOverlayShown) {
Steve McKay37550162015-09-08 17:15:25 -0700994 mView.getOverlay().add(mBand);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700995 }
996 }
997
998 @Override
999 public void hideBand() {
Steve McKay37550162015-09-08 17:15:25 -07001000 mView.getOverlay().remove(mBand);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001001 }
Ben Kwaa017bc12015-10-07 14:15:12 -07001002
1003 @Override
1004 public void focusItem(final int pos) {
1005 // If the item is already in view, focus it; otherwise, scroll to it and focus it.
1006 RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos);
1007 if (vh != null) {
1008 vh.itemView.requestFocus();
1009 } else {
1010 // Don't smooth scroll; that taxes the system unnecessarily and makes the scroll
1011 // handling logic below more complicated. See b/24865658.
1012 mView.scrollToPosition(pos);
1013 // Set a one-time listener to request focus when the scroll has completed.
1014 mView.addOnScrollListener(
1015 new RecyclerView.OnScrollListener() {
1016 @Override
1017 public void onScrolled(RecyclerView view, int dx, int dy) {
1018 view.findViewHolderForAdapterPosition(pos).itemView.requestFocus();
1019 view.removeOnScrollListener(this);
1020 }
1021 });
1022 }
1023 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001024 }
1025
Steve McKayef280152015-06-11 10:10:49 -07001026 public interface Callback {
1027 /**
1028 * Called when an item is selected or unselected while in selection mode.
1029 *
1030 * @param position Adapter position of the item that was checked or unchecked
1031 * @param selected <code>true</code> if the item is now selected, <code>false</code>
1032 * if the item is now unselected.
1033 */
1034 public void onItemStateChanged(int position, boolean selected);
1035
1036 /**
Steve McKay57394872015-08-12 14:48:34 -07001037 * Called prior to an item changing state. Callbacks can cancel
1038 * the change at {@code position} by returning {@code false}.
1039 *
1040 * @param position Adapter position of the item that was checked or unchecked
1041 * @param selected <code>true</code> if the item is to be selected, <code>false</code>
1042 * if the item is to be unselected.
Steve McKayef280152015-06-11 10:10:49 -07001043 */
1044 public boolean onBeforeItemStateChange(int position, boolean selected);
Steve McKay57394872015-08-12 14:48:34 -07001045
1046 /**
1047 * Called immediately after completion of any set of changes.
1048 */
1049 public void onSelectionChanged();
Steve McKayef280152015-06-11 10:10:49 -07001050 }
1051
1052 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001053 * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView}
1054 * and {@link MultiSelectManager}. This class is responsible for rendering the band select
1055 * overlay and selecting overlaid items via MultiSelectManager.
1056 */
Steve McKay37550162015-09-08 17:15:25 -07001057 public class BandController extends RecyclerView.OnScrollListener
1058 implements GridModel.OnSelectionChangedListener {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001059
1060 private static final int NOT_SET = -1;
1061
Steve McKay093d5e72015-09-03 16:49:51 -07001062 private final Runnable mModelBuilder;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001063
Steve McKay093d5e72015-09-03 16:49:51 -07001064 @Nullable private Rect mBounds;
1065 @Nullable private Point mCurrentPosition;
1066 @Nullable private Point mOrigin;
Steve McKay37550162015-09-08 17:15:25 -07001067 @Nullable private GridModel mModel;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001068
1069 // The time at which the current band selection-induced scroll began. If no scroll is in
1070 // progress, the value is NOT_SET.
1071 private long mScrollStartTime = NOT_SET;
1072 private final Runnable mViewScroller = new ViewScroller();
1073
Steve McKay9eabc502015-10-19 12:04:21 -07001074 public BandController() {
Steve McKay37550162015-09-08 17:15:25 -07001075 mEnvironment.addOnScrollListener(this);
Steve McKay093d5e72015-09-03 16:49:51 -07001076
1077 mModelBuilder = new Runnable() {
1078 @Override
1079 public void run() {
Ben Kwaa017bc12015-10-07 14:15:12 -07001080 mModel = new GridModel(mEnvironment);
Steve McKay37550162015-09-08 17:15:25 -07001081 mModel.addOnSelectionChangedListener(BandController.this);
Steve McKay093d5e72015-09-03 16:49:51 -07001082 }
1083 };
1084 }
1085
Steve McKay9eabc502015-10-19 12:04:21 -07001086 public boolean handleEvent(MotionInputEvent e) {
1087 // b/23793622 notes the fact that we *never* receive ACTION_DOWN
1088 // events in onTouchEvent. Where it not for this issue, we'd
1089 // push start handling down into handleInputEvent.
1090 if (mBandManager.shouldStart(e)) {
1091 // endBandSelect is handled in handleInputEvent.
1092 mBandManager.startBandSelect(e.getOrigin());
1093 } else if (mBandManager.isActive()
1094 && e.isMouseEvent()
1095 && e.isActionUp()) {
1096 // Same issue here w b/23793622. The ACTION_UP event
1097 // is only evert dispatched to onTouchEvent when
1098 // there is some associated motion. If a user taps
1099 // mouse, but doesn't move, then band select gets
1100 // started BUT not ended. Causing phantom
1101 // bands to appear when the user later clicks to start
1102 // band select.
1103 mBandManager.processInputEvent(e);
1104 }
1105
1106 return isActive();
1107 }
1108
Steve McKay093d5e72015-09-03 16:49:51 -07001109 private boolean isActive() {
1110 return mModel != null;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001111 }
1112
1113 /**
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001114 * Handle a change in layout by cleaning up and getting rid of the old model and creating
1115 * a new model which will track the new layout.
1116 */
1117 public void handleLayoutChanged() {
Steve McKay093d5e72015-09-03 16:49:51 -07001118 if (mModel != null) {
1119 mModel.removeOnSelectionChangedListener(this);
1120 mModel.stopListening();
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001121
Steve McKay093d5e72015-09-03 16:49:51 -07001122 // build a new model, all fresh and happy.
1123 mModelBuilder.run();
1124 }
1125 }
1126
Steve McKay9eabc502015-10-19 12:04:21 -07001127 boolean shouldStart(MotionInputEvent e) {
Steve McKay093d5e72015-09-03 16:49:51 -07001128 return !isActive()
Steve McKay9eabc502015-10-19 12:04:21 -07001129 && e.isMouseEvent() // a mouse
1130 && e.isActionDown() // the initial button press
Steve McKay37550162015-09-08 17:15:25 -07001131 && mAdapter.getItemCount() > 0
Steve McKay9eabc502015-10-19 12:04:21 -07001132 && e.getItemPosition() == RecyclerView.NO_ID; // in empty space
Steve McKay093d5e72015-09-03 16:49:51 -07001133 }
1134
1135 boolean shouldStop(InputEvent input) {
1136 return isActive()
1137 && input.isMouseEvent()
1138 && input.isActionUp();
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001139 }
1140
1141 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001142 * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
Steve McKay093d5e72015-09-03 16:49:51 -07001143 * @param input
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001144 */
Steve McKay9eabc502015-10-19 12:04:21 -07001145 private void processInputEvent(InputEvent input) {
Steve McKay093d5e72015-09-03 16:49:51 -07001146 checkArgument(input.isMouseEvent());
1147
1148 if (shouldStop(input)) {
Steve McKaybe629f32015-09-04 14:51:16 -07001149 endBandSelect();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001150 return;
1151 }
1152
Steve McKay093d5e72015-09-03 16:49:51 -07001153 // We shouldn't get any events in this method when band select is not active,
1154 // but it turns some guests show up late to the party.
1155 if (!isActive()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001156 return;
1157 }
1158
Steve McKay093d5e72015-09-03 16:49:51 -07001159 mCurrentPosition = input.getOrigin();
1160 mModel.resizeSelection(input.getOrigin());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001161 scrollViewIfNecessary();
1162 resizeBandSelectRectangle();
1163 }
1164
1165 /**
1166 * Starts band select by adding the drawable to the RecyclerView's overlay.
1167 */
Steve McKay093d5e72015-09-03 16:49:51 -07001168 private void startBandSelect(Point origin) {
1169 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
1170
1171 mOrigin = origin;
1172 mModelBuilder.run(); // Creates a new selection model.
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001173 mModel.startSelection(mOrigin);
1174 }
1175
1176 /**
1177 * Scrolls the view if necessary.
1178 */
1179 private void scrollViewIfNecessary() {
Steve McKay37550162015-09-08 17:15:25 -07001180 mEnvironment.removeCallback(mViewScroller);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001181 mViewScroller.run();
Steve McKay37550162015-09-08 17:15:25 -07001182 mEnvironment.invalidateView();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001183 }
1184
1185 /**
1186 * Resizes the band select rectangle by using the origin and the current pointer position as
1187 * two opposite corners of the selection.
1188 */
1189 private void resizeBandSelectRectangle() {
Steve McKay093d5e72015-09-03 16:49:51 -07001190 mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
1191 Math.min(mOrigin.y, mCurrentPosition.y),
1192 Math.max(mOrigin.x, mCurrentPosition.x),
1193 Math.max(mOrigin.y, mCurrentPosition.y));
Steve McKay37550162015-09-08 17:15:25 -07001194 mEnvironment.showBand(mBounds);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001195 }
1196
1197 /**
1198 * Ends band select by removing the overlay.
1199 */
1200 private void endBandSelect() {
1201 if (DEBUG) Log.d(TAG, "Ending band select.");
Steve McKay093d5e72015-09-03 16:49:51 -07001202
Steve McKay37550162015-09-08 17:15:25 -07001203 mEnvironment.hideBand();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001204 mSelection.applyProvisionalSelection();
1205 mModel.endSelection();
1206 int firstSelected = mModel.getPositionNearestOrigin();
Steve McKay093d5e72015-09-03 16:49:51 -07001207 if (!mSelection.contains(firstSelected)) {
1208 Log.w(TAG, "First selected by band is NOT in selection!");
1209 // Sadly this is really happening. Need to figure out what's going on.
Steve McKay37550162015-09-08 17:15:25 -07001210 } else if (firstSelected != GridModel.NOT_SET) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001211 setSelectionFocusBegin(firstSelected);
1212 }
Steve McKay093d5e72015-09-03 16:49:51 -07001213
1214 mModel = null;
1215 mOrigin = null;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001216 }
1217
1218 @Override
1219 public void onSelectionChanged(SparseBooleanArray updatedSelection) {
1220 SparseBooleanArray delta = mSelection.setProvisionalSelection(updatedSelection);
1221 for (int i = 0; i < delta.size(); i++) {
1222 int position = delta.keyAt(i);
1223 notifyItemStateChanged(position, delta.get(position));
1224 }
1225 notifySelectionChanged();
1226 }
1227
1228 private class ViewScroller implements Runnable {
1229 /**
1230 * The number of milliseconds of scrolling at which scroll speed continues to increase.
1231 * At first, the scroll starts slowly; then, the rate of scrolling increases until it
1232 * reaches its maximum value at after this many milliseconds.
1233 */
1234 private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
1235
1236 @Override
1237 public void run() {
1238 // Compute the number of pixels the pointer's y-coordinate is past the view.
1239 // Negative values mean the pointer is at or before the top of the view, and
1240 // positive values mean that the pointer is at or after the bottom of the view. Note
1241 // that one additional pixel is added here so that the view still scrolls when the
1242 // pointer is exactly at the top or bottom.
1243 int pixelsPastView = 0;
Steve McKay093d5e72015-09-03 16:49:51 -07001244 if (mCurrentPosition.y <= 0) {
1245 pixelsPastView = mCurrentPosition.y - 1;
Steve McKay37550162015-09-08 17:15:25 -07001246 } else if (mCurrentPosition.y >= mEnvironment.getHeight() - 1) {
1247 pixelsPastView = mCurrentPosition.y - mEnvironment.getHeight() + 1;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001248 }
1249
Steve McKay093d5e72015-09-03 16:49:51 -07001250 if (!isActive() || pixelsPastView == 0) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001251 // If band selection is inactive, or if it is active but not at the edge of the
1252 // view, no scrolling is necessary.
1253 mScrollStartTime = NOT_SET;
1254 return;
1255 }
1256
1257 if (mScrollStartTime == NOT_SET) {
1258 // If the pointer was previously not at the edge of the view but now is, set the
1259 // start time for the scroll.
1260 mScrollStartTime = System.currentTimeMillis();
1261 }
1262
1263 // Compute the number of pixels to scroll, and scroll that many pixels.
1264 final int numPixels = computeScrollDistance(
1265 pixelsPastView, System.currentTimeMillis() - mScrollStartTime);
Steve McKay37550162015-09-08 17:15:25 -07001266 mEnvironment.scrollBy(numPixels);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001267
Steve McKay37550162015-09-08 17:15:25 -07001268 mEnvironment.removeCallback(mViewScroller);
1269 mEnvironment.runAtNextFrame(this);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001270 }
1271
1272 /**
1273 * Computes the number of pixels to scroll based on how far the pointer is past the end
1274 * of the view and how long it has been there. Roughly based on ItemTouchHelper's
1275 * algorithm for computing the number of pixels to scroll when an item is dragged to the
1276 * end of a {@link RecyclerView}.
1277 * @param pixelsPastView
1278 * @param scrollDuration
1279 * @return
1280 */
1281 private int computeScrollDistance(int pixelsPastView, long scrollDuration) {
Steve McKay37550162015-09-08 17:15:25 -07001282 final int maxScrollStep = mEnvironment.getHeight();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001283 final int direction = (int) Math.signum(pixelsPastView);
1284 final int absPastView = Math.abs(pixelsPastView);
1285
1286 // Calculate the ratio of how far out of the view the pointer currently resides to
1287 // the entire height of the view.
1288 final float outOfBoundsRatio = Math.min(
Steve McKay37550162015-09-08 17:15:25 -07001289 1.0f, (float) absPastView / mEnvironment.getHeight());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001290 // Interpolate this ratio and use it to compute the maximum scroll that should be
1291 // possible for this step.
1292 final float cappedScrollStep =
1293 direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio);
1294
1295 // Likewise, calculate the ratio of the time spent in the scroll to the limit.
1296 final float timeRatio = Math.min(
1297 1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS);
1298 // Interpolate this ratio and use it to compute the final number of pixels to
1299 // scroll.
1300 final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio));
1301
1302 // If the final number of pixels to scroll ends up being 0, the view should still
1303 // scroll at least one pixel.
1304 return numPixels != 0 ? numPixels : direction;
1305 }
1306
1307 /**
1308 * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
1309 * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
1310 * drags that are at the edge or barely past the edge of the view still cause sufficient
1311 * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if
1312 * needed.
1313 * @param ratio A ratio which is in the range [0, 1].
1314 * @return A "smoothed" value, also in the range [0, 1].
1315 */
1316 private float smoothOutOfBoundsRatio(float ratio) {
1317 return (float) Math.pow(ratio - 1.0f, 5) + 1.0f;
1318 }
1319
1320 /**
1321 * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1)
1322 * and stays close to 0 for most input values except those very close to 1. This ensures
1323 * that scrolls start out very slowly but speed up drastically after the scroll has been
1324 * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used,
1325 * but this could also be tweaked if needed.
1326 * @param ratio A ratio which is in the range [0, 1].
1327 * @return A "smoothed" value, also in the range [0, 1].
1328 */
1329 private float smoothTimeRatio(float ratio) {
1330 return (float) Math.pow(ratio, 5);
1331 }
1332 };
Kyle Horimoto237b7402015-08-25 17:12:42 -07001333
1334 @Override
1335 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
Steve McKay093d5e72015-09-03 16:49:51 -07001336 if (!isActive()) {
Kyle Horimoto237b7402015-08-25 17:12:42 -07001337 return;
1338 }
1339
1340 // Adjust the y-coordinate of the origin the opposite number of pixels so that the
1341 // origin remains in the same place relative to the view's items.
1342 mOrigin.y -= dy;
1343 resizeBandSelectRectangle();
1344 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001345 }
1346
1347 /**
1348 * Provides a band selection item model for views within a RecyclerView. This class queries the
1349 * RecyclerView to determine where its items are placed; then, once band selection is underway,
1350 * it alerts listeners of which items are covered by the selections.
1351 */
Steve McKay37550162015-09-08 17:15:25 -07001352 public static final class GridModel extends RecyclerView.OnScrollListener {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001353
1354 public static final int NOT_SET = -1;
1355
1356 // Enum values used to determine the corner at which the origin is located within the
1357 private static final int UPPER = 0x00;
1358 private static final int LOWER = 0x01;
1359 private static final int LEFT = 0x00;
1360 private static final int RIGHT = 0x02;
1361 private static final int UPPER_LEFT = UPPER | LEFT;
1362 private static final int UPPER_RIGHT = UPPER | RIGHT;
1363 private static final int LOWER_LEFT = LOWER | LEFT;
1364 private static final int LOWER_RIGHT = LOWER | RIGHT;
1365
Ben Kwaa017bc12015-10-07 14:15:12 -07001366 private final SelectionEnvironment mHelper;
Steve McKay37550162015-09-08 17:15:25 -07001367 private final List<OnSelectionChangedListener> mOnSelectionChangedListeners =
1368 new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001369
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001370 // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed
1371 // by their y-offset. For example, if the first column of the view starts at an x-value of 5,
1372 // mColumns.get(5) would return an array of positions in that column. Within that array, the
1373 // value for key y is the adapter position for the item whose y-offset is y.
1374 private final SparseArray<SparseIntArray> mColumns = new SparseArray<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001375
Steve McKay37550162015-09-08 17:15:25 -07001376 // List of limits along the x-axis (columns).
1377 // This list is sorted from furthest left to furthest right.
1378 private final List<Limits> mColumnBounds = new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001379
Steve McKay37550162015-09-08 17:15:25 -07001380 // List of limits along the y-axis (rows). Note that this list only contains items which
1381 // have been in the viewport.
1382 private final List<Limits> mRowBounds = new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001383
1384 // The adapter positions which have been recorded so far.
1385 private final SparseBooleanArray mKnownPositions = new SparseBooleanArray();
1386
1387 // Array passed to registered OnSelectionChangedListeners. One array is created and reused
1388 // throughout the lifetime of the object.
1389 private final SparseBooleanArray mSelection = new SparseBooleanArray();
1390
1391 // The current pointer (in absolute positioning from the top of the view).
1392 private Point mPointer = null;
1393
1394 // The bounds of the band selection.
1395 private RelativePoint mRelativeOrigin;
1396 private RelativePoint mRelativePointer;
1397
1398 private boolean mIsActive;
1399
1400 // Tracks where the band select originated from. This is used to determine where selections
1401 // should expand from when Shift+click is used.
1402 private int mPositionNearestOrigin = NOT_SET;
1403
Ben Kwaa017bc12015-10-07 14:15:12 -07001404 GridModel(SelectionEnvironment helper) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001405 mHelper = helper;
1406 mHelper.addOnScrollListener(this);
1407 }
1408
1409 /**
1410 * Stops listening to the view's scrolls. Call this function before discarding a
1411 * BandSelecModel object to prevent memory leaks.
1412 */
1413 void stopListening() {
1414 mHelper.removeOnScrollListener(this);
1415 }
1416
1417 /**
1418 * Start a band select operation at the given point.
1419 * @param relativeOrigin The origin of the band select operation, relative to the viewport.
1420 * For example, if the view is scrolled to the bottom, the top-left of the viewport
1421 * would have a relative origin of (0, 0), even though its absolute point has a higher
1422 * y-value.
1423 */
1424 void startSelection(Point relativeOrigin) {
1425 mIsActive = true;
1426 mPointer = mHelper.createAbsolutePoint(relativeOrigin);
1427
1428 recordVisibleChildren();
1429 mRelativeOrigin = new RelativePoint(mPointer);
1430 mRelativePointer = new RelativePoint(mPointer);
1431 computeCurrentSelection();
1432 notifyListeners();
1433 }
1434
1435 /**
1436 * Resizes the selection by adjusting the pointer (i.e., the corner of the selection
1437 * opposite the origin.
1438 * @param relativePointer The pointer (opposite of the origin) of the band select operation,
1439 * relative to the viewport. For example, if the view is scrolled to the bottom, the
1440 * top-left of the viewport would have a relative origin of (0, 0), even though its
1441 * absolute point has a higher y-value.
1442 */
1443 void resizeSelection(Point relativePointer) {
1444 mPointer = mHelper.createAbsolutePoint(relativePointer);
1445 updateModel();
1446 }
1447
1448 /**
1449 * Ends the band selection.
1450 */
1451 void endSelection() {
1452 mIsActive = false;
1453 }
1454
1455 /**
1456 * @return The adapter position for the item nearest the origin corresponding to the latest
1457 * band select operation, or NOT_SET if the selection did not cover any items.
1458 */
1459 int getPositionNearestOrigin() {
1460 return mPositionNearestOrigin;
1461 }
1462
1463 @Override
1464 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
1465 if (!mIsActive) {
1466 return;
1467 }
1468
1469 mPointer.x += dx;
1470 mPointer.y += dy;
1471 recordVisibleChildren();
1472 updateModel();
1473 }
1474
1475 /**
1476 * Queries the view for all children and records their location metadata.
1477 */
1478 private void recordVisibleChildren() {
1479 for (int i = 0; i < mHelper.getVisibleChildCount(); i++) {
1480 int adapterPosition = mHelper.getAdapterPositionAt(i);
1481 if (!mKnownPositions.get(adapterPosition)) {
1482 mKnownPositions.put(adapterPosition, true);
1483 recordItemData(
1484 mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition);
1485 }
1486 }
1487 }
1488
1489 /**
1490 * Updates the limits lists and column map with the given item metadata.
1491 * @param absoluteChildRect The absolute rectangle for the child view being processed.
1492 * @param adapterPosition The position of the child view being processed.
1493 */
1494 private void recordItemData(Rect absoluteChildRect, int adapterPosition) {
Steve McKay37550162015-09-08 17:15:25 -07001495 if (mColumnBounds.size() != mHelper.getColumnCount()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001496 // If not all x-limits have been recorded, record this one.
1497 recordLimits(
Steve McKay37550162015-09-08 17:15:25 -07001498 mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001499 }
1500
Steve McKay37550162015-09-08 17:15:25 -07001501 if (mRowBounds.size() != mHelper.getRowCount()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001502 // If not all y-limits have been recorded, record this one.
1503 recordLimits(
Steve McKay37550162015-09-08 17:15:25 -07001504 mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001505 }
1506
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001507 SparseIntArray columnList = mColumns.get(absoluteChildRect.left);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001508 if (columnList == null) {
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001509 columnList = new SparseIntArray();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001510 mColumns.put(absoluteChildRect.left, columnList);
1511 }
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001512 columnList.put(absoluteChildRect.top, adapterPosition);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001513 }
1514
1515 /**
1516 * Ensures limits exists within the sorted list limitsList, and adds it to the list if it
1517 * does not exist.
1518 */
1519 private void recordLimits(List<Limits> limitsList, Limits limits) {
1520 int index = Collections.binarySearch(limitsList, limits);
1521 if (index < 0) {
1522 limitsList.add(~index, limits);
1523 }
1524 }
1525
1526 /**
1527 * Handles a moved pointer; this function determines whether the pointer movement resulted
1528 * in a selection change and, if it has, notifies listeners of this change.
1529 */
1530 private void updateModel() {
1531 RelativePoint old = mRelativePointer;
1532 mRelativePointer = new RelativePoint(mPointer);
1533 if (old != null && mRelativePointer.equals(old)) {
1534 return;
1535 }
1536
1537 computeCurrentSelection();
1538 notifyListeners();
1539 }
1540
1541 /**
1542 * Computes the currently-selected items.
1543 */
1544 private void computeCurrentSelection() {
Steve McKay37550162015-09-08 17:15:25 -07001545 if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001546 updateSelection(computeBounds());
1547 } else {
1548 mSelection.clear();
1549 mPositionNearestOrigin = NOT_SET;
1550 }
1551 }
1552
1553 /**
1554 * Notifies all listeners of a selection change. Note that this function simply passes
1555 * mSelection, so computeCurrentSelection() should be called before this
1556 * function.
1557 */
1558 private void notifyListeners() {
1559 for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
1560 listener.onSelectionChanged(mSelection);
1561 }
1562 }
1563
1564 /**
1565 * @param rect Rectangle including all covered items.
1566 */
1567 private void updateSelection(Rect rect) {
1568 int columnStartIndex =
Steve McKay37550162015-09-08 17:15:25 -07001569 Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001570 checkState(columnStartIndex >= 0);
1571 int columnEndIndex = columnStartIndex;
1572
Steve McKay37550162015-09-08 17:15:25 -07001573 for (int i = columnStartIndex; i < mColumnBounds.size()
1574 && mColumnBounds.get(i).lowerLimit <= rect.right; i++) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001575 columnEndIndex = i;
1576 }
1577
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001578 SparseIntArray firstColumn =
Steve McKay37550162015-09-08 17:15:25 -07001579 mColumns.get(mColumnBounds.get(columnStartIndex).lowerLimit);
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001580 int rowStartIndex = firstColumn.indexOfKey(rect.top);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001581 if (rowStartIndex < 0) {
1582 mPositionNearestOrigin = NOT_SET;
1583 return;
1584 }
1585
1586 int rowEndIndex = rowStartIndex;
1587 for (int i = rowStartIndex;
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001588 i < firstColumn.size() && firstColumn.keyAt(i) <= rect.bottom; i++) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001589 rowEndIndex = i;
1590 }
1591
1592 updateSelection(columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex);
1593 }
1594
1595 /**
1596 * Computes the selection given the previously-computed start- and end-indices for each
1597 * row and column.
1598 */
1599 private void updateSelection(
1600 int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) {
1601 mSelection.clear();
1602 for (int column = columnStartIndex; column <= columnEndIndex; column++) {
Steve McKay37550162015-09-08 17:15:25 -07001603 SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001604 for (int row = rowStartIndex; row <= rowEndIndex; row++) {
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001605 int position = items.get(items.keyAt(row));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001606 mSelection.append(position, true);
1607 if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex,
1608 row, rowStartIndex, rowEndIndex)) {
1609 // If this is the position nearest the origin, record it now so that it
1610 // can be returned by endSelection() later.
1611 mPositionNearestOrigin = position;
1612 }
1613 }
1614 }
1615 }
1616
1617 /**
1618 * @return Returns true if the position is the nearest to the origin, or, in the case of the
1619 * lower-right corner, whether it is possible that the position is the nearest to the
1620 * origin. See comment below for reasoning for this special case.
1621 */
1622 private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex,
1623 int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) {
1624 int corner = computeCornerNearestOrigin();
1625 switch (corner) {
1626 case UPPER_LEFT:
1627 return columnIndex == columnStartIndex && rowIndex == rowStartIndex;
1628 case UPPER_RIGHT:
1629 return columnIndex == columnEndIndex && rowIndex == rowStartIndex;
1630 case LOWER_LEFT:
1631 return columnIndex == columnStartIndex && rowIndex == rowEndIndex;
1632 case LOWER_RIGHT:
1633 // Note that in some cases, the last row will not have as many items as there
1634 // are columns (e.g., if there are 4 items and 3 columns, the second row will
1635 // only have one item in the first column). This function is invoked for each
1636 // position from left to right, so return true for any position in the bottom
1637 // row and only the right-most position in the bottom row will be recorded.
1638 return rowIndex == rowEndIndex;
1639 default:
1640 throw new RuntimeException("Invalid corner type.");
1641 }
1642 }
1643
1644 /**
1645 * Listener for changes in which items have been band selected.
1646 */
1647 static interface OnSelectionChangedListener {
1648 public void onSelectionChanged(SparseBooleanArray updatedSelection);
1649 }
1650
1651 void addOnSelectionChangedListener(OnSelectionChangedListener listener) {
1652 mOnSelectionChangedListeners.add(listener);
1653 }
1654
1655 void removeOnSelectionChangedListener(OnSelectionChangedListener listener) {
1656 mOnSelectionChangedListeners.remove(listener);
1657 }
1658
1659 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001660 * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side
1661 * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides
1662 * of item columns and the top- and bottom sides of item rows so that it can be determined
1663 * whether the pointer is located within the bounds of an item.
1664 */
Steve McKay37550162015-09-08 17:15:25 -07001665 private static class Limits implements Comparable<Limits> {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001666 int lowerLimit;
1667 int upperLimit;
1668
1669 Limits(int lowerLimit, int upperLimit) {
1670 this.lowerLimit = lowerLimit;
1671 this.upperLimit = upperLimit;
1672 }
1673
1674 @Override
1675 public int compareTo(Limits other) {
1676 return lowerLimit - other.lowerLimit;
1677 }
1678
1679 @Override
1680 public boolean equals(Object other) {
1681 if (!(other instanceof Limits)) {
1682 return false;
1683 }
1684
1685 return ((Limits) other).lowerLimit == lowerLimit &&
1686 ((Limits) other).upperLimit == upperLimit;
1687 }
1688 }
1689
1690 /**
1691 * The location of a coordinate relative to items. This class represents a general area of the
1692 * view as it relates to band selection rather than an explicit point. For example, two
1693 * different points within an item are considered to have the same "location" because band
1694 * selection originating within the item would select the same items no matter which point
1695 * was used. Same goes for points between items as well as those at the very beginning or end
1696 * of the view.
1697 *
1698 * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the
1699 * advantage of tying the value to the Limits of items along that axis. This allows easy
1700 * selection of items within those Limits as opposed to a search through every item to see if a
1701 * given coordinate value falls within those Limits.
1702 */
Steve McKay37550162015-09-08 17:15:25 -07001703 private static class RelativeCoordinate
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001704 implements Comparable<RelativeCoordinate> {
1705 /**
1706 * Location describing points after the last known item.
1707 */
1708 static final int AFTER_LAST_ITEM = 0;
1709
1710 /**
1711 * Location describing points before the first known item.
1712 */
1713 static final int BEFORE_FIRST_ITEM = 1;
1714
1715 /**
1716 * Location describing points between two items.
1717 */
1718 static final int BETWEEN_TWO_ITEMS = 2;
1719
1720 /**
1721 * Location describing points within the limits of one item.
1722 */
1723 static final int WITHIN_LIMITS = 3;
1724
1725 /**
1726 * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM,
1727 * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS.
1728 */
1729 final int type;
1730
1731 /**
1732 * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type ==
1733 * BETWEEN_TWO_ITEMS.
1734 */
1735 Limits limitsBeforeCoordinate;
1736
1737 /**
1738 * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS.
1739 */
1740 Limits limitsAfterCoordinate;
1741
1742 // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM.
1743 Limits mFirstKnownItem;
1744 // Limits of the last known item; only populated when type == AFTER_LAST_ITEM.
1745 Limits mLastKnownItem;
1746
1747 /**
1748 * @param limitsList The sorted limits list for the coordinate type. If this
1749 * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise,
1750 * mYLimitsList should be pased.
1751 * @param value The coordinate value.
1752 */
1753 RelativeCoordinate(List<Limits> limitsList, int value) {
Steve McKay37550162015-09-08 17:15:25 -07001754 int index = Collections.binarySearch(limitsList, new Limits(value, value));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001755
1756 if (index >= 0) {
1757 this.type = WITHIN_LIMITS;
1758 this.limitsBeforeCoordinate = limitsList.get(index);
1759 } else if (~index == 0) {
1760 this.type = BEFORE_FIRST_ITEM;
1761 this.mFirstKnownItem = limitsList.get(0);
1762 } else if (~index == limitsList.size()) {
1763 Limits lastLimits = limitsList.get(limitsList.size() - 1);
1764 if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) {
1765 this.type = WITHIN_LIMITS;
1766 this.limitsBeforeCoordinate = lastLimits;
1767 } else {
1768 this.type = AFTER_LAST_ITEM;
1769 this.mLastKnownItem = lastLimits;
1770 }
1771 } else {
1772 Limits limitsBeforeIndex = limitsList.get(~index - 1);
1773 if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) {
1774 this.type = WITHIN_LIMITS;
1775 this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1776 } else {
1777 this.type = BETWEEN_TWO_ITEMS;
1778 this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1779 this.limitsAfterCoordinate = limitsList.get(~index);
1780 }
1781 }
1782 }
1783
1784 int toComparisonValue() {
1785 if (type == BEFORE_FIRST_ITEM) {
1786 return mFirstKnownItem.lowerLimit - 1;
1787 } else if (type == AFTER_LAST_ITEM) {
1788 return mLastKnownItem.upperLimit + 1;
1789 } else if (type == BETWEEN_TWO_ITEMS) {
1790 return limitsBeforeCoordinate.upperLimit + 1;
1791 } else {
1792 return limitsBeforeCoordinate.lowerLimit;
1793 }
1794 }
1795
1796 @Override
1797 public boolean equals(Object other) {
1798 if (!(other instanceof RelativeCoordinate)) {
1799 return false;
1800 }
1801
1802 RelativeCoordinate otherCoordinate = (RelativeCoordinate) other;
1803 return toComparisonValue() == otherCoordinate.toComparisonValue();
1804 }
1805
1806 @Override
1807 public int compareTo(RelativeCoordinate other) {
1808 return toComparisonValue() - other.toComparisonValue();
1809 }
1810 }
1811
1812 /**
1813 * The location of a point relative to the Limits of nearby items; consists of both an x- and
1814 * y-RelativeCoordinateLocation.
1815 */
1816 private class RelativePoint {
1817 final RelativeCoordinate xLocation;
1818 final RelativeCoordinate yLocation;
1819
1820 RelativePoint(Point point) {
Steve McKay37550162015-09-08 17:15:25 -07001821 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
1822 this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001823 }
1824
1825 @Override
1826 public boolean equals(Object other) {
1827 if (!(other instanceof RelativePoint)) {
1828 return false;
1829 }
1830
1831 RelativePoint otherPoint = (RelativePoint) other;
1832 return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation);
1833 }
1834 }
1835
1836 /**
1837 * Generates a rectangle which contains the items selected by the pointer and origin.
1838 * @return The rectangle, or null if no items were selected.
1839 */
1840 private Rect computeBounds() {
1841 Rect rect = new Rect();
1842 rect.left = getCoordinateValue(
1843 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
Steve McKay37550162015-09-08 17:15:25 -07001844 mColumnBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001845 true);
1846 rect.right = getCoordinateValue(
1847 max(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
Steve McKay37550162015-09-08 17:15:25 -07001848 mColumnBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001849 false);
1850 rect.top = getCoordinateValue(
1851 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
Steve McKay37550162015-09-08 17:15:25 -07001852 mRowBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001853 true);
1854 rect.bottom = getCoordinateValue(
1855 max(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
Steve McKay37550162015-09-08 17:15:25 -07001856 mRowBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001857 false);
1858 return rect;
1859 }
1860
1861 /**
1862 * Computes the corner of the selection nearest the origin.
1863 * @return
1864 */
1865 private int computeCornerNearestOrigin() {
1866 int cornerValue = 0;
1867
1868 if (mRelativeOrigin.yLocation ==
1869 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) {
1870 cornerValue |= UPPER;
1871 } else {
1872 cornerValue |= LOWER;
1873 }
1874
1875 if (mRelativeOrigin.xLocation ==
1876 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) {
1877 cornerValue |= LEFT;
1878 } else {
1879 cornerValue |= RIGHT;
1880 }
1881
1882 return cornerValue;
1883 }
1884
1885 private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) {
1886 return first.compareTo(second) < 0 ? first : second;
1887 }
1888
1889 private RelativeCoordinate max(RelativeCoordinate first, RelativeCoordinate second) {
1890 return first.compareTo(second) > 0 ? first : second;
1891 }
1892
1893 /**
1894 * @return The absolute coordinate (i.e., the x- or y-value) of the given relative
1895 * coordinate.
1896 */
1897 private int getCoordinateValue(RelativeCoordinate coordinate,
1898 List<Limits> limitsList, boolean isStartOfRange) {
1899 switch (coordinate.type) {
1900 case RelativeCoordinate.BEFORE_FIRST_ITEM:
1901 return limitsList.get(0).lowerLimit;
1902 case RelativeCoordinate.AFTER_LAST_ITEM:
1903 return limitsList.get(limitsList.size() - 1).upperLimit;
1904 case RelativeCoordinate.BETWEEN_TWO_ITEMS:
1905 if (isStartOfRange) {
1906 return coordinate.limitsAfterCoordinate.lowerLimit;
1907 } else {
1908 return coordinate.limitsBeforeCoordinate.upperLimit;
1909 }
1910 case RelativeCoordinate.WITHIN_LIMITS:
1911 return coordinate.limitsBeforeCoordinate.lowerLimit;
1912 }
1913
1914 throw new RuntimeException("Invalid coordinate value.");
1915 }
1916
Steve McKay37550162015-09-08 17:15:25 -07001917 private boolean areItemsCoveredByBand(
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001918 RelativePoint first, RelativePoint second) {
1919 return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
1920 doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
1921 }
1922
1923 private boolean doesCoordinateLocationCoverItems(
1924 RelativeCoordinate pointerCoordinate,
1925 RelativeCoordinate originCoordinate) {
1926 if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM &&
1927 originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) {
1928 return false;
1929 }
1930
1931 if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM &&
1932 originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) {
1933 return false;
1934 }
1935
1936 if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
1937 originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
1938 pointerCoordinate.limitsBeforeCoordinate.equals(
1939 originCoordinate.limitsBeforeCoordinate) &&
1940 pointerCoordinate.limitsAfterCoordinate.equals(
1941 originCoordinate.limitsAfterCoordinate)) {
1942 return false;
1943 }
1944
1945 return true;
1946 }
1947 }
Ben Kwaa017bc12015-10-07 14:15:12 -07001948
1949 // TODO: Might have to move this to a more global level. e.g. What should happen if the
1950 // user taps a file and then presses shift-down? Currently the RecyclerView never even sees
1951 // the key event. Perhaps install a global key handler to catch those events while in
1952 // selection mode?
1953 @Override
1954 public boolean onKey(View view, int keyCode, KeyEvent event) {
1955 // Listen for key-down events. This allows the handler to respond appropriately when
1956 // the user holds down the arrow keys for navigation.
1957 if (event.getAction() != KeyEvent.ACTION_DOWN) {
1958 return false;
1959 }
1960
1961 int target = RecyclerView.NO_POSITION;
1962 if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
1963 target = 0;
1964 } else if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
1965 target = mAdapter.getItemCount() - 1;
1966 } else {
1967 // Find a navigation target based on the arrow key that the user pressed. Ignore
1968 // navigation targets that aren't items in the recycler view.
1969 int searchDir = -1;
1970 switch (keyCode) {
1971 case KeyEvent.KEYCODE_DPAD_UP:
1972 searchDir = View.FOCUS_UP;
1973 break;
1974 case KeyEvent.KEYCODE_DPAD_DOWN:
1975 searchDir = View.FOCUS_DOWN;
1976 break;
1977 case KeyEvent.KEYCODE_DPAD_LEFT:
1978 searchDir = View.FOCUS_LEFT;
1979 break;
1980 case KeyEvent.KEYCODE_DPAD_RIGHT:
1981 searchDir = View.FOCUS_RIGHT;
1982 break;
1983 }
1984 if (searchDir != -1) {
1985 View targetView = view.focusSearch(searchDir);
Ben Kwa98bb6cf2015-10-14 08:00:27 -07001986 // TargetView can be null, for example, if the user pressed <down> at the bottom of
1987 // the list.
1988 if (targetView != null) {
1989 target = mEnvironment.getAdapterPositionForChildView(targetView);
1990 }
Ben Kwaa017bc12015-10-07 14:15:12 -07001991 }
1992 }
1993
1994 if (target == RecyclerView.NO_POSITION) {
1995 // If there is no valid navigation target, don't handle the keypress.
1996 return false;
1997 }
1998
1999 // Focus the new file.
2000 mEnvironment.focusItem(target);
2001
2002 if (event.isShiftPressed()) {
Ben Kwa98bb6cf2015-10-14 08:00:27 -07002003 if (!hasSelection()) {
Ben Kwaa017bc12015-10-07 14:15:12 -07002004 // If there is no selection, start a selection when the user presses shift-arrow.
2005 toggleSelection(mEnvironment.getAdapterPositionForChildView(view));
Ben Kwaa017bc12015-10-07 14:15:12 -07002006 }
Ben Kwa98bb6cf2015-10-14 08:00:27 -07002007
2008 mRanger.snapSelection(target);
2009 notifySelectionChanged();
Ben Kwaa017bc12015-10-07 14:15:12 -07002010 }
2011
2012 return true;
2013 }
2014
Steve McKayef280152015-06-11 10:10:49 -07002015}