blob: 26eac26ce96b36f1ca14c313a8731afe81b6e822 [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
Steve McKayf68210e2015-11-03 15:23:16 -080017package com.android.documentsui.dirlist;
Steve McKayef280152015-06-11 10:10:49 -070018
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;
Steve McKayef280152015-06-11 10:10:49 -070031import android.util.Log;
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -070032import android.util.SparseArray;
Ben Kwa936a7fc2015-12-10 15:21:18 -080033import android.util.SparseBooleanArray;
34import android.util.SparseIntArray;
Steve McKayef280152015-06-11 10:10:49 -070035import android.view.GestureDetector;
Ben Kwaa017bc12015-10-07 14:15:12 -070036import android.view.KeyEvent;
Steve McKayef280152015-06-11 10:10:49 -070037import android.view.MotionEvent;
38import android.view.View;
Steve McKay093d5e72015-09-03 16:49:51 -070039
40import com.android.documentsui.Events.InputEvent;
41import com.android.documentsui.Events.MotionInputEvent;
Steve McKaye0cba922015-11-11 16:26:59 +090042import com.android.documentsui.R;
Steve McKayef280152015-06-11 10:10:49 -070043
44import java.util.ArrayList;
Ben Kwa936a7fc2015-12-10 15:21:18 -080045import java.util.Collection;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -070046import java.util.Collections;
Ben Kwad72a1da2015-12-01 19:56:57 -080047import java.util.HashMap;
48import java.util.HashSet;
49import java.util.Iterator;
Steve McKayef280152015-06-11 10:10:49 -070050import java.util.List;
Ben Kwad72a1da2015-12-01 19:56:57 -080051import java.util.Map;
52import java.util.Set;
Steve McKayef280152015-06-11 10:10:49 -070053
54/**
Steve McKay57394872015-08-12 14:48:34 -070055 * MultiSelectManager provides support traditional multi-item selection support to RecyclerView.
56 * Additionally it can be configured to restrict selection to a single element, @see
57 * #setSelectMode.
Steve McKayef280152015-06-11 10:10:49 -070058 */
Ben Kwaa017bc12015-10-07 14:15:12 -070059public final class MultiSelectManager implements View.OnKeyListener {
Steve McKayef280152015-06-11 10:10:49 -070060
Steve McKay57394872015-08-12 14:48:34 -070061 /** Selection mode for multiple select. **/
62 public static final int MODE_MULTIPLE = 0;
63
64 /** Selection mode for multiple select. **/
65 public static final int MODE_SINGLE = 1;
66
Steve McKayef280152015-06-11 10:10:49 -070067 private static final String TAG = "MultiSelectManager";
Steve McKayef280152015-06-11 10:10:49 -070068
69 private final Selection mSelection = new Selection();
Steve McKay8c1b8c82015-07-28 19:20:01 -070070
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
Steve McKay57394872015-08-12 14:48:34 -070076 private boolean mSingleSelect;
Steve McKaybe629f32015-09-04 14:51:16 -070077
Ben Kwa60d91e42015-10-20 15:02:33 -070078 // Payloads for notifyItemChange to distinguish between selection and other events.
79 public static final String SELECTION_CHANGED_MARKER = "Selection-Changed";
80
Steve McKay37550162015-09-08 17:15:25 -070081 @Nullable private BandController mBandManager;
Steve McKayef280152015-06-11 10:10:49 -070082
83 /**
84 * @param recyclerView
Steve McKay57394872015-08-12 14:48:34 -070085 * @param mode Selection mode
Steve McKayef280152015-06-11 10:10:49 -070086 */
Steve McKay9eabc502015-10-19 12:04:21 -070087 public MultiSelectManager(final RecyclerView recyclerView, int mode) {
Ben Kwad72a1da2015-12-01 19:56:57 -080088 this(new RuntimeSelectionEnvironment(recyclerView), mode);
Ben Kwaa017bc12015-10-07 14:15:12 -070089
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 McKaye0cba922015-11-11 16:26:59 +0900135 * @param runtimeSelectionEnvironment
Steve McKay9459a7c2015-07-24 13:14:20 -0700136 * @hide
137 */
138 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800139 MultiSelectManager(SelectionEnvironment environment, int mode) {
Steve McKaye0cba922015-11-11 16:26:59 +0900140 mEnvironment = checkNotNull(environment, "'environment' cannot be null.");
Steve McKay57394872015-08-12 14:48:34 -0700141 mSingleSelect = mode == MODE_SINGLE;
142
Ben Kwad72a1da2015-12-01 19:56:57 -0800143 mEnvironment.registerDataObserver(
144 new RecyclerView.AdapterDataObserver() {
Steve McKayef280152015-06-11 10:10:49 -0700145
Ben Kwa936a7fc2015-12-10 15:21:18 -0800146 private List<String> mModelIds;
Ben Kwada858bf2015-12-09 14:33:49 -0800147
Steve McKayef280152015-06-11 10:10:49 -0700148 @Override
149 public void onChanged() {
Ben Kwa936a7fc2015-12-10 15:21:18 -0800150 // TODO: This is causing b/22765812
Steve McKayef280152015-06-11 10:10:49 -0700151 mSelection.clear();
Ben Kwa936a7fc2015-12-10 15:21:18 -0800152 mModelIds = mEnvironment.getModelIds();
Steve McKayef280152015-06-11 10:10:49 -0700153 }
154
155 @Override
156 public void onItemRangeChanged(
Ben Kwad72a1da2015-12-01 19:56:57 -0800157 int startPosition, int itemCount, Object payload) {
Steve McKayef280152015-06-11 10:10:49 -0700158 // No change in position. Ignoring.
159 }
160
161 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -0800162 public void onItemRangeInserted(int startPosition, int itemCount) {
163 mSelection.cancelProvisionalSelection();
Steve McKayef280152015-06-11 10:10:49 -0700164 }
165
166 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -0800167 public void onItemRangeRemoved(int startPosition, int itemCount) {
168 checkState(startPosition >= 0);
169 checkState(itemCount > 0);
Ben Kwa936a7fc2015-12-10 15:21:18 -0800170
Ben Kwad72a1da2015-12-01 19:56:57 -0800171 mSelection.cancelProvisionalSelection();
Ben Kwad72a1da2015-12-01 19:56:57 -0800172 // Remove any disappeared IDs from the selection.
Ben Kwa936a7fc2015-12-10 15:21:18 -0800173 mSelection.intersect(mModelIds);
Steve McKayef280152015-06-11 10:10:49 -0700174 }
175
176 @Override
177 public void onItemRangeMoved(int fromPosition, int toPosition, int itemCount) {
178 throw new UnsupportedOperationException();
179 }
180 });
181 }
182
Steve McKay9459a7c2015-07-24 13:14:20 -0700183 /**
184 * Adds {@code callback} such that it will be notified when {@code MultiSelectManager}
185 * events occur.
186 *
187 * @param callback
188 */
Steve McKayef280152015-06-11 10:10:49 -0700189 public void addCallback(MultiSelectManager.Callback callback) {
190 mCallbacks.add(callback);
191 }
192
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700193 public boolean hasSelection() {
194 return !mSelection.isEmpty();
195 }
196
Steve McKayef280152015-06-11 10:10:49 -0700197 /**
198 * Returns a Selection object that provides a live view
Ben Kwa24be5d32015-08-27 16:04:46 -0700199 * on the current selection.
Steve McKayef280152015-06-11 10:10:49 -0700200 *
Ben Kwa24be5d32015-08-27 16:04:46 -0700201 * @see #getSelection(Selection) on how to get a snapshot
Steve McKayef280152015-06-11 10:10:49 -0700202 * of the selection that will not reflect future changes
203 * to selection.
204 *
Ben Kwa24be5d32015-08-27 16:04:46 -0700205 * @return The current selection.
Steve McKayef280152015-06-11 10:10:49 -0700206 */
207 public Selection getSelection() {
208 return mSelection;
209 }
210
211 /**
212 * Updates {@code dest} to reflect the current selection.
213 * @param dest
214 *
215 * @return The Selection instance passed in, for convenience.
216 */
217 public Selection getSelection(Selection dest) {
218 dest.copyFrom(mSelection);
219 return dest;
220 }
221
Steve McKay9459a7c2015-07-24 13:14:20 -0700222 /**
Steve McKay57394872015-08-12 14:48:34 -0700223 * Sets the selected state of the specified items. Note that the callback will NOT
224 * be consulted to see if an item can be selected.
225 *
Ben Kwad72a1da2015-12-01 19:56:57 -0800226 * @param ids
227 * @param selected
228 * @return
Steve McKay9459a7c2015-07-24 13:14:20 -0700229 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800230 public boolean setItemsSelected(Iterable<String> ids, boolean selected) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700231 boolean changed = false;
Ben Kwad72a1da2015-12-01 19:56:57 -0800232 for (String id: ids) {
233 boolean itemChanged = selected ? mSelection.add(id) : mSelection.remove(id);
Steve McKay57394872015-08-12 14:48:34 -0700234 if (itemChanged) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800235 notifyItemStateChanged(id, selected);
Steve McKay57394872015-08-12 14:48:34 -0700236 }
237 changed |= itemChanged;
Steve McKay9459a7c2015-07-24 13:14:20 -0700238 }
Steve McKay57394872015-08-12 14:48:34 -0700239 notifySelectionChanged();
Steve McKay9459a7c2015-07-24 13:14:20 -0700240 return changed;
241 }
242
243 /**
Steve McKay57394872015-08-12 14:48:34 -0700244 * Clears the selection and notifies (even if nothing changes).
Steve McKay9459a7c2015-07-24 13:14:20 -0700245 */
Steve McKayef280152015-06-11 10:10:49 -0700246 public void clearSelection() {
Steve McKay57394872015-08-12 14:48:34 -0700247 clearSelectionQuietly();
248 notifySelectionChanged();
249 }
250
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -0700251 public void handleLayoutChanged() {
Steve McKaybe629f32015-09-04 14:51:16 -0700252 if (mBandManager != null) {
253 mBandManager.handleLayoutChanged();
254 }
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -0700255 }
256
Steve McKay57394872015-08-12 14:48:34 -0700257 /**
258 * Clears the selection, without notifying anyone.
259 */
260 private void clearSelectionQuietly() {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700261 mRanger = null;
262
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700263 if (!hasSelection()) {
Steve McKay9459a7c2015-07-24 13:14:20 -0700264 return;
265 }
Ben Kwad72a1da2015-12-01 19:56:57 -0800266
267 Selection intermediateSelection = getSelection(new Selection());
Steve McKayef280152015-06-11 10:10:49 -0700268 mSelection.clear();
269
Ben Kwad72a1da2015-12-01 19:56:57 -0800270 for (String id: intermediateSelection.getAll()) {
271 notifyItemStateChanged(id, false);
Steve McKayef280152015-06-11 10:10:49 -0700272 }
273 }
274
Steve McKay093d5e72015-09-03 16:49:51 -0700275 @VisibleForTesting
276 void onLongPress(InputEvent input) {
Steve McKayef280152015-06-11 10:10:49 -0700277 if (DEBUG) Log.d(TAG, "Handling long press event.");
278
Steve McKay093d5e72015-09-03 16:49:51 -0700279 if (!input.isOverItem()) {
280 if (DEBUG) Log.i(TAG, "Cannot handle tap. No adapter position available.");
Steve McKayef280152015-06-11 10:10:49 -0700281 }
282
Steve McKay093d5e72015-09-03 16:49:51 -0700283 handleAdapterEvent(input);
Steve McKayef280152015-06-11 10:10:49 -0700284 }
285
Steve McKayef280152015-06-11 10:10:49 -0700286 @VisibleForTesting
Steve McKay093d5e72015-09-03 16:49:51 -0700287 boolean onSingleTapUp(InputEvent input) {
288 if (DEBUG) Log.d(TAG, "Processing tap event.");
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700289 if (!hasSelection()) {
Steve McKay93d8ef42015-07-30 12:27:44 -0700290 // if this is a mouse click on an item, start selection mode.
Steve McKay093d5e72015-09-03 16:49:51 -0700291 // TODO: && input.isPrimaryButtonPressed(), but it is returning false.
292 if (input.isOverItem() && input.isMouseEvent()) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800293 int position = input.getItemPosition();
294 toggleSelection(position);
295 setSelectionRangeBegin(position);
Steve McKay93d8ef42015-07-30 12:27:44 -0700296 }
297 return false;
298 }
299
Steve McKay093d5e72015-09-03 16:49:51 -0700300 if (!input.isOverItem()) {
301 if (DEBUG) Log.d(TAG, "Activity has no position. Canceling selection.");
Steve McKay93d8ef42015-07-30 12:27:44 -0700302 clearSelection();
303 return false;
304 }
305
Steve McKay093d5e72015-09-03 16:49:51 -0700306 handleAdapterEvent(input);
Steve McKay5c865462015-09-02 15:59:28 -0700307 return true;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700308 }
309
310 /**
311 * Handles a change caused by a click on the item with the given position. If the Shift key is
312 * held down, this performs a range select; otherwise, it simply toggles the item's selection
313 * state.
314 */
Steve McKay093d5e72015-09-03 16:49:51 -0700315 private void handleAdapterEvent(InputEvent input) {
316 if (mRanger != null && input.isShiftKeyDown()) {
317 mRanger.snapSelection(input.getItemPosition());
Steve McKay93d8ef42015-07-30 12:27:44 -0700318
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700319 // We're being lazy here notifying even when something might not have changed.
320 // To make this more correct, we'd need to update the Ranger class to return
321 // information about what has changed.
322 notifySelectionChanged();
Ben Kwa14a96ce2015-09-25 07:47:56 -0700323 } else {
Ben Kwad72a1da2015-12-01 19:56:57 -0800324 int position = input.getItemPosition();
325 toggleSelection(position);
326 setSelectionRangeBegin(position);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700327 }
Steve McKay93d8ef42015-07-30 12:27:44 -0700328 }
329
330 /**
Ben Kwad72a1da2015-12-01 19:56:57 -0800331 * A convenience method for toggling selection by adapter position.
Steve McKay8c1b8c82015-07-28 19:20:01 -0700332 *
Ben Kwad72a1da2015-12-01 19:56:57 -0800333 * @param position Adapter position to toggle.
Steve McKay8c1b8c82015-07-28 19:20:01 -0700334 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800335 private void toggleSelection(int position) {
Steve McKayef280152015-06-11 10:10:49 -0700336 // Position may be special "no position" during certain
337 // transitional phases. If so, skip handling of the event.
338 if (position == RecyclerView.NO_POSITION) {
339 if (DEBUG) Log.d(TAG, "Ignoring toggle for element with no position.");
Ben Kwa14a96ce2015-09-25 07:47:56 -0700340 return;
Steve McKayef280152015-06-11 10:10:49 -0700341 }
Ben Kwad72a1da2015-12-01 19:56:57 -0800342 toggleSelection(mEnvironment.getModelIdFromAdapterPosition(position));
343 }
Steve McKayef280152015-06-11 10:10:49 -0700344
Ben Kwad72a1da2015-12-01 19:56:57 -0800345 /**
346 * Toggles selection on the item with the given model ID.
347 *
348 * @param modelId
349 */
350 public void toggleSelection(String modelId) {
Steve McKay57394872015-08-12 14:48:34 -0700351 boolean changed = false;
Ben Kwad72a1da2015-12-01 19:56:57 -0800352 if (mSelection.contains(modelId)) {
353 changed = attemptDeselect(modelId);
Steve McKayef280152015-06-11 10:10:49 -0700354 } else {
Ben Kwad72a1da2015-12-01 19:56:57 -0800355 boolean canSelect = notifyBeforeItemStateChange(modelId, true);
Steve McKay57394872015-08-12 14:48:34 -0700356 if (!canSelect) {
Ben Kwa14a96ce2015-09-25 07:47:56 -0700357 return;
Steve McKay57394872015-08-12 14:48:34 -0700358 }
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700359 if (mSingleSelect && hasSelection()) {
Steve McKay57394872015-08-12 14:48:34 -0700360 clearSelectionQuietly();
361 }
362
Steve McKay8c1b8c82015-07-28 19:20:01 -0700363 // Here we're already in selection mode. In that case
364 // When a simple click/tap (without SHIFT) creates causes
365 // an item to be selected.
366 // By recreating Ranger at this point, we allow the user to create
367 // multiple separate contiguous ranges with SHIFT+Click & Click.
Ben Kwad72a1da2015-12-01 19:56:57 -0800368 selectAndNotify(modelId);
Steve McKay57394872015-08-12 14:48:34 -0700369 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 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800385 void setSelectionRangeBegin(int position) {
386 if (mSelection.contains(mEnvironment.getModelIdFromAdapterPosition(position))) {
387 mRanger = new Range(position);
388 }
Steve McKay93d8ef42015-07-30 12:27:44 -0700389 }
390
391 /**
Ben Kwad72a1da2015-12-01 19:56:57 -0800392 * Try to set selection state for all elements in range. Not that callbacks can cancel selection
393 * of specific items, so some or even all items may not reflect the desired state after the
394 * update is complete.
Steve McKay8c1b8c82015-07-28 19:20:01 -0700395 *
Ben Kwad72a1da2015-12-01 19:56:57 -0800396 * @param begin Adapter position for range start (inclusive).
397 * @param end Adapter position for range end (inclusive).
398 * @param selected New selection state.
Steve McKay8c1b8c82015-07-28 19:20:01 -0700399 */
400 private void updateRange(int begin, int end, boolean selected) {
401 checkState(end >= begin);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700402 for (int i = begin; i <= end; i++) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800403 String id = mEnvironment.getModelIdFromAdapterPosition(i);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700404 if (selected) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800405 boolean canSelect = notifyBeforeItemStateChange(id, true);
Steve McKay57394872015-08-12 14:48:34 -0700406 if (canSelect) {
Ben Kwa98bb6cf2015-10-14 08:00:27 -0700407 if (mSingleSelect && hasSelection()) {
Steve McKay57394872015-08-12 14:48:34 -0700408 clearSelectionQuietly();
409 }
Ben Kwad72a1da2015-12-01 19:56:57 -0800410 selectAndNotify(id);
Steve McKay57394872015-08-12 14:48:34 -0700411 }
Steve McKay8c1b8c82015-07-28 19:20:01 -0700412 } else {
Ben Kwad72a1da2015-12-01 19:56:57 -0800413 attemptDeselect(id);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700414 }
415 }
416 }
417
418 /**
Ben Kwad72a1da2015-12-01 19:56:57 -0800419 * @param modelId
Steve McKay8c1b8c82015-07-28 19:20:01 -0700420 * @return True if the update was applied.
421 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800422 private boolean selectAndNotify(String modelId) {
423 boolean changed = mSelection.add(modelId);
Steve McKay57394872015-08-12 14:48:34 -0700424 if (changed) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800425 notifyItemStateChanged(modelId, true);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700426 }
Steve McKay57394872015-08-12 14:48:34 -0700427 return changed;
Steve McKay8c1b8c82015-07-28 19:20:01 -0700428 }
429
430 /**
Ben Kwad72a1da2015-12-01 19:56:57 -0800431 * @param id
Steve McKay8c1b8c82015-07-28 19:20:01 -0700432 * @return True if the update was applied.
433 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800434 private boolean attemptDeselect(String id) {
435 if (notifyBeforeItemStateChange(id, false)) {
436 mSelection.remove(id);
437 notifyItemStateChanged(id, false);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700438 if (DEBUG) Log.d(TAG, "Selection after deselect: " + mSelection);
439 return true;
440 } else {
441 if (DEBUG) Log.d(TAG, "Select cancelled by listener.");
442 return false;
Steve McKayef280152015-06-11 10:10:49 -0700443 }
444 }
445
Ben Kwad72a1da2015-12-01 19:56:57 -0800446 private boolean notifyBeforeItemStateChange(String id, boolean nextState) {
Steve McKayef280152015-06-11 10:10:49 -0700447 int lastListener = mCallbacks.size() - 1;
448 for (int i = lastListener; i > -1; i--) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800449 if (!mCallbacks.get(i).onBeforeItemStateChange(id, nextState)) {
Steve McKayef280152015-06-11 10:10:49 -0700450 return false;
451 }
452 }
453 return true;
454 }
455
456 /**
Steve McKay57394872015-08-12 14:48:34 -0700457 * Notifies registered listeners when the selection status of a single item
458 * (identified by {@code position}) changes.
Steve McKayef280152015-06-11 10:10:49 -0700459 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800460 private void notifyItemStateChanged(String id, boolean selected) {
Steve McKayef280152015-06-11 10:10:49 -0700461 int lastListener = mCallbacks.size() - 1;
462 for (int i = lastListener; i > -1; i--) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800463 mCallbacks.get(i).onItemStateChanged(id, selected);
Steve McKayef280152015-06-11 10:10:49 -0700464 }
Ben Kwa6280de02015-12-16 19:42:08 -0800465 mEnvironment.notifyItemChanged(id);
Steve McKayef280152015-06-11 10:10:49 -0700466 }
467
468 /**
Steve McKay57394872015-08-12 14:48:34 -0700469 * Notifies registered listeners when the selection has changed. This
470 * notification should be sent only once a full series of changes
471 * is complete, e.g. clearingSelection, or updating the single
472 * selection from one item to another.
473 */
474 private void notifySelectionChanged() {
475 int lastListener = mCallbacks.size() - 1;
476 for (int i = lastListener; i > -1; i--) {
477 mCallbacks.get(i).onSelectionChanged();
478 }
479 }
480
481 /**
Steve McKay8c1b8c82015-07-28 19:20:01 -0700482 * Class providing support for managing range selections.
483 */
Steve McKay93d8ef42015-07-30 12:27:44 -0700484 private final class Range {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700485 private static final int UNDEFINED = -1;
486
487 final int mBegin;
488 int mEnd = UNDEFINED;
489
Steve McKay93d8ef42015-07-30 12:27:44 -0700490 public Range(int begin) {
Steve McKay57394872015-08-12 14:48:34 -0700491 if (DEBUG) Log.d(TAG, "New Ranger created beginning @ " + begin);
Steve McKay8c1b8c82015-07-28 19:20:01 -0700492 mBegin = begin;
493 }
494
Steve McKay93d8ef42015-07-30 12:27:44 -0700495 private void snapSelection(int position) {
Steve McKay8c1b8c82015-07-28 19:20:01 -0700496 checkState(mRanger != null);
497 checkArgument(position != RecyclerView.NO_POSITION);
498
499 if (mEnd == UNDEFINED || mEnd == mBegin) {
500 // Reset mEnd so it can be established in establishRange.
501 mEnd = UNDEFINED;
502 establishRange(position);
503 } else {
504 reviseRange(position);
505 }
506 }
507
508 private void establishRange(int position) {
509 checkState(mRanger.mEnd == UNDEFINED);
510
511 if (position == mBegin) {
512 mEnd = position;
513 }
514
515 if (position > mBegin) {
516 updateRange(mBegin + 1, position, true);
517 } else if (position < mBegin) {
518 updateRange(position, mBegin - 1, true);
519 }
520
521 mEnd = position;
522 }
523
524 private void reviseRange(int position) {
525 checkState(mEnd != UNDEFINED);
526 checkState(mBegin != mEnd);
527
528 if (position == mEnd) {
529 if (DEBUG) Log.i(TAG, "Skipping no-op revision click on mEndRange.");
530 }
531
532 if (mEnd > mBegin) {
533 reviseAscendingRange(position);
534 } else if (mEnd < mBegin) {
535 reviseDescendingRange(position);
536 }
537 // the "else" case is covered by checkState at beginning of method.
538
539 mEnd = position;
540 }
541
542 /**
543 * Updates an existing ascending seleciton.
544 * @param position
545 */
546 private void reviseAscendingRange(int position) {
547 // Reducing or reversing the range....
548 if (position < mEnd) {
549 if (position < mBegin) {
550 updateRange(mBegin + 1, mEnd, false);
551 updateRange(position, mBegin -1, true);
552 } else {
553 updateRange(position + 1, mEnd, false);
554 }
555 }
556
557 // Extending the range...
558 else if (position > mEnd) {
559 updateRange(mEnd + 1, position, true);
560 }
561 }
562
563 private void reviseDescendingRange(int position) {
564 // Reducing or reversing the range....
565 if (position > mEnd) {
566 if (position > mBegin) {
567 updateRange(mEnd, mBegin - 1, false);
568 updateRange(mBegin + 1, position, true);
569 } else {
570 updateRange(mEnd, position - 1, false);
571 }
572 }
573
574 // Extending the range...
575 else if (position < mEnd) {
576 updateRange(position, mEnd - 1, true);
577 }
578 }
579 }
580
581 /**
Steve McKay9459a7c2015-07-24 13:14:20 -0700582 * Object representing the current selection. Provides read only access
583 * public access, and private write access.
Steve McKayef280152015-06-11 10:10:49 -0700584 */
Steve McKayef280152015-06-11 10:10:49 -0700585 public static final class Selection {
586
Ben Kwad72a1da2015-12-01 19:56:57 -0800587 // This class tracks selected items by managing two sets: the saved selection, and the total
588 // selection. Saved selections are those which have been completed by tapping an item or by
589 // completing a band select operation. Provisional selections are selections which have been
590 // temporarily created by an in-progress band select operation (once the user releases the
591 // mouse button during a band select operation, the selected items become saved). The total
592 // selection is the combination of both the saved selection and the provisional
593 // selection. Tracking both separately is necessary to ensure that saved selections do not
594 // become deselected when they are removed from the provisional selection; for example, if
595 // item A is tapped (and selected), then an in-progress band select covers A then uncovers
596 // A, A should still be selected as it has been saved. To ensure this behavior, the saved
597 // selection must be tracked separately.
598 private Set<String> mSavedSelection = new HashSet<>();
599 private Set<String> mTotalSelection = new HashSet<>();
Steve McKayef280152015-06-11 10:10:49 -0700600
Steve McKay68a8bb82015-10-21 14:38:54 -0700601 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800602 public Selection(String... ids) {
603 for (int i = 0; i < ids.length; i++) {
604 add(ids[i]);
Steve McKay68a8bb82015-10-21 14:38:54 -0700605 }
606 }
607
Steve McKayef280152015-06-11 10:10:49 -0700608 /**
Ben Kwad72a1da2015-12-01 19:56:57 -0800609 * @param id
Steve McKayef280152015-06-11 10:10:49 -0700610 * @return true if the position is currently selected.
611 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800612 public boolean contains(String id) {
613 return mTotalSelection.contains(id);
Steve McKayef280152015-06-11 10:10:49 -0700614 }
615
616 /**
Steve McKay68a8bb82015-10-21 14:38:54 -0700617 * Returns an unordered array of selected positions.
618 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800619 public String[] getAll() {
620 return mTotalSelection.toArray(new String[0]);
Steve McKay68a8bb82015-10-21 14:38:54 -0700621 }
622
623 /**
Steve McKayef280152015-06-11 10:10:49 -0700624 * @return size of the selection.
625 */
626 public int size() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700627 return mTotalSelection.size();
Steve McKayef280152015-06-11 10:10:49 -0700628 }
629
Steve McKay9459a7c2015-07-24 13:14:20 -0700630 /**
631 * @return true if the selection is empty.
632 */
633 public boolean isEmpty() {
Ben Kwad72a1da2015-12-01 19:56:57 -0800634 return mTotalSelection.isEmpty();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700635 }
636
637 /**
638 * Sets the provisional selection, which is a temporary selection that can be saved,
639 * canceled, or adjusted at a later time. When a new provision selection is applied, the old
640 * one (if it exists) is abandoned.
641 * @return Array with entry for each position added or removed. Entries which were added
642 * contain a value of true, and entries which were removed contain a value of false.
643 */
644 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800645 protected Map<String, Boolean> setProvisionalSelection(Set<String> provisionalSelection) {
646 Map<String, Boolean> delta = new HashMap<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700647
Ben Kwad72a1da2015-12-01 19:56:57 -0800648 for (String id: mTotalSelection) {
649 // Mark each item that used to be in the selection but is unsaved and not in the new
650 // provisional selection.
651 if (!provisionalSelection.contains(id) && !mSavedSelection.contains(id)) {
652 delta.put(id, false);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700653 }
654 }
655
Ben Kwad72a1da2015-12-01 19:56:57 -0800656 for (String id: provisionalSelection) {
657 // Mark each item that was not previously in the selection but is in the new
658 // provisional selection.
659 if (!mTotalSelection.contains(id)) {
660 delta.put(id, true);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700661 }
662 }
663
Ben Kwad72a1da2015-12-01 19:56:57 -0800664 // Now, iterate through the changes and actually add/remove them to/from the current
665 // selection. This could not be done in the previous loops because changing the size of
666 // the selection mid-iteration changes iteration order erroneously.
667 for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
668 String id = entry.getKey();
669 if (entry.getValue()) {
670 mTotalSelection.add(id);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700671 } else {
Ben Kwad72a1da2015-12-01 19:56:57 -0800672 mTotalSelection.remove(id);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700673 }
674 }
675
676 return delta;
677 }
678
679 /**
680 * Saves the existing provisional selection. Once the provisional selection is saved,
681 * subsequent provisional selections which are different from this existing one cannot
682 * cause items in this existing provisional selection to become deselected.
683 */
684 @VisibleForTesting
685 protected void applyProvisionalSelection() {
Ben Kwad72a1da2015-12-01 19:56:57 -0800686 mSavedSelection = new HashSet<>(mTotalSelection);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700687 }
688
689 /**
690 * Abandons the existing provisional selection so that all items provisionally selected are
691 * now deselected.
692 */
693 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800694 void cancelProvisionalSelection() {
695 mTotalSelection = new HashSet<>(mSavedSelection);
Steve McKay9459a7c2015-07-24 13:14:20 -0700696 }
697
Steve McKayef280152015-06-11 10:10:49 -0700698 /** @hide */
699 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800700 boolean add(String id) {
701 if (!mTotalSelection.contains(id)) {
702 mTotalSelection.add(id);
703 mSavedSelection.add(id);
Steve McKay9459a7c2015-07-24 13:14:20 -0700704 return true;
705 }
706 return false;
Steve McKayef280152015-06-11 10:10:49 -0700707 }
708
709 /** @hide */
710 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -0800711 boolean remove(String id) {
712 if (mTotalSelection.contains(id)) {
713 mTotalSelection.remove(id);
714 mSavedSelection.remove(id);
Steve McKay9459a7c2015-07-24 13:14:20 -0700715 return true;
716 }
717 return false;
Steve McKayef280152015-06-11 10:10:49 -0700718 }
719
Steve McKayf68210e2015-11-03 15:23:16 -0800720 public void clear() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700721 mSavedSelection.clear();
722 mTotalSelection.clear();
Steve McKayef280152015-06-11 10:10:49 -0700723 }
724
Ben Kwa936a7fc2015-12-10 15:21:18 -0800725 /**
726 * Trims this selection to be the intersection of itself with the set of given IDs.
727 */
728 public void intersect(Collection<String> ids) {
729 mSavedSelection.retainAll(ids);
730 mTotalSelection.retainAll(ids);
731 }
732
Steve McKayef280152015-06-11 10:10:49 -0700733 @VisibleForTesting
734 void copyFrom(Selection source) {
Ben Kwad72a1da2015-12-01 19:56:57 -0800735 mSavedSelection = new HashSet<>(source.mSavedSelection);
736 mTotalSelection = new HashSet<>(source.mTotalSelection);
Steve McKayef280152015-06-11 10:10:49 -0700737 }
738
739 @Override
740 public String toString() {
741 if (size() <= 0) {
742 return "size=0, items=[]";
743 }
744
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700745 StringBuilder buffer = new StringBuilder(mTotalSelection.size() * 28);
Steve McKay57394872015-08-12 14:48:34 -0700746 buffer.append("{size=")
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700747 .append(mTotalSelection.size())
Steve McKay57394872015-08-12 14:48:34 -0700748 .append(", ")
749 .append("items=[");
Ben Kwad72a1da2015-12-01 19:56:57 -0800750 for (Iterator<String> i = mTotalSelection.iterator(); i.hasNext(); ) {
751 buffer.append(i.next());
752 if (i.hasNext()) {
Steve McKayef280152015-06-11 10:10:49 -0700753 buffer.append(", ");
754 }
Steve McKayef280152015-06-11 10:10:49 -0700755 }
756 buffer.append("]}");
757 return buffer.toString();
758 }
759
760 @Override
Steve McKayf4c06ab2015-07-22 11:42:14 -0700761 public int hashCode() {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700762 return mSavedSelection.hashCode() ^ mTotalSelection.hashCode();
Steve McKayf4c06ab2015-07-22 11:42:14 -0700763 }
764
765 @Override
Steve McKayef280152015-06-11 10:10:49 -0700766 public boolean equals(Object that) {
767 if (this == that) {
768 return true;
769 }
770
Steve McKayf4c06ab2015-07-22 11:42:14 -0700771 if (!(that instanceof Selection)) {
772 return false;
Steve McKayef280152015-06-11 10:10:49 -0700773 }
Steve McKayf4c06ab2015-07-22 11:42:14 -0700774
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700775 return mSavedSelection.equals(((Selection) that).mSavedSelection) &&
776 mTotalSelection.equals(((Selection) that).mTotalSelection);
Steve McKayef280152015-06-11 10:10:49 -0700777 }
778 }
779
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700780 /**
Steve McKay37550162015-09-08 17:15:25 -0700781 * Provides functionality for BandController. Exists primarily to tests that are
782 * fully isolated from RecyclerView.
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700783 */
Ben Kwaa017bc12015-10-07 14:15:12 -0700784 interface SelectionEnvironment {
Steve McKay37550162015-09-08 17:15:25 -0700785 void showBand(Rect rect);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700786 void hideBand();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700787 void addOnScrollListener(RecyclerView.OnScrollListener listener);
Steve McKay37550162015-09-08 17:15:25 -0700788 void removeOnScrollListener(RecyclerView.OnScrollListener listener);
789 void scrollBy(int dy);
790 int getHeight();
791 void invalidateView();
792 void runAtNextFrame(Runnable r);
793 void removeCallback(Runnable r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700794 Point createAbsolutePoint(Point relativePoint);
795 Rect getAbsoluteRectForChildViewAt(int index);
Ben Kwa936a7fc2015-12-10 15:21:18 -0800796 int getAdapterPositionAt(int index);
Ben Kwaa017bc12015-10-07 14:15:12 -0700797 int getAdapterPositionForChildView(View view);
Steve McKay37550162015-09-08 17:15:25 -0700798 int getColumnCount();
799 int getRowCount();
800 int getChildCount();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700801 int getVisibleChildCount();
Ben Kwaa017bc12015-10-07 14:15:12 -0700802 void focusItem(int position);
Ben Kwad72a1da2015-12-01 19:56:57 -0800803 String getModelIdFromAdapterPosition(int position);
Ben Kwad72a1da2015-12-01 19:56:57 -0800804 int getItemCount();
Ben Kwa936a7fc2015-12-10 15:21:18 -0800805 List<String> getModelIds();
Ben Kwa6280de02015-12-16 19:42:08 -0800806 void notifyItemChanged(String id);
Ben Kwad72a1da2015-12-01 19:56:57 -0800807 void registerDataObserver(RecyclerView.AdapterDataObserver observer);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700808 }
809
Steve McKaye0cba922015-11-11 16:26:59 +0900810 /** Recycler view facade implementation backed by good ol' RecyclerView. */
Ben Kwaa017bc12015-10-07 14:15:12 -0700811 private static final class RuntimeSelectionEnvironment implements SelectionEnvironment {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700812
Steve McKay37550162015-09-08 17:15:25 -0700813 private final RecyclerView mView;
814 private final Drawable mBand;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700815
816 private boolean mIsOverlayShown = false;
Ben Kwad72a1da2015-12-01 19:56:57 -0800817 private DirectoryFragment.DocumentsAdapter mAdapter;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700818
Ben Kwaa017bc12015-10-07 14:15:12 -0700819 RuntimeSelectionEnvironment(RecyclerView rv) {
Steve McKay37550162015-09-08 17:15:25 -0700820 mView = rv;
Ben Kwad72a1da2015-12-01 19:56:57 -0800821 mAdapter = (DirectoryFragment.DocumentsAdapter) rv.getAdapter();
Steve McKay37550162015-09-08 17:15:25 -0700822 mBand = mView.getContext().getTheme().getDrawable(R.drawable.band_select_overlay);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700823 }
824
825 @Override
Ben Kwaa017bc12015-10-07 14:15:12 -0700826 public int getAdapterPositionForChildView(View view) {
827 if (view.getParent() == mView) {
828 return mView.getChildAdapterPosition(view);
829 } else {
830 return RecyclerView.NO_POSITION;
831 }
832 }
833
834 @Override
Ben Kwa936a7fc2015-12-10 15:21:18 -0800835 public int getAdapterPositionAt(int index) {
836 return getAdapterPositionForChildView(mView.getChildAt(index));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700837 }
838
839 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -0800840 public String getModelIdFromAdapterPosition(int position) {
841 return mAdapter.getModelId(position);
842 }
843
844 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -0800845 public void addOnScrollListener(RecyclerView.OnScrollListener listener) {
Steve McKay37550162015-09-08 17:15:25 -0700846 mView.addOnScrollListener(listener);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700847 }
848
849 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -0800850 public void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
Steve McKay37550162015-09-08 17:15:25 -0700851 mView.removeOnScrollListener(listener);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700852 }
853
854 @Override
855 public Point createAbsolutePoint(Point relativePoint) {
Steve McKay37550162015-09-08 17:15:25 -0700856 return new Point(relativePoint.x + mView.computeHorizontalScrollOffset(),
857 relativePoint.y + mView.computeVerticalScrollOffset());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700858 }
859
860 @Override
861 public Rect getAbsoluteRectForChildViewAt(int index) {
Steve McKay37550162015-09-08 17:15:25 -0700862 final View child = mView.getChildAt(index);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700863 final Rect childRect = new Rect();
864 child.getHitRect(childRect);
Steve McKay37550162015-09-08 17:15:25 -0700865 childRect.left += mView.computeHorizontalScrollOffset();
866 childRect.right += mView.computeHorizontalScrollOffset();
867 childRect.top += mView.computeVerticalScrollOffset();
868 childRect.bottom += mView.computeVerticalScrollOffset();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700869 return childRect;
870 }
871
872 @Override
Steve McKay37550162015-09-08 17:15:25 -0700873 public int getChildCount() {
874 return mView.getAdapter().getItemCount();
875 }
876
877 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700878 public int getVisibleChildCount() {
Steve McKay37550162015-09-08 17:15:25 -0700879 return mView.getChildCount();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700880 }
881
882 @Override
Steve McKay37550162015-09-08 17:15:25 -0700883 public int getColumnCount() {
Ben Kwad72a1da2015-12-01 19:56:57 -0800884 RecyclerView.LayoutManager layoutManager = mView.getLayoutManager();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700885 if (layoutManager instanceof GridLayoutManager) {
886 return ((GridLayoutManager) layoutManager).getSpanCount();
887 }
888
889 // Otherwise, it is a list with 1 column.
890 return 1;
891 }
892
893 @Override
Steve McKay37550162015-09-08 17:15:25 -0700894 public int getRowCount() {
895 int numFullColumns = getChildCount() / getColumnCount();
896 boolean hasPartiallyFullColumn = getChildCount() % getColumnCount() != 0;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700897 return numFullColumns + (hasPartiallyFullColumn ? 1 : 0);
898 }
899
900 @Override
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700901 public int getHeight() {
Steve McKay37550162015-09-08 17:15:25 -0700902 return mView.getHeight();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700903 }
904
905 @Override
906 public void invalidateView() {
Steve McKay37550162015-09-08 17:15:25 -0700907 mView.invalidate();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700908 }
909
910 @Override
Steve McKay37550162015-09-08 17:15:25 -0700911 public void runAtNextFrame(Runnable r) {
912 mView.postOnAnimation(r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700913 }
914
915 @Override
916 public void removeCallback(Runnable r) {
Steve McKay37550162015-09-08 17:15:25 -0700917 mView.removeCallbacks(r);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700918 }
919
920 @Override
921 public void scrollBy(int dy) {
Steve McKay37550162015-09-08 17:15:25 -0700922 mView.scrollBy(0, dy);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700923 }
924
925 @Override
Steve McKay37550162015-09-08 17:15:25 -0700926 public void showBand(Rect rect) {
927 mBand.setBounds(rect);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700928
929 if (!mIsOverlayShown) {
Steve McKay37550162015-09-08 17:15:25 -0700930 mView.getOverlay().add(mBand);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700931 }
932 }
933
934 @Override
935 public void hideBand() {
Steve McKay37550162015-09-08 17:15:25 -0700936 mView.getOverlay().remove(mBand);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700937 }
Ben Kwaa017bc12015-10-07 14:15:12 -0700938
939 @Override
940 public void focusItem(final int pos) {
941 // If the item is already in view, focus it; otherwise, scroll to it and focus it.
942 RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos);
943 if (vh != null) {
944 vh.itemView.requestFocus();
945 } else {
946 // Don't smooth scroll; that taxes the system unnecessarily and makes the scroll
947 // handling logic below more complicated. See b/24865658.
948 mView.scrollToPosition(pos);
949 // Set a one-time listener to request focus when the scroll has completed.
950 mView.addOnScrollListener(
951 new RecyclerView.OnScrollListener() {
952 @Override
953 public void onScrolled(RecyclerView view, int dx, int dy) {
954 view.findViewHolderForAdapterPosition(pos).itemView.requestFocus();
955 view.removeOnScrollListener(this);
956 }
957 });
958 }
959 }
Ben Kwad72a1da2015-12-01 19:56:57 -0800960
961 @Override
Ben Kwa6280de02015-12-16 19:42:08 -0800962 public void notifyItemChanged(String id) {
963 mAdapter.notifyItemChanged(id, SELECTION_CHANGED_MARKER);
Ben Kwad72a1da2015-12-01 19:56:57 -0800964 }
965
966 @Override
967 public int getItemCount() {
968 return mAdapter.getItemCount();
969 }
970
971 @Override
972 public void registerDataObserver(RecyclerView.AdapterDataObserver observer) {
973 mAdapter.registerAdapterDataObserver(observer);
974 }
Ben Kwa936a7fc2015-12-10 15:21:18 -0800975
976 @Override
977 public List<String> getModelIds() {
978 return mAdapter.getModelIds();
979 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -0700980 }
981
Steve McKayef280152015-06-11 10:10:49 -0700982 public interface Callback {
983 /**
984 * Called when an item is selected or unselected while in selection mode.
985 *
986 * @param position Adapter position of the item that was checked or unchecked
987 * @param selected <code>true</code> if the item is now selected, <code>false</code>
988 * if the item is now unselected.
989 */
Ben Kwad72a1da2015-12-01 19:56:57 -0800990 public void onItemStateChanged(String id, boolean selected);
Steve McKayef280152015-06-11 10:10:49 -0700991
992 /**
Steve McKay57394872015-08-12 14:48:34 -0700993 * Called prior to an item changing state. Callbacks can cancel
994 * the change at {@code position} by returning {@code false}.
995 *
Ben Kwad72a1da2015-12-01 19:56:57 -0800996 * @param id Adapter position of the item that was checked or unchecked
Steve McKay57394872015-08-12 14:48:34 -0700997 * @param selected <code>true</code> if the item is to be selected, <code>false</code>
998 * if the item is to be unselected.
Steve McKayef280152015-06-11 10:10:49 -0700999 */
Ben Kwad72a1da2015-12-01 19:56:57 -08001000 public boolean onBeforeItemStateChange(String id, boolean selected);
Steve McKay57394872015-08-12 14:48:34 -07001001
1002 /**
1003 * Called immediately after completion of any set of changes.
1004 */
1005 public void onSelectionChanged();
Steve McKayef280152015-06-11 10:10:49 -07001006 }
1007
1008 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001009 * Provides mouse driven band-select support when used in conjunction with {@link RecyclerView}
1010 * and {@link MultiSelectManager}. This class is responsible for rendering the band select
1011 * overlay and selecting overlaid items via MultiSelectManager.
1012 */
Steve McKay37550162015-09-08 17:15:25 -07001013 public class BandController extends RecyclerView.OnScrollListener
1014 implements GridModel.OnSelectionChangedListener {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001015
1016 private static final int NOT_SET = -1;
1017
Steve McKay093d5e72015-09-03 16:49:51 -07001018 private final Runnable mModelBuilder;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001019
Steve McKay093d5e72015-09-03 16:49:51 -07001020 @Nullable private Rect mBounds;
1021 @Nullable private Point mCurrentPosition;
1022 @Nullable private Point mOrigin;
Steve McKay37550162015-09-08 17:15:25 -07001023 @Nullable private GridModel mModel;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001024
1025 // The time at which the current band selection-induced scroll began. If no scroll is in
1026 // progress, the value is NOT_SET.
1027 private long mScrollStartTime = NOT_SET;
1028 private final Runnable mViewScroller = new ViewScroller();
1029
Steve McKay9eabc502015-10-19 12:04:21 -07001030 public BandController() {
Steve McKay37550162015-09-08 17:15:25 -07001031 mEnvironment.addOnScrollListener(this);
Steve McKay093d5e72015-09-03 16:49:51 -07001032
1033 mModelBuilder = new Runnable() {
1034 @Override
1035 public void run() {
Ben Kwaa017bc12015-10-07 14:15:12 -07001036 mModel = new GridModel(mEnvironment);
Steve McKay37550162015-09-08 17:15:25 -07001037 mModel.addOnSelectionChangedListener(BandController.this);
Steve McKay093d5e72015-09-03 16:49:51 -07001038 }
1039 };
1040 }
1041
Steve McKay9eabc502015-10-19 12:04:21 -07001042 public boolean handleEvent(MotionInputEvent e) {
1043 // b/23793622 notes the fact that we *never* receive ACTION_DOWN
1044 // events in onTouchEvent. Where it not for this issue, we'd
1045 // push start handling down into handleInputEvent.
1046 if (mBandManager.shouldStart(e)) {
1047 // endBandSelect is handled in handleInputEvent.
1048 mBandManager.startBandSelect(e.getOrigin());
1049 } else if (mBandManager.isActive()
1050 && e.isMouseEvent()
1051 && e.isActionUp()) {
1052 // Same issue here w b/23793622. The ACTION_UP event
1053 // is only evert dispatched to onTouchEvent when
1054 // there is some associated motion. If a user taps
1055 // mouse, but doesn't move, then band select gets
1056 // started BUT not ended. Causing phantom
1057 // bands to appear when the user later clicks to start
1058 // band select.
1059 mBandManager.processInputEvent(e);
1060 }
1061
1062 return isActive();
1063 }
1064
Steve McKay093d5e72015-09-03 16:49:51 -07001065 private boolean isActive() {
1066 return mModel != null;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001067 }
1068
1069 /**
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001070 * Handle a change in layout by cleaning up and getting rid of the old model and creating
1071 * a new model which will track the new layout.
1072 */
1073 public void handleLayoutChanged() {
Steve McKay093d5e72015-09-03 16:49:51 -07001074 if (mModel != null) {
1075 mModel.removeOnSelectionChangedListener(this);
1076 mModel.stopListening();
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001077
Steve McKay093d5e72015-09-03 16:49:51 -07001078 // build a new model, all fresh and happy.
1079 mModelBuilder.run();
1080 }
1081 }
1082
Steve McKay9eabc502015-10-19 12:04:21 -07001083 boolean shouldStart(MotionInputEvent e) {
Steve McKay093d5e72015-09-03 16:49:51 -07001084 return !isActive()
Steve McKay9eabc502015-10-19 12:04:21 -07001085 && e.isMouseEvent() // a mouse
1086 && e.isActionDown() // the initial button press
Ben Kwad72a1da2015-12-01 19:56:57 -08001087 && mEnvironment.getItemCount() > 0
Steve McKay9eabc502015-10-19 12:04:21 -07001088 && e.getItemPosition() == RecyclerView.NO_ID; // in empty space
Steve McKay093d5e72015-09-03 16:49:51 -07001089 }
1090
1091 boolean shouldStop(InputEvent input) {
1092 return isActive()
1093 && input.isMouseEvent()
1094 && input.isActionUp();
Kyle Horimoto2da6e4a2015-08-27 16:44:00 -07001095 }
1096
1097 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001098 * Processes a MotionEvent by starting, ending, or resizing the band select overlay.
Steve McKay093d5e72015-09-03 16:49:51 -07001099 * @param input
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001100 */
Steve McKay9eabc502015-10-19 12:04:21 -07001101 private void processInputEvent(InputEvent input) {
Steve McKay093d5e72015-09-03 16:49:51 -07001102 checkArgument(input.isMouseEvent());
1103
1104 if (shouldStop(input)) {
Steve McKaybe629f32015-09-04 14:51:16 -07001105 endBandSelect();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001106 return;
1107 }
1108
Steve McKay093d5e72015-09-03 16:49:51 -07001109 // We shouldn't get any events in this method when band select is not active,
1110 // but it turns some guests show up late to the party.
1111 if (!isActive()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001112 return;
1113 }
1114
Steve McKay093d5e72015-09-03 16:49:51 -07001115 mCurrentPosition = input.getOrigin();
1116 mModel.resizeSelection(input.getOrigin());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001117 scrollViewIfNecessary();
1118 resizeBandSelectRectangle();
1119 }
1120
1121 /**
1122 * Starts band select by adding the drawable to the RecyclerView's overlay.
1123 */
Steve McKay093d5e72015-09-03 16:49:51 -07001124 private void startBandSelect(Point origin) {
1125 if (DEBUG) Log.d(TAG, "Starting band select @ " + origin);
1126
1127 mOrigin = origin;
1128 mModelBuilder.run(); // Creates a new selection model.
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001129 mModel.startSelection(mOrigin);
1130 }
1131
1132 /**
1133 * Scrolls the view if necessary.
1134 */
1135 private void scrollViewIfNecessary() {
Steve McKay37550162015-09-08 17:15:25 -07001136 mEnvironment.removeCallback(mViewScroller);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001137 mViewScroller.run();
Steve McKay37550162015-09-08 17:15:25 -07001138 mEnvironment.invalidateView();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001139 }
1140
1141 /**
1142 * Resizes the band select rectangle by using the origin and the current pointer position as
1143 * two opposite corners of the selection.
1144 */
1145 private void resizeBandSelectRectangle() {
Steve McKay093d5e72015-09-03 16:49:51 -07001146 mBounds = new Rect(Math.min(mOrigin.x, mCurrentPosition.x),
1147 Math.min(mOrigin.y, mCurrentPosition.y),
1148 Math.max(mOrigin.x, mCurrentPosition.x),
1149 Math.max(mOrigin.y, mCurrentPosition.y));
Steve McKay37550162015-09-08 17:15:25 -07001150 mEnvironment.showBand(mBounds);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001151 }
1152
1153 /**
1154 * Ends band select by removing the overlay.
1155 */
1156 private void endBandSelect() {
1157 if (DEBUG) Log.d(TAG, "Ending band select.");
Steve McKay093d5e72015-09-03 16:49:51 -07001158
Steve McKay37550162015-09-08 17:15:25 -07001159 mEnvironment.hideBand();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001160 mSelection.applyProvisionalSelection();
1161 mModel.endSelection();
Ben Kwa936a7fc2015-12-10 15:21:18 -08001162 int firstSelected = mModel.getPositionNearestOrigin();
1163 if (!mSelection.contains(mEnvironment.getModelIdFromAdapterPosition(firstSelected))) {
Steve McKay093d5e72015-09-03 16:49:51 -07001164 Log.w(TAG, "First selected by band is NOT in selection!");
1165 // Sadly this is really happening. Need to figure out what's going on.
Ben Kwa936a7fc2015-12-10 15:21:18 -08001166 } else if (firstSelected != NOT_SET) {
1167 // TODO: firstSelected should really be lastSelected, we want to anchor the item
1168 // where the mouse-up occurred.
1169 setSelectionRangeBegin(firstSelected);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001170 }
Steve McKay093d5e72015-09-03 16:49:51 -07001171
1172 mModel = null;
1173 mOrigin = null;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001174 }
1175
1176 @Override
Ben Kwad72a1da2015-12-01 19:56:57 -08001177 public void onSelectionChanged(Set<String> updatedSelection) {
1178 Map<String, Boolean> delta = mSelection.setProvisionalSelection(updatedSelection);
1179 for (Map.Entry<String, Boolean> entry: delta.entrySet()) {
1180 notifyItemStateChanged(entry.getKey(), entry.getValue());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001181 }
1182 notifySelectionChanged();
1183 }
1184
1185 private class ViewScroller implements Runnable {
1186 /**
1187 * The number of milliseconds of scrolling at which scroll speed continues to increase.
1188 * At first, the scroll starts slowly; then, the rate of scrolling increases until it
1189 * reaches its maximum value at after this many milliseconds.
1190 */
1191 private static final long SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
1192
1193 @Override
1194 public void run() {
1195 // Compute the number of pixels the pointer's y-coordinate is past the view.
1196 // Negative values mean the pointer is at or before the top of the view, and
1197 // positive values mean that the pointer is at or after the bottom of the view. Note
1198 // that one additional pixel is added here so that the view still scrolls when the
1199 // pointer is exactly at the top or bottom.
1200 int pixelsPastView = 0;
Steve McKay093d5e72015-09-03 16:49:51 -07001201 if (mCurrentPosition.y <= 0) {
1202 pixelsPastView = mCurrentPosition.y - 1;
Steve McKay37550162015-09-08 17:15:25 -07001203 } else if (mCurrentPosition.y >= mEnvironment.getHeight() - 1) {
1204 pixelsPastView = mCurrentPosition.y - mEnvironment.getHeight() + 1;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001205 }
1206
Steve McKay093d5e72015-09-03 16:49:51 -07001207 if (!isActive() || pixelsPastView == 0) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001208 // If band selection is inactive, or if it is active but not at the edge of the
1209 // view, no scrolling is necessary.
1210 mScrollStartTime = NOT_SET;
1211 return;
1212 }
1213
1214 if (mScrollStartTime == NOT_SET) {
1215 // If the pointer was previously not at the edge of the view but now is, set the
1216 // start time for the scroll.
1217 mScrollStartTime = System.currentTimeMillis();
1218 }
1219
1220 // Compute the number of pixels to scroll, and scroll that many pixels.
1221 final int numPixels = computeScrollDistance(
1222 pixelsPastView, System.currentTimeMillis() - mScrollStartTime);
Steve McKay37550162015-09-08 17:15:25 -07001223 mEnvironment.scrollBy(numPixels);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001224
Steve McKay37550162015-09-08 17:15:25 -07001225 mEnvironment.removeCallback(mViewScroller);
1226 mEnvironment.runAtNextFrame(this);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001227 }
1228
1229 /**
1230 * Computes the number of pixels to scroll based on how far the pointer is past the end
1231 * of the view and how long it has been there. Roughly based on ItemTouchHelper's
1232 * algorithm for computing the number of pixels to scroll when an item is dragged to the
1233 * end of a {@link RecyclerView}.
1234 * @param pixelsPastView
1235 * @param scrollDuration
1236 * @return
1237 */
1238 private int computeScrollDistance(int pixelsPastView, long scrollDuration) {
Steve McKay37550162015-09-08 17:15:25 -07001239 final int maxScrollStep = mEnvironment.getHeight();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001240 final int direction = (int) Math.signum(pixelsPastView);
1241 final int absPastView = Math.abs(pixelsPastView);
1242
1243 // Calculate the ratio of how far out of the view the pointer currently resides to
1244 // the entire height of the view.
1245 final float outOfBoundsRatio = Math.min(
Steve McKay37550162015-09-08 17:15:25 -07001246 1.0f, (float) absPastView / mEnvironment.getHeight());
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001247 // Interpolate this ratio and use it to compute the maximum scroll that should be
1248 // possible for this step.
1249 final float cappedScrollStep =
1250 direction * maxScrollStep * smoothOutOfBoundsRatio(outOfBoundsRatio);
1251
1252 // Likewise, calculate the ratio of the time spent in the scroll to the limit.
1253 final float timeRatio = Math.min(
1254 1.0f, (float) scrollDuration / SCROLL_ACCELERATION_LIMIT_TIME_MS);
1255 // Interpolate this ratio and use it to compute the final number of pixels to
1256 // scroll.
1257 final int numPixels = (int) (cappedScrollStep * smoothTimeRatio(timeRatio));
1258
1259 // If the final number of pixels to scroll ends up being 0, the view should still
1260 // scroll at least one pixel.
1261 return numPixels != 0 ? numPixels : direction;
1262 }
1263
1264 /**
1265 * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
1266 * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
1267 * drags that are at the edge or barely past the edge of the view still cause sufficient
1268 * scrolling. The equation y=(x-1)^5+1 is used, but this could also be tweaked if
1269 * needed.
1270 * @param ratio A ratio which is in the range [0, 1].
1271 * @return A "smoothed" value, also in the range [0, 1].
1272 */
1273 private float smoothOutOfBoundsRatio(float ratio) {
1274 return (float) Math.pow(ratio - 1.0f, 5) + 1.0f;
1275 }
1276
1277 /**
1278 * Interpolates the given time ratio on a curve which starts at (0,0) and ends at (1,1)
1279 * and stays close to 0 for most input values except those very close to 1. This ensures
1280 * that scrolls start out very slowly but speed up drastically after the scroll has been
1281 * in progress close to SCROLL_ACCELERATION_LIMIT_TIME_MS. The equation y=x^5 is used,
1282 * but this could also be tweaked if needed.
1283 * @param ratio A ratio which is in the range [0, 1].
1284 * @return A "smoothed" value, also in the range [0, 1].
1285 */
1286 private float smoothTimeRatio(float ratio) {
1287 return (float) Math.pow(ratio, 5);
1288 }
1289 };
Kyle Horimoto237b7402015-08-25 17:12:42 -07001290
1291 @Override
1292 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
Steve McKay093d5e72015-09-03 16:49:51 -07001293 if (!isActive()) {
Kyle Horimoto237b7402015-08-25 17:12:42 -07001294 return;
1295 }
1296
1297 // Adjust the y-coordinate of the origin the opposite number of pixels so that the
1298 // origin remains in the same place relative to the view's items.
1299 mOrigin.y -= dy;
1300 resizeBandSelectRectangle();
1301 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001302 }
1303
1304 /**
1305 * Provides a band selection item model for views within a RecyclerView. This class queries the
1306 * RecyclerView to determine where its items are placed; then, once band selection is underway,
1307 * it alerts listeners of which items are covered by the selections.
1308 */
Steve McKay37550162015-09-08 17:15:25 -07001309 public static final class GridModel extends RecyclerView.OnScrollListener {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001310
1311 public static final int NOT_SET = -1;
1312
1313 // Enum values used to determine the corner at which the origin is located within the
1314 private static final int UPPER = 0x00;
1315 private static final int LOWER = 0x01;
1316 private static final int LEFT = 0x00;
1317 private static final int RIGHT = 0x02;
1318 private static final int UPPER_LEFT = UPPER | LEFT;
1319 private static final int UPPER_RIGHT = UPPER | RIGHT;
1320 private static final int LOWER_LEFT = LOWER | LEFT;
1321 private static final int LOWER_RIGHT = LOWER | RIGHT;
1322
Ben Kwaa017bc12015-10-07 14:15:12 -07001323 private final SelectionEnvironment mHelper;
Steve McKay37550162015-09-08 17:15:25 -07001324 private final List<OnSelectionChangedListener> mOnSelectionChangedListeners =
1325 new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001326
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001327 // Map from the x-value of the left side of a SparseBooleanArray of adapter positions, keyed
1328 // by their y-offset. For example, if the first column of the view starts at an x-value of 5,
1329 // mColumns.get(5) would return an array of positions in that column. Within that array, the
1330 // value for key y is the adapter position for the item whose y-offset is y.
Ben Kwa936a7fc2015-12-10 15:21:18 -08001331 private final SparseArray<SparseIntArray> mColumns = new SparseArray<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001332
Steve McKay37550162015-09-08 17:15:25 -07001333 // List of limits along the x-axis (columns).
1334 // This list is sorted from furthest left to furthest right.
1335 private final List<Limits> mColumnBounds = new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001336
Steve McKay37550162015-09-08 17:15:25 -07001337 // List of limits along the y-axis (rows). Note that this list only contains items which
1338 // have been in the viewport.
1339 private final List<Limits> mRowBounds = new ArrayList<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001340
1341 // The adapter positions which have been recorded so far.
Ben Kwa936a7fc2015-12-10 15:21:18 -08001342 private final SparseBooleanArray mKnownPositions = new SparseBooleanArray();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001343
1344 // Array passed to registered OnSelectionChangedListeners. One array is created and reused
1345 // throughout the lifetime of the object.
Ben Kwad72a1da2015-12-01 19:56:57 -08001346 private final Set<String> mSelection = new HashSet<>();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001347
1348 // The current pointer (in absolute positioning from the top of the view).
1349 private Point mPointer = null;
1350
1351 // The bounds of the band selection.
1352 private RelativePoint mRelativeOrigin;
1353 private RelativePoint mRelativePointer;
1354
1355 private boolean mIsActive;
1356
1357 // Tracks where the band select originated from. This is used to determine where selections
1358 // should expand from when Shift+click is used.
Ben Kwa936a7fc2015-12-10 15:21:18 -08001359 private int mPositionNearestOrigin = NOT_SET;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001360
Ben Kwaa017bc12015-10-07 14:15:12 -07001361 GridModel(SelectionEnvironment helper) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001362 mHelper = helper;
1363 mHelper.addOnScrollListener(this);
1364 }
1365
1366 /**
1367 * Stops listening to the view's scrolls. Call this function before discarding a
1368 * BandSelecModel object to prevent memory leaks.
1369 */
1370 void stopListening() {
1371 mHelper.removeOnScrollListener(this);
1372 }
1373
1374 /**
1375 * Start a band select operation at the given point.
1376 * @param relativeOrigin The origin of the band select operation, relative to the viewport.
1377 * For example, if the view is scrolled to the bottom, the top-left of the viewport
1378 * would have a relative origin of (0, 0), even though its absolute point has a higher
1379 * y-value.
1380 */
1381 void startSelection(Point relativeOrigin) {
1382 mIsActive = true;
1383 mPointer = mHelper.createAbsolutePoint(relativeOrigin);
1384
1385 recordVisibleChildren();
1386 mRelativeOrigin = new RelativePoint(mPointer);
1387 mRelativePointer = new RelativePoint(mPointer);
1388 computeCurrentSelection();
1389 notifyListeners();
1390 }
1391
1392 /**
1393 * Resizes the selection by adjusting the pointer (i.e., the corner of the selection
1394 * opposite the origin.
1395 * @param relativePointer The pointer (opposite of the origin) of the band select operation,
1396 * relative to the viewport. For example, if the view is scrolled to the bottom, the
1397 * top-left of the viewport would have a relative origin of (0, 0), even though its
1398 * absolute point has a higher y-value.
1399 */
1400 void resizeSelection(Point relativePointer) {
1401 mPointer = mHelper.createAbsolutePoint(relativePointer);
1402 updateModel();
1403 }
1404
1405 /**
1406 * Ends the band selection.
1407 */
1408 void endSelection() {
1409 mIsActive = false;
1410 }
1411
1412 /**
1413 * @return The adapter position for the item nearest the origin corresponding to the latest
1414 * band select operation, or NOT_SET if the selection did not cover any items.
1415 */
Ben Kwa936a7fc2015-12-10 15:21:18 -08001416 int getPositionNearestOrigin() {
1417 return mPositionNearestOrigin;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001418 }
1419
1420 @Override
1421 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
1422 if (!mIsActive) {
1423 return;
1424 }
1425
1426 mPointer.x += dx;
1427 mPointer.y += dy;
1428 recordVisibleChildren();
1429 updateModel();
1430 }
1431
1432 /**
1433 * Queries the view for all children and records their location metadata.
1434 */
1435 private void recordVisibleChildren() {
1436 for (int i = 0; i < mHelper.getVisibleChildCount(); i++) {
Ben Kwa936a7fc2015-12-10 15:21:18 -08001437 int adapterPosition = mHelper.getAdapterPositionAt(i);
1438 if (!mKnownPositions.get(adapterPosition)) {
1439 mKnownPositions.put(adapterPosition, true);
1440 recordItemData(mHelper.getAbsoluteRectForChildViewAt(i), adapterPosition);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001441 }
1442 }
1443 }
1444
1445 /**
1446 * Updates the limits lists and column map with the given item metadata.
1447 * @param absoluteChildRect The absolute rectangle for the child view being processed.
1448 * @param adapterPosition The position of the child view being processed.
1449 */
Ben Kwa936a7fc2015-12-10 15:21:18 -08001450 private void recordItemData(Rect absoluteChildRect, int adapterPosition) {
Steve McKay37550162015-09-08 17:15:25 -07001451 if (mColumnBounds.size() != mHelper.getColumnCount()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001452 // If not all x-limits have been recorded, record this one.
1453 recordLimits(
Steve McKay37550162015-09-08 17:15:25 -07001454 mColumnBounds, new Limits(absoluteChildRect.left, absoluteChildRect.right));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001455 }
1456
Steve McKay37550162015-09-08 17:15:25 -07001457 if (mRowBounds.size() != mHelper.getRowCount()) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001458 // If not all y-limits have been recorded, record this one.
1459 recordLimits(
Steve McKay37550162015-09-08 17:15:25 -07001460 mRowBounds, new Limits(absoluteChildRect.top, absoluteChildRect.bottom));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001461 }
1462
Ben Kwa936a7fc2015-12-10 15:21:18 -08001463 SparseIntArray columnList = mColumns.get(absoluteChildRect.left);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001464 if (columnList == null) {
Ben Kwa936a7fc2015-12-10 15:21:18 -08001465 columnList = new SparseIntArray();
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001466 mColumns.put(absoluteChildRect.left, columnList);
1467 }
Ben Kwa936a7fc2015-12-10 15:21:18 -08001468 columnList.put(absoluteChildRect.top, adapterPosition);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001469 }
1470
1471 /**
1472 * Ensures limits exists within the sorted list limitsList, and adds it to the list if it
1473 * does not exist.
1474 */
1475 private void recordLimits(List<Limits> limitsList, Limits limits) {
1476 int index = Collections.binarySearch(limitsList, limits);
1477 if (index < 0) {
1478 limitsList.add(~index, limits);
1479 }
1480 }
1481
1482 /**
1483 * Handles a moved pointer; this function determines whether the pointer movement resulted
1484 * in a selection change and, if it has, notifies listeners of this change.
1485 */
1486 private void updateModel() {
1487 RelativePoint old = mRelativePointer;
1488 mRelativePointer = new RelativePoint(mPointer);
1489 if (old != null && mRelativePointer.equals(old)) {
1490 return;
1491 }
1492
1493 computeCurrentSelection();
1494 notifyListeners();
1495 }
1496
1497 /**
1498 * Computes the currently-selected items.
1499 */
1500 private void computeCurrentSelection() {
Steve McKay37550162015-09-08 17:15:25 -07001501 if (areItemsCoveredByBand(mRelativePointer, mRelativeOrigin)) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001502 updateSelection(computeBounds());
1503 } else {
1504 mSelection.clear();
Ben Kwa936a7fc2015-12-10 15:21:18 -08001505 mPositionNearestOrigin = NOT_SET;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001506 }
1507 }
1508
1509 /**
1510 * Notifies all listeners of a selection change. Note that this function simply passes
1511 * mSelection, so computeCurrentSelection() should be called before this
1512 * function.
1513 */
1514 private void notifyListeners() {
1515 for (OnSelectionChangedListener listener : mOnSelectionChangedListeners) {
1516 listener.onSelectionChanged(mSelection);
1517 }
1518 }
1519
1520 /**
1521 * @param rect Rectangle including all covered items.
1522 */
1523 private void updateSelection(Rect rect) {
1524 int columnStartIndex =
Steve McKay37550162015-09-08 17:15:25 -07001525 Collections.binarySearch(mColumnBounds, new Limits(rect.left, rect.left));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001526 checkState(columnStartIndex >= 0);
1527 int columnEndIndex = columnStartIndex;
1528
Steve McKay37550162015-09-08 17:15:25 -07001529 for (int i = columnStartIndex; i < mColumnBounds.size()
1530 && mColumnBounds.get(i).lowerLimit <= rect.right; i++) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001531 columnEndIndex = i;
1532 }
1533
Ben Kwa936a7fc2015-12-10 15:21:18 -08001534 SparseIntArray firstColumn =
Steve McKay37550162015-09-08 17:15:25 -07001535 mColumns.get(mColumnBounds.get(columnStartIndex).lowerLimit);
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001536 int rowStartIndex = firstColumn.indexOfKey(rect.top);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001537 if (rowStartIndex < 0) {
Ben Kwa936a7fc2015-12-10 15:21:18 -08001538 mPositionNearestOrigin = NOT_SET;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001539 return;
1540 }
1541
1542 int rowEndIndex = rowStartIndex;
1543 for (int i = rowStartIndex;
Kyle Horimotod3b3d3d2015-08-25 21:03:38 -07001544 i < firstColumn.size() && firstColumn.keyAt(i) <= rect.bottom; i++) {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001545 rowEndIndex = i;
1546 }
1547
1548 updateSelection(columnStartIndex, columnEndIndex, rowStartIndex, rowEndIndex);
1549 }
1550
1551 /**
1552 * Computes the selection given the previously-computed start- and end-indices for each
1553 * row and column.
1554 */
1555 private void updateSelection(
1556 int columnStartIndex, int columnEndIndex, int rowStartIndex, int rowEndIndex) {
1557 mSelection.clear();
1558 for (int column = columnStartIndex; column <= columnEndIndex; column++) {
Ben Kwa936a7fc2015-12-10 15:21:18 -08001559 SparseIntArray items = mColumns.get(mColumnBounds.get(column).lowerLimit);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001560 for (int row = rowStartIndex; row <= rowEndIndex; row++) {
Ben Kwa936a7fc2015-12-10 15:21:18 -08001561 int position = items.get(items.keyAt(row));
1562 String id = mHelper.getModelIdFromAdapterPosition(position);
1563 if (id != null) {
1564 // The adapter inserts items for UI layout purposes that aren't associated
1565 // with files. Those will have a null model ID. Don't select them.
1566 mSelection.add(id);
1567 }
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001568 if (isPossiblePositionNearestOrigin(column, columnStartIndex, columnEndIndex,
1569 row, rowStartIndex, rowEndIndex)) {
1570 // If this is the position nearest the origin, record it now so that it
1571 // can be returned by endSelection() later.
Ben Kwa936a7fc2015-12-10 15:21:18 -08001572 mPositionNearestOrigin = position;
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001573 }
1574 }
1575 }
1576 }
1577
1578 /**
1579 * @return Returns true if the position is the nearest to the origin, or, in the case of the
1580 * lower-right corner, whether it is possible that the position is the nearest to the
1581 * origin. See comment below for reasoning for this special case.
1582 */
1583 private boolean isPossiblePositionNearestOrigin(int columnIndex, int columnStartIndex,
1584 int columnEndIndex, int rowIndex, int rowStartIndex, int rowEndIndex) {
1585 int corner = computeCornerNearestOrigin();
1586 switch (corner) {
1587 case UPPER_LEFT:
1588 return columnIndex == columnStartIndex && rowIndex == rowStartIndex;
1589 case UPPER_RIGHT:
1590 return columnIndex == columnEndIndex && rowIndex == rowStartIndex;
1591 case LOWER_LEFT:
1592 return columnIndex == columnStartIndex && rowIndex == rowEndIndex;
1593 case LOWER_RIGHT:
1594 // Note that in some cases, the last row will not have as many items as there
1595 // are columns (e.g., if there are 4 items and 3 columns, the second row will
1596 // only have one item in the first column). This function is invoked for each
1597 // position from left to right, so return true for any position in the bottom
1598 // row and only the right-most position in the bottom row will be recorded.
1599 return rowIndex == rowEndIndex;
1600 default:
1601 throw new RuntimeException("Invalid corner type.");
1602 }
1603 }
1604
1605 /**
1606 * Listener for changes in which items have been band selected.
1607 */
1608 static interface OnSelectionChangedListener {
Ben Kwad72a1da2015-12-01 19:56:57 -08001609 public void onSelectionChanged(Set<String> updatedSelection);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001610 }
1611
1612 void addOnSelectionChangedListener(OnSelectionChangedListener listener) {
1613 mOnSelectionChangedListeners.add(listener);
1614 }
1615
1616 void removeOnSelectionChangedListener(OnSelectionChangedListener listener) {
1617 mOnSelectionChangedListeners.remove(listener);
1618 }
1619
1620 /**
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001621 * Limits of a view item. For example, if an item's left side is at x-value 5 and its right side
1622 * is at x-value 10, the limits would be from 5 to 10. Used to record the left- and right sides
1623 * of item columns and the top- and bottom sides of item rows so that it can be determined
1624 * whether the pointer is located within the bounds of an item.
1625 */
Steve McKay37550162015-09-08 17:15:25 -07001626 private static class Limits implements Comparable<Limits> {
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001627 int lowerLimit;
1628 int upperLimit;
1629
1630 Limits(int lowerLimit, int upperLimit) {
1631 this.lowerLimit = lowerLimit;
1632 this.upperLimit = upperLimit;
1633 }
1634
1635 @Override
1636 public int compareTo(Limits other) {
1637 return lowerLimit - other.lowerLimit;
1638 }
1639
1640 @Override
1641 public boolean equals(Object other) {
1642 if (!(other instanceof Limits)) {
1643 return false;
1644 }
1645
1646 return ((Limits) other).lowerLimit == lowerLimit &&
1647 ((Limits) other).upperLimit == upperLimit;
1648 }
1649 }
1650
1651 /**
1652 * The location of a coordinate relative to items. This class represents a general area of the
1653 * view as it relates to band selection rather than an explicit point. For example, two
1654 * different points within an item are considered to have the same "location" because band
1655 * selection originating within the item would select the same items no matter which point
1656 * was used. Same goes for points between items as well as those at the very beginning or end
1657 * of the view.
1658 *
1659 * Tracking a coordinate (e.g., an x-value) as a CoordinateLocation instead of as an int has the
1660 * advantage of tying the value to the Limits of items along that axis. This allows easy
1661 * selection of items within those Limits as opposed to a search through every item to see if a
1662 * given coordinate value falls within those Limits.
1663 */
Steve McKay37550162015-09-08 17:15:25 -07001664 private static class RelativeCoordinate
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001665 implements Comparable<RelativeCoordinate> {
1666 /**
1667 * Location describing points after the last known item.
1668 */
1669 static final int AFTER_LAST_ITEM = 0;
1670
1671 /**
1672 * Location describing points before the first known item.
1673 */
1674 static final int BEFORE_FIRST_ITEM = 1;
1675
1676 /**
1677 * Location describing points between two items.
1678 */
1679 static final int BETWEEN_TWO_ITEMS = 2;
1680
1681 /**
1682 * Location describing points within the limits of one item.
1683 */
1684 static final int WITHIN_LIMITS = 3;
1685
1686 /**
1687 * The type of this coordinate, which is one of AFTER_LAST_ITEM, BEFORE_FIRST_ITEM,
1688 * BETWEEN_TWO_ITEMS, or WITHIN_LIMITS.
1689 */
1690 final int type;
1691
1692 /**
1693 * The limits before the coordinate; only populated when type == WITHIN_LIMITS or type ==
1694 * BETWEEN_TWO_ITEMS.
1695 */
1696 Limits limitsBeforeCoordinate;
1697
1698 /**
1699 * The limits after the coordinate; only populated when type == BETWEEN_TWO_ITEMS.
1700 */
1701 Limits limitsAfterCoordinate;
1702
1703 // Limits of the first known item; only populated when type == BEFORE_FIRST_ITEM.
1704 Limits mFirstKnownItem;
1705 // Limits of the last known item; only populated when type == AFTER_LAST_ITEM.
1706 Limits mLastKnownItem;
1707
1708 /**
1709 * @param limitsList The sorted limits list for the coordinate type. If this
1710 * CoordinateLocation is an x-value, mXLimitsList should be passed; otherwise,
1711 * mYLimitsList should be pased.
1712 * @param value The coordinate value.
1713 */
1714 RelativeCoordinate(List<Limits> limitsList, int value) {
Steve McKay37550162015-09-08 17:15:25 -07001715 int index = Collections.binarySearch(limitsList, new Limits(value, value));
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001716
1717 if (index >= 0) {
1718 this.type = WITHIN_LIMITS;
1719 this.limitsBeforeCoordinate = limitsList.get(index);
1720 } else if (~index == 0) {
1721 this.type = BEFORE_FIRST_ITEM;
1722 this.mFirstKnownItem = limitsList.get(0);
1723 } else if (~index == limitsList.size()) {
1724 Limits lastLimits = limitsList.get(limitsList.size() - 1);
1725 if (lastLimits.lowerLimit <= value && value <= lastLimits.upperLimit) {
1726 this.type = WITHIN_LIMITS;
1727 this.limitsBeforeCoordinate = lastLimits;
1728 } else {
1729 this.type = AFTER_LAST_ITEM;
1730 this.mLastKnownItem = lastLimits;
1731 }
1732 } else {
1733 Limits limitsBeforeIndex = limitsList.get(~index - 1);
1734 if (limitsBeforeIndex.lowerLimit <= value && value <= limitsBeforeIndex.upperLimit) {
1735 this.type = WITHIN_LIMITS;
1736 this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1737 } else {
1738 this.type = BETWEEN_TWO_ITEMS;
1739 this.limitsBeforeCoordinate = limitsList.get(~index - 1);
1740 this.limitsAfterCoordinate = limitsList.get(~index);
1741 }
1742 }
1743 }
1744
1745 int toComparisonValue() {
1746 if (type == BEFORE_FIRST_ITEM) {
1747 return mFirstKnownItem.lowerLimit - 1;
1748 } else if (type == AFTER_LAST_ITEM) {
1749 return mLastKnownItem.upperLimit + 1;
1750 } else if (type == BETWEEN_TWO_ITEMS) {
1751 return limitsBeforeCoordinate.upperLimit + 1;
1752 } else {
1753 return limitsBeforeCoordinate.lowerLimit;
1754 }
1755 }
1756
1757 @Override
1758 public boolean equals(Object other) {
1759 if (!(other instanceof RelativeCoordinate)) {
1760 return false;
1761 }
1762
1763 RelativeCoordinate otherCoordinate = (RelativeCoordinate) other;
1764 return toComparisonValue() == otherCoordinate.toComparisonValue();
1765 }
1766
1767 @Override
1768 public int compareTo(RelativeCoordinate other) {
1769 return toComparisonValue() - other.toComparisonValue();
1770 }
1771 }
1772
1773 /**
1774 * The location of a point relative to the Limits of nearby items; consists of both an x- and
1775 * y-RelativeCoordinateLocation.
1776 */
1777 private class RelativePoint {
1778 final RelativeCoordinate xLocation;
1779 final RelativeCoordinate yLocation;
1780
1781 RelativePoint(Point point) {
Steve McKay37550162015-09-08 17:15:25 -07001782 this.xLocation = new RelativeCoordinate(mColumnBounds, point.x);
1783 this.yLocation = new RelativeCoordinate(mRowBounds, point.y);
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001784 }
1785
1786 @Override
1787 public boolean equals(Object other) {
1788 if (!(other instanceof RelativePoint)) {
1789 return false;
1790 }
1791
1792 RelativePoint otherPoint = (RelativePoint) other;
1793 return xLocation.equals(otherPoint.xLocation) && yLocation.equals(otherPoint.yLocation);
1794 }
1795 }
1796
1797 /**
1798 * Generates a rectangle which contains the items selected by the pointer and origin.
1799 * @return The rectangle, or null if no items were selected.
1800 */
1801 private Rect computeBounds() {
1802 Rect rect = new Rect();
1803 rect.left = getCoordinateValue(
1804 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
Steve McKay37550162015-09-08 17:15:25 -07001805 mColumnBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001806 true);
1807 rect.right = getCoordinateValue(
1808 max(mRelativeOrigin.xLocation, mRelativePointer.xLocation),
Steve McKay37550162015-09-08 17:15:25 -07001809 mColumnBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001810 false);
1811 rect.top = getCoordinateValue(
1812 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
Steve McKay37550162015-09-08 17:15:25 -07001813 mRowBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001814 true);
1815 rect.bottom = getCoordinateValue(
1816 max(mRelativeOrigin.yLocation, mRelativePointer.yLocation),
Steve McKay37550162015-09-08 17:15:25 -07001817 mRowBounds,
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001818 false);
1819 return rect;
1820 }
1821
1822 /**
1823 * Computes the corner of the selection nearest the origin.
1824 * @return
1825 */
1826 private int computeCornerNearestOrigin() {
1827 int cornerValue = 0;
1828
1829 if (mRelativeOrigin.yLocation ==
1830 min(mRelativeOrigin.yLocation, mRelativePointer.yLocation)) {
1831 cornerValue |= UPPER;
1832 } else {
1833 cornerValue |= LOWER;
1834 }
1835
1836 if (mRelativeOrigin.xLocation ==
1837 min(mRelativeOrigin.xLocation, mRelativePointer.xLocation)) {
1838 cornerValue |= LEFT;
1839 } else {
1840 cornerValue |= RIGHT;
1841 }
1842
1843 return cornerValue;
1844 }
1845
1846 private RelativeCoordinate min(RelativeCoordinate first, RelativeCoordinate second) {
1847 return first.compareTo(second) < 0 ? first : second;
1848 }
1849
1850 private RelativeCoordinate max(RelativeCoordinate first, RelativeCoordinate second) {
1851 return first.compareTo(second) > 0 ? first : second;
1852 }
1853
1854 /**
1855 * @return The absolute coordinate (i.e., the x- or y-value) of the given relative
1856 * coordinate.
1857 */
1858 private int getCoordinateValue(RelativeCoordinate coordinate,
1859 List<Limits> limitsList, boolean isStartOfRange) {
1860 switch (coordinate.type) {
1861 case RelativeCoordinate.BEFORE_FIRST_ITEM:
1862 return limitsList.get(0).lowerLimit;
1863 case RelativeCoordinate.AFTER_LAST_ITEM:
1864 return limitsList.get(limitsList.size() - 1).upperLimit;
1865 case RelativeCoordinate.BETWEEN_TWO_ITEMS:
1866 if (isStartOfRange) {
1867 return coordinate.limitsAfterCoordinate.lowerLimit;
1868 } else {
1869 return coordinate.limitsBeforeCoordinate.upperLimit;
1870 }
1871 case RelativeCoordinate.WITHIN_LIMITS:
1872 return coordinate.limitsBeforeCoordinate.lowerLimit;
1873 }
1874
1875 throw new RuntimeException("Invalid coordinate value.");
1876 }
1877
Steve McKay37550162015-09-08 17:15:25 -07001878 private boolean areItemsCoveredByBand(
Kyle Horimoto62a7fd02015-08-18 13:25:29 -07001879 RelativePoint first, RelativePoint second) {
1880 return doesCoordinateLocationCoverItems(first.xLocation, second.xLocation) &&
1881 doesCoordinateLocationCoverItems(first.yLocation, second.yLocation);
1882 }
1883
1884 private boolean doesCoordinateLocationCoverItems(
1885 RelativeCoordinate pointerCoordinate,
1886 RelativeCoordinate originCoordinate) {
1887 if (pointerCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM &&
1888 originCoordinate.type == RelativeCoordinate.BEFORE_FIRST_ITEM) {
1889 return false;
1890 }
1891
1892 if (pointerCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM &&
1893 originCoordinate.type == RelativeCoordinate.AFTER_LAST_ITEM) {
1894 return false;
1895 }
1896
1897 if (pointerCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
1898 originCoordinate.type == RelativeCoordinate.BETWEEN_TWO_ITEMS &&
1899 pointerCoordinate.limitsBeforeCoordinate.equals(
1900 originCoordinate.limitsBeforeCoordinate) &&
1901 pointerCoordinate.limitsAfterCoordinate.equals(
1902 originCoordinate.limitsAfterCoordinate)) {
1903 return false;
1904 }
1905
1906 return true;
1907 }
1908 }
Ben Kwaa017bc12015-10-07 14:15:12 -07001909
1910 // TODO: Might have to move this to a more global level. e.g. What should happen if the
1911 // user taps a file and then presses shift-down? Currently the RecyclerView never even sees
1912 // the key event. Perhaps install a global key handler to catch those events while in
1913 // selection mode?
1914 @Override
1915 public boolean onKey(View view, int keyCode, KeyEvent event) {
1916 // Listen for key-down events. This allows the handler to respond appropriately when
1917 // the user holds down the arrow keys for navigation.
1918 if (event.getAction() != KeyEvent.ACTION_DOWN) {
1919 return false;
1920 }
1921
Steve McKaye0cba922015-11-11 16:26:59 +09001922 // Here we unpack information from the event and pass it to an more
1923 // easily tested method....basically eliminating the need to synthesize
1924 // events and views and so on in our tests.
1925 int position = findTargetPosition(view, keyCode);
1926 if (position == RecyclerView.NO_POSITION) {
1927 // If there is no valid navigation target, don't handle the keypress.
1928 return false;
1929 }
1930
Ben Kwad72a1da2015-12-01 19:56:57 -08001931 return attemptChangeFocus(position, event.isShiftPressed());
Steve McKaye0cba922015-11-11 16:26:59 +09001932 }
1933
Ben Kwad72a1da2015-12-01 19:56:57 -08001934 /**
1935 * @param targetPosition The adapter position to focus.
1936 * @param extendSelection
1937 */
Steve McKaye0cba922015-11-11 16:26:59 +09001938 @VisibleForTesting
Ben Kwad72a1da2015-12-01 19:56:57 -08001939 boolean attemptChangeFocus(int targetPosition, boolean extendSelection) {
Steve McKaye0cba922015-11-11 16:26:59 +09001940 // Focus the new file.
1941 mEnvironment.focusItem(targetPosition);
1942
Ben Kwad72a1da2015-12-01 19:56:57 -08001943 if (extendSelection) {
Steve McKaye0cba922015-11-11 16:26:59 +09001944 if (!hasSelection()) {
1945 // If there is no selection, start a selection when the user presses shift-arrow.
1946 toggleSelection(targetPosition);
Ben Kwad72a1da2015-12-01 19:56:57 -08001947 setSelectionRangeBegin(targetPosition);
Steve McKaye0cba922015-11-11 16:26:59 +09001948 } else if (!mSingleSelect) {
1949 mRanger.snapSelection(targetPosition);
1950 notifySelectionChanged();
1951 } else {
1952 // We're in single select and have an existing selection.
1953 // Our best guess as to what the user would expect is to advance the selection.
1954 clearSelection();
1955 toggleSelection(targetPosition);
1956 }
1957 }
1958
1959 return true;
1960 }
1961
1962 /**
1963 * Returns the adapter position that the key combo is targeted at.
1964 */
1965 private int findTargetPosition(View view, int keyCode) {
1966 int position = RecyclerView.NO_POSITION;
Ben Kwaa017bc12015-10-07 14:15:12 -07001967 if (keyCode == KeyEvent.KEYCODE_MOVE_HOME) {
Steve McKaye0cba922015-11-11 16:26:59 +09001968 position = 0;
Ben Kwaa017bc12015-10-07 14:15:12 -07001969 } else if (keyCode == KeyEvent.KEYCODE_MOVE_END) {
Ben Kwad72a1da2015-12-01 19:56:57 -08001970 position = mEnvironment.getItemCount() - 1;
Ben Kwaa017bc12015-10-07 14:15:12 -07001971 } else {
1972 // Find a navigation target based on the arrow key that the user pressed. Ignore
1973 // navigation targets that aren't items in the recycler view.
1974 int searchDir = -1;
1975 switch (keyCode) {
1976 case KeyEvent.KEYCODE_DPAD_UP:
1977 searchDir = View.FOCUS_UP;
1978 break;
1979 case KeyEvent.KEYCODE_DPAD_DOWN:
1980 searchDir = View.FOCUS_DOWN;
1981 break;
1982 case KeyEvent.KEYCODE_DPAD_LEFT:
1983 searchDir = View.FOCUS_LEFT;
1984 break;
1985 case KeyEvent.KEYCODE_DPAD_RIGHT:
1986 searchDir = View.FOCUS_RIGHT;
1987 break;
1988 }
1989 if (searchDir != -1) {
1990 View targetView = view.focusSearch(searchDir);
Ben Kwa98bb6cf2015-10-14 08:00:27 -07001991 // TargetView can be null, for example, if the user pressed <down> at the bottom of
1992 // the list.
1993 if (targetView != null) {
Steve McKaye0cba922015-11-11 16:26:59 +09001994 position = mEnvironment.getAdapterPositionForChildView(targetView);
Ben Kwa98bb6cf2015-10-14 08:00:27 -07001995 }
Ben Kwaa017bc12015-10-07 14:15:12 -07001996 }
1997 }
Steve McKaye0cba922015-11-11 16:26:59 +09001998 return position;
Ben Kwaa017bc12015-10-07 14:15:12 -07001999 }
Steve McKayef280152015-06-11 10:10:49 -07002000}