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