FocusManager.java revision 340ab17f468789bb507daeae116cf7940ba84b03
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 17package com.android.documentsui; 18 19import static com.android.documentsui.base.DocumentInfo.getCursorString; 20import static com.android.documentsui.base.Shared.DEBUG; 21 22import android.annotation.ColorRes; 23import android.annotation.Nullable; 24import android.database.Cursor; 25import android.os.Handler; 26import android.os.Looper; 27import android.os.SystemClock; 28import android.provider.DocumentsContract.Document; 29import android.support.v7.widget.GridLayoutManager; 30import android.support.v7.widget.RecyclerView; 31import android.text.Editable; 32import android.text.Spannable; 33import android.text.method.KeyListener; 34import android.text.method.TextKeyListener; 35import android.text.method.TextKeyListener.Capitalize; 36import android.text.style.BackgroundColorSpan; 37import android.util.Log; 38import android.view.KeyEvent; 39import android.view.View; 40import android.widget.TextView; 41 42import com.android.documentsui.base.EventListener; 43import com.android.documentsui.base.Events; 44import com.android.documentsui.base.Procedure; 45import com.android.documentsui.base.Shared; 46import com.android.documentsui.dirlist.DocumentHolder; 47import com.android.documentsui.dirlist.DocumentsAdapter; 48import com.android.documentsui.dirlist.FocusHandler; 49import com.android.documentsui.dirlist.Model; 50import com.android.documentsui.dirlist.Model.Update; 51import com.android.documentsui.selection.SelectionManager; 52 53import java.util.ArrayList; 54import java.util.List; 55import java.util.Timer; 56import java.util.TimerTask; 57 58public final class FocusManager implements FocusHandler { 59 private static final String TAG = "FocusManager"; 60 61 private final ContentScope mScope = new ContentScope(); 62 63 private final SelectionManager mSelectionMgr; 64 private final DrawerController mDrawer; 65 private final Procedure mRootsFocuser; 66 private final TitleSearchHelper mSearchHelper; 67 68 private boolean mNavDrawerHasFocus; 69 70 public FocusManager( 71 SelectionManager selectionMgr, 72 DrawerController drawer, 73 Procedure rootsFocuser, 74 @ColorRes int color) { 75 76 mSelectionMgr = selectionMgr; 77 mDrawer = drawer; 78 mRootsFocuser = rootsFocuser; 79 80 mSearchHelper = new TitleSearchHelper(color); 81 } 82 83 @Override 84 public boolean advanceFocusArea() { 85 // This should only be called in pre-O devices. 86 // O has built-in keyboard navigation support. 87 assert(!Shared.ENABLE_OMC_API_FEATURES); 88 boolean focusChanged = false; 89 if (mNavDrawerHasFocus) { 90 mDrawer.setOpen(false); 91 focusChanged = focusDirectoryList(); 92 } else { 93 mDrawer.setOpen(true); 94 focusChanged = mRootsFocuser.run(); 95 } 96 97 if (focusChanged) { 98 mNavDrawerHasFocus = !mNavDrawerHasFocus; 99 return true; 100 } 101 102 return false; 103 } 104 105 @Override 106 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 107 // Search helper gets first crack, for doing type-to-focus. 108 if (mSearchHelper.handleKey(doc, keyCode, event)) { 109 return true; 110 } 111 112 if (Events.isNavigationKeyCode(keyCode)) { 113 // Find the target item and focus it. 114 int endPos = findTargetPosition(doc.itemView, keyCode, event); 115 116 if (endPos != RecyclerView.NO_POSITION) { 117 focusItem(endPos); 118 } 119 // Swallow all navigation keystrokes. Otherwise they go to the app's global 120 // key-handler, which will route them back to the DF and cause focus to be reset. 121 return true; 122 } 123 return false; 124 } 125 126 @Override 127 public void onFocusChange(View v, boolean hasFocus) { 128 // Remember focus events on items. 129 if (hasFocus && v.getParent() == mScope.view) { 130 mScope.lastFocusPosition = mScope.view.getChildAdapterPosition(v); 131 } 132 } 133 134 @Override 135 public boolean focusDirectoryList() { 136 if (mScope.adapter.getItemCount() == 0) { 137 if (DEBUG) Log.v(TAG, "Nothing to focus."); 138 return false; 139 } 140 141 // If there's a selection going on, we don't want to grant user the ability to focus 142 // on any individfocusSomethingual item to prevent ambiguity in operations (Cut selection 143 // vs. Cut focused 144 // item) 145 if (mSelectionMgr.hasSelection()) { 146 if (DEBUG) Log.v(TAG, "Existing selection found. No focus will be done."); 147 return false; 148 } 149 150 final int focusPos = (mScope.lastFocusPosition != RecyclerView.NO_POSITION) 151 ? mScope.lastFocusPosition 152 : mScope.layout.findFirstVisibleItemPosition(); 153 focusItem(focusPos); 154 return true; 155 } 156 157 /* 158 * Attempts to reset focus on the item corresponding to {@code mPendingFocusId} if it exists and 159 * has a valid position in the adapter. It then automatically resets {@code mPendingFocusId}. 160 */ 161 @Override 162 public void onLayoutCompleted() { 163 if (mScope.pendingFocusId == null) { 164 return; 165 } 166 167 int pos = mScope.adapter.getModelIds().indexOf(mScope.pendingFocusId); 168 if (pos != -1) { 169 focusItem(pos); 170 } 171 mScope.pendingFocusId = null; 172 } 173 174 /* 175 * Attempts to put focus on the document associated with the given modelId. If item does not 176 * exist yet in the layout, this sets a pending modelId to be used when {@code 177 * #applyPendingFocus()} is called next time. 178 */ 179 @Override 180 public void focusDocument(String modelId) { 181 int pos = mScope.adapter.getModelIds().indexOf(modelId); 182 if (pos != -1 && mScope.view.findViewHolderForAdapterPosition(pos) != null) { 183 focusItem(pos); 184 } else { 185 mScope.pendingFocusId = modelId; 186 } 187 } 188 189 @Override 190 public int getFocusPosition() { 191 return mScope.lastFocusPosition; 192 } 193 194 @Override 195 public boolean hasFocusedItem() { 196 return mScope.lastFocusPosition != RecyclerView.NO_POSITION; 197 } 198 199 @Override 200 public @Nullable String getFocusModelId() { 201 if (mScope.lastFocusPosition != RecyclerView.NO_POSITION) { 202 DocumentHolder holder = (DocumentHolder) mScope.view 203 .findViewHolderForAdapterPosition(mScope.lastFocusPosition); 204 return holder.getModelId(); 205 } 206 return null; 207 } 208 209 /** 210 * Finds the destination position where the focus should land for a given navigation event. 211 * 212 * @param view The view that received the event. 213 * @param keyCode The key code for the event. 214 * @param event 215 * @return The adapter position of the destination item. Could be RecyclerView.NO_POSITION. 216 */ 217 private int findTargetPosition(View view, int keyCode, KeyEvent event) { 218 switch (keyCode) { 219 case KeyEvent.KEYCODE_MOVE_HOME: 220 return 0; 221 case KeyEvent.KEYCODE_MOVE_END: 222 return mScope.adapter.getItemCount() - 1; 223 case KeyEvent.KEYCODE_PAGE_UP: 224 case KeyEvent.KEYCODE_PAGE_DOWN: 225 return findPagedTargetPosition(view, keyCode, event); 226 } 227 228 // Find a navigation target based on the arrow key that the user pressed. 229 int searchDir = -1; 230 switch (keyCode) { 231 case KeyEvent.KEYCODE_DPAD_UP: 232 searchDir = View.FOCUS_UP; 233 break; 234 case KeyEvent.KEYCODE_DPAD_DOWN: 235 searchDir = View.FOCUS_DOWN; 236 break; 237 } 238 239 if (inGridMode()) { 240 int currentPosition = mScope.view.getChildAdapterPosition(view); 241 // Left and right arrow keys only work in grid mode. 242 switch (keyCode) { 243 case KeyEvent.KEYCODE_DPAD_LEFT: 244 if (currentPosition > 0) { 245 // Stop backward focus search at the first item, otherwise focus will wrap 246 // around to the last visible item. 247 searchDir = View.FOCUS_BACKWARD; 248 } 249 break; 250 case KeyEvent.KEYCODE_DPAD_RIGHT: 251 if (currentPosition < mScope.adapter.getItemCount() - 1) { 252 // Stop forward focus search at the last item, otherwise focus will wrap 253 // around to the first visible item. 254 searchDir = View.FOCUS_FORWARD; 255 } 256 break; 257 } 258 } 259 260 if (searchDir != -1) { 261 // Focus search behaves badly if the parent RecyclerView is focused. However, focusable 262 // shouldn't be unset on RecyclerView, otherwise focus isn't properly restored after 263 // events that cause a UI rebuild (like rotating the device). Compromise: turn focusable 264 // off while performing the focus search. 265 // TODO: Revisit this when RV focus issues are resolved. 266 mScope.view.setFocusable(false); 267 View targetView = view.focusSearch(searchDir); 268 mScope.view.setFocusable(true); 269 // TargetView can be null, for example, if the user pressed <down> at the bottom 270 // of the list. 271 if (targetView != null) { 272 // Ignore navigation targets that aren't items in the RecyclerView. 273 if (targetView.getParent() == mScope.view) { 274 return mScope.view.getChildAdapterPosition(targetView); 275 } 276 } 277 } 278 279 return RecyclerView.NO_POSITION; 280 } 281 282 /** 283 * Given a PgUp/PgDn event and the current view, find the position of the target view. This 284 * returns: 285 * <li>The position of the topmost (or bottom-most) visible item, if the current item is not the 286 * top- or bottom-most visible item. 287 * <li>The position of an item that is one page's worth of items up (or down) if the current 288 * item is the top- or bottom-most visible item. 289 * <li>The first (or last) item, if paging up (or down) would go past those limits. 290 * 291 * @param view The view that received the key event. 292 * @param keyCode Must be KEYCODE_PAGE_UP or KEYCODE_PAGE_DOWN. 293 * @param event 294 * @return The adapter position of the target item. 295 */ 296 private int findPagedTargetPosition(View view, int keyCode, KeyEvent event) { 297 int first = mScope.layout.findFirstVisibleItemPosition(); 298 int last = mScope.layout.findLastVisibleItemPosition(); 299 int current = mScope.view.getChildAdapterPosition(view); 300 int pageSize = last - first + 1; 301 302 if (keyCode == KeyEvent.KEYCODE_PAGE_UP) { 303 if (current > first) { 304 // If the current item isn't the first item, target the first item. 305 return first; 306 } else { 307 // If the current item is the first item, target the item one page up. 308 int target = current - pageSize; 309 return target < 0 ? 0 : target; 310 } 311 } 312 313 if (keyCode == KeyEvent.KEYCODE_PAGE_DOWN) { 314 if (current < last) { 315 // If the current item isn't the last item, target the last item. 316 return last; 317 } else { 318 // If the current item is the last item, target the item one page down. 319 int target = current + pageSize; 320 int max = mScope.adapter.getItemCount() - 1; 321 return target < max ? target : max; 322 } 323 } 324 325 throw new IllegalArgumentException("Unsupported keyCode: " + keyCode); 326 } 327 328 /** 329 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 330 * necessary. 331 * 332 * @param pos 333 */ 334 private void focusItem(final int pos) { 335 focusItem(pos, null); 336 } 337 338 /** 339 * Requests focus for the item in the given adapter position, scrolling the RecyclerView if 340 * necessary. 341 * 342 * @param pos 343 * @param callback A callback to call after the given item has been focused. 344 */ 345 private void focusItem(final int pos, @Nullable final FocusCallback callback) { 346 if (mScope.pendingFocusId != null) { 347 Log.v(TAG, "clearing pending focus id: " + mScope.pendingFocusId); 348 mScope.pendingFocusId = null; 349 } 350 351 // If the item is already in view, focus it; otherwise, scroll to it and focus it. 352 RecyclerView.ViewHolder vh = mScope.view.findViewHolderForAdapterPosition(pos); 353 if (vh != null) { 354 if (vh.itemView.requestFocus() && callback != null) { 355 callback.onFocus(vh.itemView); 356 } 357 } else { 358 // Set a one-time listener to request focus when the scroll has completed. 359 mScope.view.addOnScrollListener( 360 new RecyclerView.OnScrollListener() { 361 @Override 362 public void onScrollStateChanged(RecyclerView view, int newState) { 363 if (newState == RecyclerView.SCROLL_STATE_IDLE) { 364 // When scrolling stops, find the item and focus it. 365 RecyclerView.ViewHolder vh = view 366 .findViewHolderForAdapterPosition(pos); 367 if (vh != null) { 368 if (vh.itemView.requestFocus() && callback != null) { 369 callback.onFocus(vh.itemView); 370 } 371 } else { 372 // This might happen in weird corner cases, e.g. if the user is 373 // scrolling while a delete operation is in progress. In that 374 // case, just don't attempt to focus the missing item. 375 Log.w(TAG, "Unable to focus position " + pos + " after scroll"); 376 } 377 view.removeOnScrollListener(this); 378 } 379 } 380 }); 381 mScope.view.smoothScrollToPosition(pos); 382 } 383 } 384 385 /** @return Whether the layout manager is currently in a grid-configuration. */ 386 private boolean inGridMode() { 387 return mScope.layout.getSpanCount() > 1; 388 } 389 390 private interface FocusCallback { 391 public void onFocus(View view); 392 } 393 394 /** 395 * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via 396 * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build 397 * up a string from individual key events, and perform searching based on that string. When an 398 * item is found that matches the search term, that item will be focused. This class also 399 * highlights instances of the search term found in the view. 400 */ 401 private class TitleSearchHelper { 402 private static final int SEARCH_TIMEOUT = 500; // ms 403 404 private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false); 405 private final Editable mSearchString = Editable.Factory.getInstance().newEditable(""); 406 private final Highlighter mHighlighter = new Highlighter(); 407 private final BackgroundColorSpan mSpan; 408 409 private List<String> mIndex; 410 private boolean mActive; 411 private Timer mTimer; 412 private KeyEvent mLastEvent; 413 private Handler mUiRunner; 414 415 public TitleSearchHelper(@ColorRes int color) { 416 mSpan = new BackgroundColorSpan(color); 417 // Handler for running things on the main UI thread. Needed for updating the UI from a 418 // timer (see #activate, below). 419 mUiRunner = new Handler(Looper.getMainLooper()); 420 } 421 422 /** 423 * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out 424 * of individual key events, and then performs a search for the given string. 425 * 426 * @param doc The document holder receiving the key event. 427 * @param keyCode 428 * @param event 429 * @return Whether the event was handled. 430 */ 431 public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) { 432 switch (keyCode) { 433 case KeyEvent.KEYCODE_ESCAPE: 434 case KeyEvent.KEYCODE_ENTER: 435 if (mActive) { 436 // These keys end any active searches. 437 endSearch(); 438 return true; 439 } else { 440 // Don't handle these key events if there is no active search. 441 return false; 442 } 443 case KeyEvent.KEYCODE_SPACE: 444 // This allows users to search for files with spaces in their names, but ignores 445 // spacebar events when a text search is not active. Ignoring the spacebar 446 // event is necessary because other handlers (see FocusManager#handleKey) also 447 // listen for and handle it. 448 if (!mActive) { 449 return false; 450 } 451 } 452 453 // Navigation keys also end active searches. 454 if (Events.isNavigationKeyCode(keyCode)) { 455 endSearch(); 456 // Don't handle the keycode, so navigation still occurs. 457 return false; 458 } 459 460 // Build up the search string, and perform the search. 461 boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event); 462 463 // Delete is processed by the text listener, but not "handled". Check separately for it. 464 if (keyCode == KeyEvent.KEYCODE_DEL) { 465 handled = true; 466 } 467 468 if (handled) { 469 mLastEvent = event; 470 if (mSearchString.length() == 0) { 471 // Don't perform empty searches. 472 return false; 473 } 474 search(); 475 } 476 477 return handled; 478 } 479 480 /** 481 * Activates the search helper, which changes its key handling and updates the search index 482 * and highlights if necessary. Call this each time the search term is updated. 483 */ 484 private void search() { 485 if (!mActive) { 486 // The model listener invalidates the search index when the model changes. 487 mScope.model.addUpdateListener(mModelListener); 488 489 // Used to keep the current search alive until the timeout expires. If the user 490 // presses another key within that time, that keystroke is added to the current 491 // search. Otherwise, the current search ends, and subsequent keystrokes start a new 492 // search. 493 mTimer = new Timer(); 494 mActive = true; 495 } 496 497 // If the search index was invalidated, rebuild it 498 if (mIndex == null) { 499 buildIndex(); 500 } 501 502 // Search for the current search term. 503 // Perform case-insensitive search. 504 String searchString = mSearchString.toString().toLowerCase(); 505 for (int pos = 0; pos < mIndex.size(); pos++) { 506 String title = mIndex.get(pos); 507 if (title != null && title.startsWith(searchString)) { 508 focusItem( 509 pos, 510 new FocusCallback() { 511 @Override 512 public void onFocus(View view) { 513 mHighlighter.applyHighlight(view); 514 // Using a timer repeat period of SEARCH_TIMEOUT/2 means the 515 // amount of 516 // time between the last keystroke and a search expiring is 517 // actually 518 // between 500 and 750 ms. A smaller timer period results in 519 // less 520 // variability but does more polling. 521 mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2); 522 } 523 }); 524 break; 525 } 526 } 527 } 528 529 /** Ends the current search (see {@link #search()}. */ 530 private void endSearch() { 531 if (mActive) { 532 mScope.model.removeUpdateListener(mModelListener); 533 mTimer.cancel(); 534 } 535 536 mHighlighter.removeHighlight(); 537 538 mIndex = null; 539 mSearchString.clear(); 540 mActive = false; 541 } 542 543 /** 544 * Builds a search index for finding items by title. Queries the model and adapter, so both 545 * must be set up before calling this method. 546 */ 547 private void buildIndex() { 548 int itemCount = mScope.adapter.getItemCount(); 549 List<String> index = new ArrayList<>(itemCount); 550 for (int i = 0; i < itemCount; i++) { 551 String modelId = mScope.adapter.getModelId(i); 552 Cursor cursor = mScope.model.getItem(modelId); 553 if (modelId != null && cursor != null) { 554 String title = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME); 555 // Perform case-insensitive search. 556 index.add(title.toLowerCase()); 557 } else { 558 index.add(""); 559 } 560 } 561 mIndex = index; 562 } 563 564 private EventListener<Model.Update> mModelListener = new EventListener<Model.Update>() { 565 @Override 566 public void accept(Update event) { 567 // Invalidate the search index when the model updates. 568 mIndex = null; 569 } 570 }; 571 572 private class TimeoutTask extends TimerTask { 573 @Override 574 public void run() { 575 long last = mLastEvent.getEventTime(); 576 long now = SystemClock.uptimeMillis(); 577 if ((now - last) > SEARCH_TIMEOUT) { 578 // endSearch must run on the main thread because it does UI work 579 mUiRunner.post( 580 new Runnable() { 581 @Override 582 public void run() { 583 endSearch(); 584 } 585 }); 586 } 587 } 588 }; 589 590 private class Highlighter { 591 private Spannable mCurrentHighlight; 592 593 /** 594 * Applies title highlights to the given view. The view must have a title field that is 595 * a spannable text field. If this condition is not met, this function does nothing. 596 * 597 * @param view 598 */ 599 private void applyHighlight(View view) { 600 TextView titleView = (TextView) view.findViewById(android.R.id.title); 601 if (titleView == null) { 602 return; 603 } 604 605 CharSequence tmpText = titleView.getText(); 606 if (tmpText instanceof Spannable) { 607 if (mCurrentHighlight != null) { 608 mCurrentHighlight.removeSpan(mSpan); 609 } 610 mCurrentHighlight = (Spannable) tmpText; 611 mCurrentHighlight.setSpan( 612 mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); 613 } 614 } 615 616 /** 617 * Removes title highlights from the given view. The view must have a title field that 618 * is a spannable text field. If this condition is not met, this function does nothing. 619 * 620 * @param view 621 */ 622 private void removeHighlight() { 623 if (mCurrentHighlight != null) { 624 mCurrentHighlight.removeSpan(mSpan); 625 } 626 } 627 }; 628 } 629 630 public FocusManager reset(RecyclerView view, Model model) { 631 assert (view != null); 632 assert (model != null); 633 mScope.view = view; 634 mScope.adapter = (DocumentsAdapter) view.getAdapter(); 635 mScope.layout = (GridLayoutManager) view.getLayoutManager(); 636 mScope.model = model; 637 638 mScope.lastFocusPosition = RecyclerView.NO_POSITION; 639 mScope.pendingFocusId = null; 640 641 return this; 642 } 643 644 private static final class ContentScope { 645 private @Nullable RecyclerView view; 646 private @Nullable DocumentsAdapter adapter; 647 private @Nullable GridLayoutManager layout; 648 private @Nullable Model model; 649 650 private @Nullable String pendingFocusId; 651 private int lastFocusPosition = RecyclerView.NO_POSITION; 652 } 653} 654