Home | History | Annotate | Download | only in dirlist
      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 
     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      */
     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
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    317     private boolean inGridMode() {
    318         return mLayout.getSpanCount() > 1;
    319     }
    320 
    321     private interface FocusCallback {
    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 
    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          */
    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          */
    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          */
    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          */
    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
    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              */
    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              */
    555             private void removeHighlight() {
    556                 if (mCurrentHighlight != null) {
    557                     mCurrentHighlight.removeSpan(mSpan);
    558                 }
    559             }
    560         };
    561     }
    562 }
    563