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