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