blob: 39f2e5c52fbbe5633f7b044ac2dcfca0eb127e71 [file] [log] [blame]
Ben Kwa15de7f92016-02-09 11:27:45 -08001/*
2 * Copyright (C) 2016 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
Steve McKay17b761e2016-09-20 17:26:46 -070017package com.android.documentsui;
Ben Kwa15de7f92016-02-09 11:27:45 -080018
Steve McKayd0805062016-09-15 14:30:38 -070019import static com.android.documentsui.base.DocumentInfo.getCursorString;
Ben Kwa472103f2016-02-10 15:48:25 -080020
Ben Lin81afd7f2016-08-22 18:24:03 -070021import android.annotation.ColorRes;
Ben Kwa6fd431e2016-02-23 23:00:01 -080022import android.annotation.Nullable;
Steve McKay5a22a112016-04-12 11:29:10 -070023import android.database.Cursor;
Ben Kwa6fd431e2016-02-23 23:00:01 -080024import android.os.Handler;
25import android.os.Looper;
26import android.os.SystemClock;
Ben Kwa472103f2016-02-10 15:48:25 -080027import android.provider.DocumentsContract.Document;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -080028import android.support.v7.widget.GridLayoutManager;
Ben Kwa15de7f92016-02-09 11:27:45 -080029import android.support.v7.widget.RecyclerView;
Ben Kwa472103f2016-02-10 15:48:25 -080030import android.text.Editable;
31import android.text.Spannable;
32import android.text.method.KeyListener;
33import android.text.method.TextKeyListener;
34import android.text.method.TextKeyListener.Capitalize;
35import android.text.style.BackgroundColorSpan;
Ben Kwa15de7f92016-02-09 11:27:45 -080036import android.util.Log;
37import android.view.KeyEvent;
38import android.view.View;
Ben Kwa472103f2016-02-10 15:48:25 -080039import android.widget.TextView;
Ben Kwa15de7f92016-02-09 11:27:45 -080040
Steve McKay990f76e2016-09-16 12:36:58 -070041import com.android.documentsui.base.EventListener;
Steve McKayd9caa6a2016-09-15 16:36:45 -070042import com.android.documentsui.base.Events;
Steve McKay17b761e2016-09-20 17:26:46 -070043import com.android.documentsui.dirlist.DocumentHolder;
44import com.android.documentsui.dirlist.DocumentsAdapter;
45import com.android.documentsui.dirlist.FocusHandler;
46import com.android.documentsui.dirlist.Model;
Steve McKay990f76e2016-09-16 12:36:58 -070047import com.android.documentsui.dirlist.Model.Update;
Ben Kwa472103f2016-02-10 15:48:25 -080048
49import java.util.ArrayList;
50import java.util.List;
Ben Kwa6fd431e2016-02-23 23:00:01 -080051import java.util.Timer;
52import java.util.TimerTask;
Ben Kwa15de7f92016-02-09 11:27:45 -080053
54/**
55 * A class that handles navigation and focus within the DirectoryFragment.
56 */
Ben Lin81afd7f2016-08-22 18:24:03 -070057public final class FocusManager implements FocusHandler {
Ben Kwa15de7f92016-02-09 11:27:45 -080058 private static final String TAG = "FocusManager";
59
Steve McKay17b761e2016-09-20 17:26:46 -070060 private final Config mConfig = new Config();
61 @Nullable TitleSearchHelper mSearchHelper;
Ben Lin81afd7f2016-08-22 18:24:03 -070062 private @Nullable String mPendingFocusId;
Ben Kwa472103f2016-02-10 15:48:25 -080063
Ben Kwa74e5d412016-02-10 07:46:35 -080064 private int mLastFocusPosition = RecyclerView.NO_POSITION;
65
Steve McKay17b761e2016-09-20 17:26:46 -070066 public FocusManager(@ColorRes int color) {
Ben Lin81afd7f2016-08-22 18:24:03 -070067 mSearchHelper = new TitleSearchHelper(color);
Ben Kwa15de7f92016-02-09 11:27:45 -080068 }
69
Steve McKay74956af2016-06-30 21:03:06 -070070 @Override
Ben Kwa15de7f92016-02-09 11:27:45 -080071 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
Ben Kwa472103f2016-02-10 15:48:25 -080072 // Search helper gets first crack, for doing type-to-focus.
73 if (mSearchHelper.handleKey(doc, keyCode, event)) {
74 return true;
75 }
76
Ben Kwa15de7f92016-02-09 11:27:45 -080077 if (Events.isNavigationKeyCode(keyCode)) {
78 // Find the target item and focus it.
79 int endPos = findTargetPosition(doc.itemView, keyCode, event);
80
81 if (endPos != RecyclerView.NO_POSITION) {
82 focusItem(endPos);
Ben Kwa15de7f92016-02-09 11:27:45 -080083 }
Ben Kwa74e5d412016-02-10 07:46:35 -080084 // Swallow all navigation keystrokes. Otherwise they go to the app's global
85 // key-handler, which will route them back to the DF and cause focus to be reset.
86 return true;
Ben Kwa15de7f92016-02-09 11:27:45 -080087 }
Ben Kwa74e5d412016-02-10 07:46:35 -080088 return false;
89 }
90
91 @Override
92 public void onFocusChange(View v, boolean hasFocus) {
93 // Remember focus events on items.
Steve McKay17b761e2016-09-20 17:26:46 -070094 if (hasFocus && v.getParent() == mConfig.mView) {
95 mLastFocusPosition = mConfig.mView.getChildAdapterPosition(v);
Ben Kwa74e5d412016-02-10 07:46:35 -080096 }
97 }
98
Steve McKay74956af2016-06-30 21:03:06 -070099 @Override
Ben Kwa74e5d412016-02-10 07:46:35 -0800100 public void restoreLastFocus() {
Steve McKay17b761e2016-09-20 17:26:46 -0700101 if (mConfig.mAdapter.getItemCount() == 0) {
Ben Kwa237432e2016-02-24 10:11:10 -0800102 // Nothing to focus.
103 return;
104 }
105
Ben Kwa74e5d412016-02-10 07:46:35 -0800106 if (mLastFocusPosition != RecyclerView.NO_POSITION) {
107 // The system takes care of situations when a view is no longer on screen, etc,
108 focusItem(mLastFocusPosition);
109 } else {
110 // Focus the first visible item
Steve McKay17b761e2016-09-20 17:26:46 -0700111 focusItem(mConfig.mLayout.findFirstVisibleItemPosition());
Ben Kwa74e5d412016-02-10 07:46:35 -0800112 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800113 }
114
Ben Lin81afd7f2016-08-22 18:24:03 -0700115 /*
116 * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and
117 * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}.
118 */
119 @Override
120 public void onLayoutCompleted() {
121 if (mPendingFocusId == null) {
122 return;
123 }
124
Steve McKay17b761e2016-09-20 17:26:46 -0700125 int pos = mConfig.mAdapter.getModelIds().indexOf(mPendingFocusId);
Ben Lin81afd7f2016-08-22 18:24:03 -0700126 if (pos != -1) {
127 focusItem(pos);
128 }
129 mPendingFocusId = null;
130 }
131
132 /*
133 * Attempts to put focus on the document associated with the given modelId. If item does not
134 * exist yet in the layout, this sets a pending modelId to be used when
135 * {@code #applyPendingFocus()} is called next time.
136 */
137 @Override
Steve McKay17b761e2016-09-20 17:26:46 -0700138 public void focusDocument(String modelId) {
139 int pos = mConfig.mAdapter.getModelIds().indexOf(modelId);
140 if (pos != -1 && mConfig.mView.findViewHolderForAdapterPosition(pos) != null) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700141 focusItem(pos);
142 } else {
143 mPendingFocusId = modelId;
144 }
145 }
146
Steve McKay74956af2016-06-30 21:03:06 -0700147 @Override
Ben Kwa9504d762016-02-10 14:01:19 -0800148 public int getFocusPosition() {
149 return mLastFocusPosition;
150 }
151
152 /**
Ben Kwa15de7f92016-02-09 11:27:45 -0800153 * Finds the destination position where the focus should land for a given navigation event.
154 *
155 * @param view The view that received the event.
156 * @param keyCode The key code for the event.
157 * @param event
158 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION.
159 */
160 private int findTargetPosition(View view, int keyCode, KeyEvent event) {
161 switch (keyCode) {
162 case KeyEvent.KEYCODE_MOVE_HOME:
163 return 0;
164 case KeyEvent.KEYCODE_MOVE_END:
Steve McKay17b761e2016-09-20 17:26:46 -0700165 return mConfig.mAdapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800166 case KeyEvent.KEYCODE_PAGE_UP:
167 case KeyEvent.KEYCODE_PAGE_DOWN:
168 return findPagedTargetPosition(view, keyCode, event);
169 }
170
171 // Find a navigation target based on the arrow key that the user pressed.
172 int searchDir = -1;
173 switch (keyCode) {
174 case KeyEvent.KEYCODE_DPAD_UP:
175 searchDir = View.FOCUS_UP;
176 break;
177 case KeyEvent.KEYCODE_DPAD_DOWN:
178 searchDir = View.FOCUS_DOWN;
179 break;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800180 }
181
182 if (inGridMode()) {
Steve McKay17b761e2016-09-20 17:26:46 -0700183 int currentPosition = mConfig.mView.getChildAdapterPosition(view);
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800184 // Left and right arrow keys only work in grid mode.
185 switch (keyCode) {
186 case KeyEvent.KEYCODE_DPAD_LEFT:
187 if (currentPosition > 0) {
188 // Stop backward focus search at the first item, otherwise focus will wrap
189 // around to the last visible item.
190 searchDir = View.FOCUS_BACKWARD;
191 }
192 break;
193 case KeyEvent.KEYCODE_DPAD_RIGHT:
Steve McKay17b761e2016-09-20 17:26:46 -0700194 if (currentPosition < mConfig.mAdapter.getItemCount() - 1) {
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800195 // Stop forward focus search at the last item, otherwise focus will wrap
196 // around to the first visible item.
197 searchDir = View.FOCUS_FORWARD;
198 }
199 break;
200 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800201 }
202
203 if (searchDir != -1) {
Ben Kwa67f06a32016-02-17 14:08:52 -0800204 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable
205 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after
206 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable
207 // off while performing the focus search.
208 // TODO: Revisit this when RV focus issues are resolved.
Steve McKay17b761e2016-09-20 17:26:46 -0700209 mConfig.mView.setFocusable(false);
Ben Kwa15de7f92016-02-09 11:27:45 -0800210 View targetView = view.focusSearch(searchDir);
Steve McKay17b761e2016-09-20 17:26:46 -0700211 mConfig.mView.setFocusable(true);
Ben Kwa15de7f92016-02-09 11:27:45 -0800212 // TargetView can be null, for example, if the user pressed <down> at the bottom
213 // of the list.
214 if (targetView != null) {
215 // Ignore navigation targets that aren't items in the RecyclerView.
Steve McKay17b761e2016-09-20 17:26:46 -0700216 if (targetView.getParent() == mConfig.mView) {
217 return mConfig.mView.getChildAdapterPosition(targetView);
Ben Kwa15de7f92016-02-09 11:27:45 -0800218 }
219 }
220 }
221
222 return RecyclerView.NO_POSITION;
223 }
224
225 /**
226 * Given a PgUp/PgDn event and the current view, find the position of the target view.
227 * This returns:
228 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not
229 * the top- or bottom-most visible item.
230 * <li>The position of an item that is one page's worth of items up (or down) if the current
231 * item is the top- or bottom-most visible item.
232 * <li>The first (or last) item, if paging up (or down) would go past those limits.
233 * @param view The view that received the key event.
234 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN.
235 * @param event
236 * @return The adapter position of the target item.
237 */
238 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) {
Steve McKay17b761e2016-09-20 17:26:46 -0700239 int first = mConfig.mLayout.findFirstVisibleItemPosition();
240 int last = mConfig.mLayout.findLastVisibleItemPosition();
241 int current = mConfig.mView.getChildAdapterPosition(view);
Ben Kwa15de7f92016-02-09 11:27:45 -0800242 int pageSize = last - first + 1;
243
244 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) {
245 if (current > first) {
246 // If the current item isn't the first item, target the first item.
247 return first;
248 } else {
249 // If the current item is the first item, target the item one page up.
250 int target = current - pageSize;
251 return target < 0 ? 0 : target;
252 }
253 }
254
255 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) {
256 if (current < last) {
257 // If the current item isn't the last item, target the last item.
258 return last;
259 } else {
260 // If the current item is the last item, target the item one page down.
261 int target = current + pageSize;
Steve McKay17b761e2016-09-20 17:26:46 -0700262 int max = mConfig.mAdapter.getItemCount() - 1;
Ben Kwa15de7f92016-02-09 11:27:45 -0800263 return target < max ? target : max;
264 }
265 }
266
267 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode);
268 }
269
270 /**
271 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
272 * necessary.
273 *
274 * @param pos
275 */
276 private void focusItem(final int pos) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800277 focusItem(pos, null);
278 }
279
280 /**
281 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if
282 * necessary.
283 *
284 * @param pos
285 * @param callback A callback to call after the given item has been focused.
286 */
287 private void focusItem(final int pos, @Nullable final FocusCallback callback) {
Ben Lin81afd7f2016-08-22 18:24:03 -0700288 if (mPendingFocusId != null) {
289 Log.v(TAG, "clearing pending focus id: " + mPendingFocusId);
290 mPendingFocusId = null;
291 }
292
Ben Kwa15de7f92016-02-09 11:27:45 -0800293 // If the item is already in view, focus it; otherwise, scroll to it and focus it.
Steve McKay17b761e2016-09-20 17:26:46 -0700294 RecyclerView.ViewHolder vh = mConfig.mView.findViewHolderForAdapterPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800295 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800296 if (vh.itemView.requestFocus() && callback != null) {
297 callback.onFocus(vh.itemView);
298 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800299 } else {
Ben Kwa15de7f92016-02-09 11:27:45 -0800300 // Set a one-time listener to request focus when the scroll has completed.
Steve McKay17b761e2016-09-20 17:26:46 -0700301 mConfig.mView.addOnScrollListener(
Ben Kwa15de7f92016-02-09 11:27:45 -0800302 new RecyclerView.OnScrollListener() {
303 @Override
304 public void onScrollStateChanged(RecyclerView view, int newState) {
305 if (newState == RecyclerView.SCROLL_STATE_IDLE) {
306 // When scrolling stops, find the item and focus it.
307 RecyclerView.ViewHolder vh =
308 view.findViewHolderForAdapterPosition(pos);
309 if (vh != null) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800310 if (vh.itemView.requestFocus() && callback != null) {
311 callback.onFocus(vh.itemView);
312 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800313 } else {
314 // This might happen in weird corner cases, e.g. if the user is
315 // scrolling while a delete operation is in progress. In that
316 // case, just don't attempt to focus the missing item.
317 Log.w(TAG, "Unable to focus position " + pos + " after scroll");
318 }
319 view.removeOnScrollListener(this);
320 }
321 }
322 });
Steve McKay17b761e2016-09-20 17:26:46 -0700323 mConfig.mView.smoothScrollToPosition(pos);
Ben Kwa15de7f92016-02-09 11:27:45 -0800324 }
325 }
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800326
327 /**
328 * @return Whether the layout manager is currently in a grid-configuration.
329 */
330 private boolean inGridMode() {
Steve McKay17b761e2016-09-20 17:26:46 -0700331 return mConfig.mLayout.getSpanCount() > 1;
Ben Kwaa6c2f0a2016-02-10 11:54:55 -0800332 }
Ben Kwa472103f2016-02-10 15:48:25 -0800333
Ben Kwa6fd431e2016-02-23 23:00:01 -0800334 private interface FocusCallback {
335 public void onFocus(View view);
336 }
337
Ben Kwa472103f2016-02-10 15:48:25 -0800338 /**
339 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
340 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
341 * up a string from individual key events, and perform searching based on that string. When an
342 * item is found that matches the search term, that item will be focused. This class also
343 * highlights instances of the search term found in the view.
344 */
345 private class TitleSearchHelper {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800346 static private final int SEARCH_TIMEOUT = 500; // ms
347
348 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
349 private final Editable mSearchString = Editable.Factory.getInstance().newEditable("");
350 private final Highlighter mHighlighter = new Highlighter();
351 private final BackgroundColorSpan mSpan;
352
Ben Kwa472103f2016-02-10 15:48:25 -0800353 private List<String> mIndex;
354 private boolean mActive;
Ben Kwa6fd431e2016-02-23 23:00:01 -0800355 private Timer mTimer;
356 private KeyEvent mLastEvent;
357 private Handler mUiRunner;
Ben Kwa472103f2016-02-10 15:48:25 -0800358
Ben Lin81afd7f2016-08-22 18:24:03 -0700359 public TitleSearchHelper(@ColorRes int color) {
360 mSpan = new BackgroundColorSpan(color);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800361 // Handler for running things on the main UI thread. Needed for updating the UI from a
362 // timer (see #activate, below).
363 mUiRunner = new Handler(Looper.getMainLooper());
Ben Kwa472103f2016-02-10 15:48:25 -0800364 }
365
366 /**
367 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
368 * of individual key events, and then performs a search for the given string.
369 *
370 * @param doc The document holder receiving the key event.
371 * @param keyCode
372 * @param event
373 * @return Whether the event was handled.
374 */
375 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
376 switch (keyCode) {
377 case KeyEvent.KEYCODE_ESCAPE:
378 case KeyEvent.KEYCODE_ENTER:
379 if (mActive) {
380 // These keys end any active searches.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800381 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800382 return true;
383 } else {
384 // Don't handle these key events if there is no active search.
385 return false;
386 }
387 case KeyEvent.KEYCODE_SPACE:
388 // This allows users to search for files with spaces in their names, but ignores
Ben Kwa6fd431e2016-02-23 23:00:01 -0800389 // spacebar events when a text search is not active. Ignoring the spacebar
390 // event is necessary because other handlers (see FocusManager#handleKey) also
391 // listen for and handle it.
Ben Kwa472103f2016-02-10 15:48:25 -0800392 if (!mActive) {
393 return false;
394 }
395 }
396
397 // Navigation keys also end active searches.
398 if (Events.isNavigationKeyCode(keyCode)) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800399 endSearch();
Ben Kwa472103f2016-02-10 15:48:25 -0800400 // Don't handle the keycode, so navigation still occurs.
401 return false;
402 }
403
404 // Build up the search string, and perform the search.
405 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
406
407 // Delete is processed by the text listener, but not "handled". Check separately for it.
Ben Kwa6fd431e2016-02-23 23:00:01 -0800408 if (keyCode == KeyEvent.KEYCODE_DEL) {
409 handled = true;
410 }
411
412 if (handled) {
413 mLastEvent = event;
414 if (mSearchString.length() == 0) {
Ben Kwa472103f2016-02-10 15:48:25 -0800415 // Don't perform empty searches.
416 return false;
417 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800418 search();
Ben Kwa472103f2016-02-10 15:48:25 -0800419 }
420
421 return handled;
422 }
423
424 /**
425 * Activates the search helper, which changes its key handling and updates the search index
426 * and highlights if necessary. Call this each time the search term is updated.
427 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800428 private void search() {
Ben Kwa472103f2016-02-10 15:48:25 -0800429 if (!mActive) {
Ben Kwa6fd431e2016-02-23 23:00:01 -0800430 // The model listener invalidates the search index when the model changes.
Steve McKay17b761e2016-09-20 17:26:46 -0700431 mConfig.mModel.addUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800432
433 // Used to keep the current search alive until the timeout expires. If the user
434 // presses another key within that time, that keystroke is added to the current
435 // search. Otherwise, the current search ends, and subsequent keystrokes start a new
436 // search.
437 mTimer = new Timer();
438 mActive = true;
Ben Kwa472103f2016-02-10 15:48:25 -0800439 }
440
441 // If the search index was invalidated, rebuild it
442 if (mIndex == null) {
443 buildIndex();
444 }
445
Ben Kwa6fd431e2016-02-23 23:00:01 -0800446 // Search for the current search term.
447 // Perform case-insensitive search.
448 String searchString = mSearchString.toString().toLowerCase();
449 for (int pos = 0; pos < mIndex.size(); pos++) {
450 String title = mIndex.get(pos);
451 if (title != null && title.startsWith(searchString)) {
452 focusItem(pos, new FocusCallback() {
453 @Override
454 public void onFocus(View view) {
455 mHighlighter.applyHighlight(view);
456 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the amount of
457 // time between the last keystroke and a search expiring is actually
458 // between 500 and 750 ms. A smaller timer period results in less
459 // variability but does more polling.
460 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2);
461 }
462 });
463 break;
Ben Kwa472103f2016-02-10 15:48:25 -0800464 }
465 }
466 }
467
468 /**
Ben Kwa6fd431e2016-02-23 23:00:01 -0800469 * Ends the current search (see {@link #search()}.
Ben Kwa472103f2016-02-10 15:48:25 -0800470 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800471 private void endSearch() {
472 if (mActive) {
Steve McKay17b761e2016-09-20 17:26:46 -0700473 mConfig.mModel.removeUpdateListener(mModelListener);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800474 mTimer.cancel();
Ben Kwa472103f2016-02-10 15:48:25 -0800475 }
476
Ben Kwa6fd431e2016-02-23 23:00:01 -0800477 mHighlighter.removeHighlight();
478
479 mIndex = null;
480 mSearchString.clear();
481 mActive = false;
Ben Kwa472103f2016-02-10 15:48:25 -0800482 }
483
484 /**
485 * Builds a search index for finding items by title. Queries the model and adapter, so both
486 * must be set up before calling this method.
487 */
488 private void buildIndex() {
Steve McKay17b761e2016-09-20 17:26:46 -0700489 int itemCount = mConfig.mAdapter.getItemCount();
Ben Kwa472103f2016-02-10 15:48:25 -0800490 List<String> index = new ArrayList<>(itemCount);
491 for (int i = 0; i < itemCount; i++) {
Steve McKay17b761e2016-09-20 17:26:46 -0700492 String modelId = mConfig.mAdapter.getModelId(i);
493 Cursor cursor = mConfig.mModel.getItem(modelId);
Steve McKay5a22a112016-04-12 11:29:10 -0700494 if (modelId != null && cursor != null) {
495 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800496 // Perform case-insensitive search.
497 index.add(title.toLowerCase());
Ben Kwa472103f2016-02-10 15:48:25 -0800498 } else {
499 index.add("");
500 }
501 }
502 mIndex = index;
503 }
504
Steve McKay990f76e2016-09-16 12:36:58 -0700505 private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() {
Ben Kwa472103f2016-02-10 15:48:25 -0800506 @Override
Steve McKay990f76e2016-09-16 12:36:58 -0700507 public void accept(Update event) {
Ben Kwa472103f2016-02-10 15:48:25 -0800508 // Invalidate the search index when the model updates.
509 mIndex = null;
510 }
511 };
512
Ben Kwa6fd431e2016-02-23 23:00:01 -0800513 private class TimeoutTask extends TimerTask {
514 @Override
515 public void run() {
516 long last = mLastEvent.getEventTime();
517 long now = SystemClock.uptimeMillis();
518 if ((now - last) > SEARCH_TIMEOUT) {
519 // endSearch must run on the main thread because it does UI work
520 mUiRunner.post(new Runnable() {
521 @Override
522 public void run() {
523 endSearch();
524 }
525 });
Ben Kwa472103f2016-02-10 15:48:25 -0800526 }
Ben Kwa472103f2016-02-10 15:48:25 -0800527 }
Ben Kwa6fd431e2016-02-23 23:00:01 -0800528 };
529
530 private class Highlighter {
531 private Spannable mCurrentHighlight;
Ben Kwa472103f2016-02-10 15:48:25 -0800532
533 /**
Ben Kwa6fd431e2016-02-23 23:00:01 -0800534 * Applies title highlights to the given view. The view must have a title field that is a
535 * spannable text field. If this condition is not met, this function does nothing.
536 *
537 * @param view
Ben Kwa472103f2016-02-10 15:48:25 -0800538 */
Ben Kwa6fd431e2016-02-23 23:00:01 -0800539 private void applyHighlight(View view) {
Ben Kwa472103f2016-02-10 15:48:25 -0800540 TextView titleView = (TextView) view.findViewById(android.R.id.title);
Ben Kwa6fd431e2016-02-23 23:00:01 -0800541 if (titleView == null) {
542 return;
543 }
544
545 CharSequence tmpText = titleView.getText();
546 if (tmpText instanceof Spannable) {
547 if (mCurrentHighlight != null) {
548 mCurrentHighlight.removeSpan(mSpan);
549 }
550 mCurrentHighlight = (Spannable) tmpText;
551 mCurrentHighlight.setSpan(
552 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
553 }
554 }
555
556 /**
557 * Removes title highlights from the given view. The view must have a title field that is a
558 * spannable text field. If this condition is not met, this function does nothing.
559 *
560 * @param view
561 */
562 private void removeHighlight() {
563 if (mCurrentHighlight != null) {
564 mCurrentHighlight.removeSpan(mSpan);
Ben Kwa472103f2016-02-10 15:48:25 -0800565 }
566 }
567 };
568 }
Steve McKay17b761e2016-09-20 17:26:46 -0700569
570 public FocusManager reset(RecyclerView view, Model model) {
571 mConfig.reset(view, model);
572 return this;
573 }
574
575 private static final class Config {
576
577 @Nullable RecyclerView mView;
578 @Nullable DocumentsAdapter mAdapter;
579 @Nullable GridLayoutManager mLayout;
580
581 private Model mModel;
582
583 public void reset(RecyclerView view, Model model) {
584 assert (view != null);
585 assert (model != null);
586 mView = view;
587 mAdapter = (DocumentsAdapter) view.getAdapter();
588 mLayout = (GridLayoutManager) view.getLayoutManager();
589 mModel = model;
590 }
591 }
Ben Kwa15de7f92016-02-09 11:27:45 -0800592}