1 /* 2 * Copyright (C) 2016 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.documentsui.dirlist; 18 19 import static com.android.documentsui.model.DocumentInfo.getCursorString; 20 21 import android.annotation.Nullable; 22 import android.content.Context; 23 import android.database.Cursor; 24 import android.os.Handler; 25 import android.os.Looper; 26 import android.os.SystemClock; 27 import android.provider.DocumentsContract.Document; 28 import android.support.v7.widget.GridLayoutManager; 29 import android.support.v7.widget.RecyclerView; 30 import android.text.Editable; 31 import android.text.Spannable; 32 import android.text.method.KeyListener; 33 import android.text.method.TextKeyListener; 34 import android.text.method.TextKeyListener.Capitalize; 35 import android.text.style.BackgroundColorSpan; 36 import android.util.Log; 37 import android.view.KeyEvent; 38 import android.view.View; 39 import android.widget.TextView; 40 41 import com.android.documentsui.Events; 42 import com.android.documentsui.R; 43 44 import java.util.ArrayList; 45 import java.util.List; 46 import java.util.Timer; 47 import java.util.TimerTask; 48 49 /** 50 * A class that handles navigation and focus within the DirectoryFragment. 51 */ 52 class FocusManager implements View.OnFocusChangeListener { 53 private static final String TAG = "FocusManager"; 54 55 private RecyclerView mView; 56 private DocumentsAdapter mAdapter; 57 private GridLayoutManager mLayout; 58 59 private TitleSearchHelper mSearchHelper; 60 private Model mModel; 61 62 private int mLastFocusPosition = RecyclerView.NO_POSITION; 63 FocusManager(Context context, RecyclerView view, Model model)64 public FocusManager(Context context, RecyclerView view, Model model) { 65 mView = view; 66 mAdapter = (DocumentsAdapter) view.getAdapter(); 67 mLayout = (GridLayoutManager) view.getLayoutManager(); 68 mModel = model; 69 70 mSearchHelper = new TitleSearchHelper(context); 71 } 72 73 /** 74 * Handles navigation (setting focus, adjusting selection if needed) arising from incoming key 75 * events. 76 * 77 * @param doc The DocumentHolder receiving the key event. 78 * @param keyCode 79 * @param event 80 * @return Whether the event was handled. 81 */ handleKey(DocumentHolder doc, int keyCode, KeyEvent event)82 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 83 // Search helper gets first crack, for doing type-to-focus. 84 if (mSearchHelper.handleKey(doc, keyCode, event)) { 85 return true; 86 } 87 88 // Translate space/shift-space into PgDn/PgUp 89 if (keyCode == KeyEvent.KEYCODE_SPACE) { 90 if (event.isShiftPressed()) { 91 keyCode = KeyEvent.KEYCODE_PAGE_UP; 92 } else { 93 keyCode = KeyEvent.KEYCODE_PAGE_DOWN; 94 } 95 } 96 97 if (Events.isNavigationKeyCode(keyCode)) { 98 // Find the target item and focus it. 99 int endPos = findTargetPosition(doc.itemView, keyCode, event); 100 101 if (endPos != RecyclerView.NO_POSITION) { 102 focusItem(endPos); 103 } 104 // Swallow all navigation keystrokes. Otherwise they go to the app's global 105 // key-handler, which will route them back to the DF and cause focus to be reset. 106 return true; 107 } 108 return false; 109 } 110 111 @Override onFocusChange(View v, boolean hasFocus)112 public void onFocusChange(View v, boolean hasFocus) { 113 // Remember focus events on items. 114 if (hasFocus && v.getParent() == mView) { 115 mLastFocusPosition = mView.getChildAdapterPosition(v); 116 } 117 } 118 119 /** 120 * Requests focus on the item that last had focus. Scrolls to that item if necessary. 121 */ restoreLastFocus()122 public void restoreLastFocus() { 123 if (mAdapter.getItemCount() == 0) { 124 // Nothing to focus. 125 return; 126 } 127 128 if (mLastFocusPosition != RecyclerView.NO_POSITION) { 129 // The system takes care of situations when a view is no longer on screen, etc, 130 focusItem(mLastFocusPosition); 131 } else { 132 // Focus the first visible item 133 focusItem(mLayout.findFirstVisibleItemPosition()); 134 } 135 } 136 137 /** 138 * @return The adapter position of the last focused item. 139 */ getFocusPosition()140 public int getFocusPosition() { 141 return mLastFocusPosition; 142 } 143 144 /** 145 * Finds the destination position where the focus should land for a given navigation event. 146 * 147 * @param view The view that received the event. 148 * @param keyCode The key code for the event. 149 * @param event 150 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION. 151 */ findTargetPosition(View view, int keyCode, KeyEvent event)152 private int findTargetPosition(View view, int keyCode, KeyEvent event) { 153 switch (keyCode) { 154 case KeyEvent.KEYCODE_MOVE_HOME: 155 return 0; 156 case KeyEvent.KEYCODE_MOVE_END: 157 return mAdapter.getItemCount() - 1; 158 case KeyEvent.KEYCODE_PAGE_UP: 159 case KeyEvent.KEYCODE_PAGE_DOWN: 160 return findPagedTargetPosition(view, keyCode, event); 161 } 162 163 // Find a navigation target based on the arrow key that the user pressed. 164 int searchDir = -1; 165 switch (keyCode) { 166 case KeyEvent.KEYCODE_DPAD_UP: 167 searchDir = View.FOCUS_UP; 168 break; 169 case KeyEvent.KEYCODE_DPAD_DOWN: 170 searchDir = View.FOCUS_DOWN; 171 break; 172 } 173 174 if (inGridMode()) { 175 int currentPosition = mView.getChildAdapterPosition(view); 176 // Left and right arrow keys only work in grid mode. 177 switch (keyCode) { 178 case KeyEvent.KEYCODE_DPAD_LEFT: 179 if (currentPosition > 0) { 180 // Stop backward focus search at the first item, otherwise focus will wrap 181 // around to the last visible item. 182 searchDir = View.FOCUS_BACKWARD; 183 } 184 break; 185 case KeyEvent.KEYCODE_DPAD_RIGHT: 186 if (currentPosition < mAdapter.getItemCount() - 1) { 187 // Stop forward focus search at the last item, otherwise focus will wrap 188 // around to the first visible item. 189 searchDir = View.FOCUS_FORWARD; 190 } 191 break; 192 } 193 } 194 195 if (searchDir != -1) { 196 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable 197 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after 198 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable 199 // off while performing the focus search. 200 // TODO: Revisit this when RV focus issues are resolved. 201 mView.setFocusable(false); 202 View targetView = view.focusSearch(searchDir); 203 mView.setFocusable(true); 204 // TargetView can be null, for example, if the user pressed <down> at the bottom 205 // of the list. 206 if (targetView != null) { 207 // Ignore navigation targets that aren't items in the RecyclerView. 208 if (targetView.getParent() == mView) { 209 return mView.getChildAdapterPosition(targetView); 210 } 211 } 212 } 213 214 return RecyclerView.NO_POSITION; 215 } 216 217 /** 218 * Given a PgUp/PgDn event and the current view, find the position of the target view. 219 * This returns: 220 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not 221 * the top- or bottom-most visible item. 222 * <li>The position of an item that is one page's worth of items up (or down) if the current 223 * item is the top- or bottom-most visible item. 224 * <li>The first (or last) item, if paging up (or down) would go past those limits. 225 * @param view The view that received the key event. 226 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN. 227 * @param event 228 * @return The adapter position of the target item. 229 */ findPagedTargetPosition(View view, int keyCode, KeyEvent event)230 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) { 231 int first = mLayout.findFirstVisibleItemPosition(); 232 int last = mLayout.findLastVisibleItemPosition(); 233 int current = mView.getChildAdapterPosition(view); 234 int pageSize = last - first + 1; 235 236 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) { 237 if (current > first) { 238 // If the current item isn't the first item, target the first item. 239 return first; 240 } else { 241 // If the current item is the first item, target the item one page up. 242 int target = current - pageSize; 243 return target < 0 ? 0 : target; 244 } 245 } 246 247 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) { 248 if (current < last) { 249 // If the current item isn't the last item, target the last item. 250 return last; 251 } else { 252 // If the current item is the last item, target the item one page down. 253 int target = current + pageSize; 254 int max = mAdapter.getItemCount() - 1; 255 return target < max ? target : max; 256 } 257 } 258 259 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode); 260 } 261 262 /** 263 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 264 * necessary. 265 * 266 * @param pos 267 */ focusItem(final int pos)268 private void focusItem(final int pos) { 269 focusItem(pos, null); 270 } 271 272 /** 273 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 274 * necessary. 275 * 276 * @param pos 277 * @param callback A callback to call after the given item has been focused. 278 */ focusItem(final int pos, @Nullable final FocusCallback callback)279 private void focusItem(final int pos, @Nullable final FocusCallback callback) { 280 // If the item is already in view, focus it; otherwise, scroll to it and focus it. 281 RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos); 282 if (vh != null) { 283 if (vh.itemView.requestFocus() && callback != null) { 284 callback.onFocus(vh.itemView); 285 } 286 } else { 287 // Set a one-time listener to request focus when the scroll has completed. 288 mView.addOnScrollListener( 289 new RecyclerView.OnScrollListener() { 290 @Override 291 public void onScrollStateChanged(RecyclerView view, int newState) { 292 if (newState == RecyclerView.SCROLL_STATE_IDLE) { 293 // When scrolling stops, find the item and focus it. 294 RecyclerView.ViewHolder vh = 295 view.findViewHolderForAdapterPosition(pos); 296 if (vh != null) { 297 if (vh.itemView.requestFocus() && callback != null) { 298 callback.onFocus(vh.itemView); 299 } 300 } else { 301 // This might happen in weird corner cases, e.g. if the user is 302 // scrolling while a delete operation is in progress. In that 303 // case, just don't attempt to focus the missing item. 304 Log.w(TAG, "Unable to focus position " + pos + " after scroll"); 305 } 306 view.removeOnScrollListener(this); 307 } 308 } 309 }); 310 mView.smoothScrollToPosition(pos); 311 } 312 } 313 314 /** 315 * @return Whether the layout manager is currently in a grid-configuration. 316 */ inGridMode()317 private boolean inGridMode() { 318 return mLayout.getSpanCount() > 1; 319 } 320 321 private interface FocusCallback { onFocus(View view)322 public void onFocus(View view); 323 } 324 325 /** 326 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via 327 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build 328 * up a string from individual key events, and perform searching based on that string. When an 329 * item is found that matches the search term, that item will be focused. This class also 330 * highlights instances of the search term found in the view. 331 */ 332 private class TitleSearchHelper { 333 static private final int SEARCH_TIMEOUT = 500; // ms 334 335 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false); 336 private final Editable mSearchString = Editable.Factory.getInstance().newEditable(""); 337 private final Highlighter mHighlighter = new Highlighter(); 338 private final BackgroundColorSpan mSpan; 339 340 private List<String> mIndex; 341 private boolean mActive; 342 private Timer mTimer; 343 private KeyEvent mLastEvent; 344 private Handler mUiRunner; 345 TitleSearchHelper(Context context)346 public TitleSearchHelper(Context context) { 347 mSpan = new BackgroundColorSpan(context.getColor(R.color.accent_dark)); 348 // Handler for running things on the main UI thread. Needed for updating the UI from a 349 // timer (see #activate, below). 350 mUiRunner = new Handler(Looper.getMainLooper()); 351 } 352 353 /** 354 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out 355 * of individual key events, and then performs a search for the given string. 356 * 357 * @param doc The document holder receiving the key event. 358 * @param keyCode 359 * @param event 360 * @return Whether the event was handled. 361 */ handleKey(DocumentHolder doc, int keyCode, KeyEvent event)362 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 363 switch (keyCode) { 364 case KeyEvent.KEYCODE_ESCAPE: 365 case KeyEvent.KEYCODE_ENTER: 366 if (mActive) { 367 // These keys end any active searches. 368 endSearch(); 369 return true; 370 } else { 371 // Don't handle these key events if there is no active search. 372 return false; 373 } 374 case KeyEvent.KEYCODE_SPACE: 375 // This allows users to search for files with spaces in their names, but ignores 376 // spacebar events when a text search is not active. Ignoring the spacebar 377 // event is necessary because other handlers (see FocusManager#handleKey) also 378 // listen for and handle it. 379 if (!mActive) { 380 return false; 381 } 382 } 383 384 // Navigation keys also end active searches. 385 if (Events.isNavigationKeyCode(keyCode)) { 386 endSearch(); 387 // Don't handle the keycode, so navigation still occurs. 388 return false; 389 } 390 391 // Build up the search string, and perform the search. 392 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event); 393 394 // Delete is processed by the text listener, but not "handled". Check separately for it. 395 if (keyCode == KeyEvent.KEYCODE_DEL) { 396 handled = true; 397 } 398 399 if (handled) { 400 mLastEvent = event; 401 if (mSearchString.length() == 0) { 402 // Don't perform empty searches. 403 return false; 404 } 405 search(); 406 } 407 408 return handled; 409 } 410 411 /** 412 * Activates the search helper, which changes its key handling and updates the search index 413 * and highlights if necessary. Call this each time the search term is updated. 414 */ search()415 private void search() { 416 if (!mActive) { 417 // The model listener invalidates the search index when the model changes. 418 mModel.addUpdateListener(mModelListener); 419 420 // Used to keep the current search alive until the timeout expires. If the user 421 // presses another key within that time, that keystroke is added to the current 422 // search. Otherwise, the current search ends, and subsequent keystrokes start a new 423 // search. 424 mTimer = new Timer(); 425 mActive = true; 426 } 427 428 // If the search index was invalidated, rebuild it 429 if (mIndex == null) { 430 buildIndex(); 431 } 432 433 // Search for the current search term. 434 // Perform case-insensitive search. 435 String searchString = mSearchString.toString().toLowerCase(); 436 for (int pos = 0; pos < mIndex.size(); pos++) { 437 String title = mIndex.get(pos); 438 if (title != null && title.startsWith(searchString)) { 439 focusItem(pos, new FocusCallback() { 440 @Override 441 public void onFocus(View view) { 442 mHighlighter.applyHighlight(view); 443 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the amount of 444 // time between the last keystroke and a search expiring is actually 445 // between 500 and 750 ms. A smaller timer period results in less 446 // variability but does more polling. 447 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2); 448 } 449 }); 450 break; 451 } 452 } 453 } 454 455 /** 456 * Ends the current search (see {@link #search()}. 457 */ endSearch()458 private void endSearch() { 459 if (mActive) { 460 mModel.removeUpdateListener(mModelListener); 461 mTimer.cancel(); 462 } 463 464 mHighlighter.removeHighlight(); 465 466 mIndex = null; 467 mSearchString.clear(); 468 mActive = false; 469 } 470 471 /** 472 * Builds a search index for finding items by title. Queries the model and adapter, so both 473 * must be set up before calling this method. 474 */ buildIndex()475 private void buildIndex() { 476 int itemCount = mAdapter.getItemCount(); 477 List<String> index = new ArrayList<>(itemCount); 478 for (int i = 0; i < itemCount; i++) { 479 String modelId = mAdapter.getModelId(i); 480 Cursor cursor = mModel.getItem(modelId); 481 if (modelId != null && cursor != null) { 482 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME); 483 // Perform case-insensitive search. 484 index.add(title.toLowerCase()); 485 } else { 486 index.add(""); 487 } 488 } 489 mIndex = index; 490 } 491 492 private Model.UpdateListener mModelListener = new Model.UpdateListener() { 493 @Override 494 public void onModelUpdate(Model model) { 495 // Invalidate the search index when the model updates. 496 mIndex = null; 497 } 498 499 @Override 500 public void onModelUpdateFailed(Exception e) { 501 // Invalidate the search index when the model updates. 502 mIndex = null; 503 } 504 }; 505 506 private class TimeoutTask extends TimerTask { 507 @Override run()508 public void run() { 509 long last = mLastEvent.getEventTime(); 510 long now = SystemClock.uptimeMillis(); 511 if ((now - last) > SEARCH_TIMEOUT) { 512 // endSearch must run on the main thread because it does UI work 513 mUiRunner.post(new Runnable() { 514 @Override 515 public void run() { 516 endSearch(); 517 } 518 }); 519 } 520 } 521 }; 522 523 private class Highlighter { 524 private Spannable mCurrentHighlight; 525 526 /** 527 * Applies title highlights to the given view. The view must have a title field that is a 528 * spannable text field. If this condition is not met, this function does nothing. 529 * 530 * @param view 531 */ applyHighlight(View view)532 private void applyHighlight(View view) { 533 TextView titleView = (TextView) view.findViewById(android.R.id.title); 534 if (titleView == null) { 535 return; 536 } 537 538 CharSequence tmpText = titleView.getText(); 539 if (tmpText instanceof Spannable) { 540 if (mCurrentHighlight != null) { 541 mCurrentHighlight.removeSpan(mSpan); 542 } 543 mCurrentHighlight = (Spannable) tmpText; 544 mCurrentHighlight.setSpan( 545 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 546 } 547 } 548 549 /** 550 * Removes title highlights from the given view. The view must have a title field that is a 551 * spannable text field. If this condition is not met, this function does nothing. 552 * 553 * @param view 554 */ removeHighlight()555 private void removeHighlight() { 556 if (mCurrentHighlight != null) { 557 mCurrentHighlight.removeSpan(mSpan); 558 } 559 } 560 }; 561 } 562 } 563