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