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