• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 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.car.ui.utils;
18 
19 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
20 
21 import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER;
22 import static com.android.car.ui.utils.RotaryConstants.ROTARY_FOCUS_DELEGATING_CONTAINER;
23 import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
24 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
25 
26 import android.text.TextUtils;
27 import android.view.View;
28 import android.view.ViewGroup;
29 import android.view.ViewParent;
30 
31 import androidx.annotation.IntDef;
32 import androidx.annotation.NonNull;
33 import androidx.annotation.Nullable;
34 import androidx.annotation.VisibleForTesting;
35 
36 import com.android.car.ui.FocusArea;
37 import com.android.car.ui.FocusParkingView;
38 
39 import java.lang.annotation.Retention;
40 import java.lang.annotation.RetentionPolicy;
41 
42 /**
43  * Utility class used by {@link com.android.car.ui.FocusArea} and {@link
44  * com.android.car.ui.FocusParkingView}.
45  *
46  * @hide
47  */
48 public final class ViewUtils {
49 
50     /**
51      * How many milliseconds to wait before trying to restore the focus inside the LazyLayoutView
52      * the second time.
53      */
54     private static final int RESTORE_FOCUS_RETRY_DELAY_MS = 3000;
55 
56     /**
57      * No view is focused, the focused view is not shown, or the focused view is a FocusParkingView.
58      */
59     @VisibleForTesting
60     static final int NO_FOCUS = 1;
61 
62     /** A scrollable container is focused. */
63     @VisibleForTesting
64     static final int SCROLLABLE_CONTAINER_FOCUS = 2;
65 
66     /**
67      * A regular view is focused. A regular View is a View that is neither a FocusParkingView nor a
68      * scrollable container.
69      */
70     @VisibleForTesting
71     static final int REGULAR_FOCUS = 3;
72 
73     /**
74      * An implicit default focus view (i.e., the selected item or the first focusable item in a
75      * scrollable container) is focused.
76      */
77     @VisibleForTesting
78     static final int IMPLICIT_DEFAULT_FOCUS = 4;
79 
80     /** The {@code app:defaultFocus} view is focused. */
81     @VisibleForTesting
82     static final int DEFAULT_FOCUS = 5;
83 
84     /** The {@code android:focusedByDefault} view is focused. */
85     @VisibleForTesting
86     static final int FOCUSED_BY_DEFAULT = 6;
87 
88     /**
89      * Focus level of a view. When adjusting the focus, the view with the highest focus level will
90      * be focused.
91      */
92     @IntDef(flag = true, value = {NO_FOCUS, SCROLLABLE_CONTAINER_FOCUS, REGULAR_FOCUS,
93             IMPLICIT_DEFAULT_FOCUS, DEFAULT_FOCUS, FOCUSED_BY_DEFAULT})
94     @Retention(RetentionPolicy.SOURCE)
95     private @interface FocusLevel {
96     }
97 
98     /** This is a utility class. */
ViewUtils()99     private ViewUtils() {
100     }
101 
102     /**
103      * This is a functional interface and can therefore be used as the assignment target for a
104      * lambda expression or method reference.
105      *
106      * @param <T> the type of the input to the predicate
107      */
108     private interface Predicate<T> {
109         /** Evaluates this predicate on the given argument. */
test(@onNull T t)110         boolean test(@NonNull T t);
111     }
112 
113     /**
114      * An interface used to restore focus inside a view when its layout is completed.
115      * <p>
116      * The view that needs to restore focus lazily should implement this interface.
117      */
118     public interface LazyLayoutView {
119 
120         /**
121          * Returnes whether the view's layout is completed and ready to restore focus inside it.
122          */
isLayoutCompleted()123         boolean isLayoutCompleted();
124 
125         /**
126          * Adds a listener to be called when the view's layout is completed.
127          */
addOnLayoutCompleteListener(@ullable Runnable runnable)128         void addOnLayoutCompleteListener(@Nullable Runnable runnable);
129 
130         /**
131          * Removes a listener to be called when the view's layout is completed.
132          */
removeOnLayoutCompleteListener(@ullable Runnable runnable)133         void removeOnLayoutCompleteListener(@Nullable Runnable runnable);
134     }
135 
136     /**
137      * Hides the focus by searching the view tree for the {@link FocusParkingView}
138      * and focusing on it.
139      *
140      * @param root the root view to search from
141      * @return true if the FocusParkingView was successfully found and focused
142      *         or if it was already focused
143      */
hideFocus(@onNull View root)144     public static boolean hideFocus(@NonNull View root) {
145         FocusParkingView fpv = (FocusParkingView) depthFirstSearch(root,
146                 /* targetPredicate= */ v -> v instanceof FocusParkingView,
147                 /* skipPredicate= */ null);
148         if (fpv == null) {
149             return false;
150         }
151         if (fpv.isFocused()) {
152             return true;
153         }
154         return fpv.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
155     }
156 
157     /** Gets the ancestor FocusArea of the {@code view}, if any. Returns null if not found. */
158     @Nullable
getAncestorFocusArea(@onNull View view)159     public static FocusArea getAncestorFocusArea(@NonNull View view) {
160         ViewParent parent = view.getParent();
161         while (parent != null) {
162             if (parent instanceof FocusArea) {
163                 return (FocusArea) parent;
164             }
165             parent = parent.getParent();
166         }
167         return null;
168     }
169 
170     /**
171      * Gets the ancestor scrollable container of the {@code view}, if any. Returns null if not
172      * found.
173      */
174     @Nullable
getAncestorScrollableContainer(@ullable View view)175     public static ViewGroup getAncestorScrollableContainer(@Nullable View view) {
176         if (view == null) {
177             return null;
178         }
179         ViewParent parent = view.getParent();
180         // A scrollable container can't contain a FocusArea, so let's return earlier if we found
181         // a FocusArea.
182         while (parent != null && parent instanceof ViewGroup && !(parent instanceof FocusArea)) {
183             ViewGroup viewGroup = (ViewGroup) parent;
184             if (isScrollableContainer(viewGroup)) {
185                 return viewGroup;
186             }
187             parent = parent.getParent();
188         }
189         return null;
190     }
191 
192     /**
193      * Focuses on the {@code view} if it can be focused.
194      *
195      * @return whether it was successfully focused or already focused
196      */
requestFocus(@ullable View view)197     public static boolean requestFocus(@Nullable View view) {
198         if (view == null || !canTakeFocus(view)) {
199             return false;
200         }
201         if (view.isFocused()) {
202             return true;
203         }
204         // Exit touch mode and focus the view. The view may not be focusable in touch mode, so we
205         // need to exit touch mode before focusing it.
206         return view.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
207     }
208 
209     /**
210      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
211      * the view's FocusLevel is higher than the {@code currentFocus}'s FocusLevel, focuses on the
212      * view. If it tried to focus on a LazyLayoutView but failed, requests to adjust the focus
213      * inside the LazyLayoutView later.
214      *
215      * @return whether the view is focused
216      */
adjustFocus(@onNull View root, @Nullable View currentFocus)217     public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) {
218         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
219         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
220                 /* defaultFocusOverridesHistory= */ false);
221     }
222 
223     /**
224      * Similar to {@link #adjustFocus(View, View)} but without requesting to adjust the focus
225      * inside the LazyLayoutView later.
226      */
adjustFocusImmediately(@onNull View root, @Nullable View currentFocus)227     public static boolean adjustFocusImmediately(@NonNull View root, @Nullable View currentFocus) {
228         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
229         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
230                 /* defaultFocusOverridesHistory= */ false, /* delayed= */ false);
231     }
232 
233     /**
234      * If the {@code currentFocus}'s FocusLevel is lower than REGULAR_FOCUS, adjusts focus within
235      * {@code root}. See {@link #adjustFocus(View, int)}. Otherwise no-op.
236      *
237      * @return whether the focus has changed
238      */
initFocus(@onNull View root, @Nullable View currentFocus)239     public static boolean initFocus(@NonNull View root, @Nullable View currentFocus) {
240         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
241         if (currentLevel >= REGULAR_FOCUS) {
242             return false;
243         }
244         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
245                 /* defaultFocusOverridesHistory= */ false);
246     }
247 
248     /**
249      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
250      * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view.
251      *
252      * @return whether the view is focused
253      */
254     @VisibleForTesting
adjustFocus(@onNull View root, @FocusLevel int currentLevel)255     static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) {
256         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
257                 /* defaultFocusOverridesHistory= */ false);
258     }
259 
260     /**
261      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel} and
262      * focuses on it or the {@code cachedFocusedView}.
263      *
264      * @return whether the view is focused
265      */
adjustFocus(@onNull View root, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)266     public static boolean adjustFocus(@NonNull View root,
267             @Nullable View cachedFocusedView,
268             boolean defaultFocusOverridesHistory) {
269         return adjustFocus(root, NO_FOCUS, cachedFocusedView, defaultFocusOverridesHistory);
270     }
271 
adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)272     private static boolean adjustFocus(@NonNull View root,
273             @FocusLevel int currentLevel,
274             @Nullable View cachedFocusedView,
275             boolean defaultFocusOverridesHistory) {
276         return adjustFocus(root, currentLevel, cachedFocusedView, defaultFocusOverridesHistory,
277                 /* delayed= */true);
278     }
279 
280     /**
281      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
282      * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view or {@code
283      * cachedFocusedView}.
284      *
285      * @return whether the view is focused
286      */
adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory, boolean delayed)287     private static boolean adjustFocus(@NonNull View root,
288             @FocusLevel int currentLevel,
289             @Nullable View cachedFocusedView,
290             boolean defaultFocusOverridesHistory,
291             boolean delayed) {
292         // If the previously focused view has higher priority than the default focus, try to focus
293         // on the previously focused view.
294         if (!defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
295             return true;
296         }
297 
298         // Try to focus on the default focus view.
299         if (currentLevel < FOCUSED_BY_DEFAULT && focusOnFocusedByDefaultView(root)) {
300             return true;
301         }
302         if (currentLevel < DEFAULT_FOCUS && focusOnDefaultFocusView(root)) {
303             return true;
304         }
305         if (currentLevel < IMPLICIT_DEFAULT_FOCUS && focusOnImplicitDefaultFocusView(root)) {
306             return true;
307         }
308 
309         // When delayed is true, if there is a LazyLayoutView but it failed to adjust focus
310         // inside it because it is not loaded yet or it's loaded but has no descendnats, request to
311         // restore focus inside it later, and return false for now.
312         if (delayed && currentLevel < IMPLICIT_DEFAULT_FOCUS) {
313             LazyLayoutView lazyLayoutView = findLazyLayoutView(root);
314             if (lazyLayoutView != null && !lazyLayoutView.isLayoutCompleted()) {
315                 initFocusDelayed(lazyLayoutView);
316                 return false;
317             }
318         }
319 
320         // If the previously focused view has lower priority than the default focus, try to focus
321         // on the previously focused view.
322         if (defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
323             return true;
324         }
325 
326         // Try to focus on other views with low focus levels.
327         if (currentLevel < REGULAR_FOCUS && focusOnFirstRegularView(root)) {
328             return true;
329         }
330         if (currentLevel < SCROLLABLE_CONTAINER_FOCUS) {
331             return focusOnScrollableContainer(root);
332         }
333         return false;
334     }
335 
336     /**
337      * If the {code lazyLayoutView} has a focusable descendant and no visible view is focused,
338      * focuses on the descendant. Otherwise tries again when the {code lazyLayoutView} completes
339      * layout or after a timeout, whichever comes first.
340      */
initFocus(@onNull LazyLayoutView lazyLayoutView)341     public static void initFocus(@NonNull LazyLayoutView lazyLayoutView) {
342         if (initFocusImmediately(lazyLayoutView)) {
343             return;
344         }
345         initFocusDelayed(lazyLayoutView);
346     }
347 
initFocusDelayed(@onNull LazyLayoutView lazyLayoutView)348     private static void initFocusDelayed(@NonNull LazyLayoutView lazyLayoutView) {
349         if (!(lazyLayoutView instanceof View)) {
350             return;
351         }
352         View lazyView = (View) lazyLayoutView;
353         Runnable[] onLayoutCompleteListener = new Runnable[1];
354         Runnable delayedTask = () -> {
355             lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]);
356             initFocusImmediately(lazyLayoutView);
357         };
358         onLayoutCompleteListener[0] = () -> {
359             if (initFocusImmediately(lazyLayoutView)) {
360                 // Remove the delayedTask only when onLayoutCompleteListener has initialized the
361                 // focus succefully, because the delayedTask needs to kick in when it fails, such
362                 // as the lazyLayoutView is still loading after a timeout, or it's loaded but has
363                 // no descendants to take focus.
364                 lazyView.removeCallbacks(delayedTask);
365                 lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]);
366             }
367         };
368         lazyLayoutView.addOnLayoutCompleteListener(onLayoutCompleteListener[0]);
369         lazyView.postDelayed(delayedTask, RESTORE_FOCUS_RETRY_DELAY_MS);
370     }
371 
initFocusImmediately(@onNull LazyLayoutView lazyLayoutView)372     private static boolean initFocusImmediately(@NonNull LazyLayoutView lazyLayoutView) {
373         if (!(lazyLayoutView instanceof View)) {
374             return false;
375         }
376         View lazyView = (View) lazyLayoutView;
377         View focusedView = lazyView.getRootView().findFocus();
378         // If the currently focused view won't draw, it's not a valid focus.
379         View visibleFocusedView =
380                 focusedView != null && !focusedView.willNotDraw() ? focusedView : null;
381         // If there is a visible view focused, just return true.
382         if (visibleFocusedView != null && !(visibleFocusedView instanceof FocusParkingView)) {
383             return true;
384         }
385 
386         return ViewUtils.adjustFocusImmediately(lazyView, visibleFocusedView);
387     }
388 
389     @VisibleForTesting
390     @FocusLevel
getFocusLevel(@ullable View view)391     static int getFocusLevel(@Nullable View view) {
392         if (view == null || view instanceof FocusParkingView || !view.isShown()) {
393             return NO_FOCUS;
394         }
395         if (view.isFocusedByDefault()) {
396             return FOCUSED_BY_DEFAULT;
397         }
398         if (isDefaultFocus(view)) {
399             return DEFAULT_FOCUS;
400         }
401         if (isImplicitDefaultFocusView(view)) {
402             return IMPLICIT_DEFAULT_FOCUS;
403         }
404         if (isScrollableContainer(view)) {
405             return SCROLLABLE_CONTAINER_FOCUS;
406         }
407         return REGULAR_FOCUS;
408     }
409 
410     /** Returns whether the {@code view} is a {@code app:defaultFocus} view. */
isDefaultFocus(@onNull View view)411     private static boolean isDefaultFocus(@NonNull View view) {
412         FocusArea parent = getAncestorFocusArea(view);
413         return parent != null && view == parent.getDefaultFocusView();
414     }
415 
416     /**
417      * Returns whether the {@code view} is an implicit default focus view, i.e., the selected
418      * item or the first focusable item in a rotary container.
419      */
420     @VisibleForTesting
isImplicitDefaultFocusView(@onNull View view)421     static boolean isImplicitDefaultFocusView(@NonNull View view) {
422         ViewGroup rotaryContainer = null;
423         ViewParent parent = view.getParent();
424         while (parent != null && parent instanceof ViewGroup) {
425             ViewGroup viewGroup = (ViewGroup) parent;
426             if (isRotaryContainer(viewGroup)) {
427                 rotaryContainer = viewGroup;
428                 break;
429             }
430             parent = parent.getParent();
431         }
432         if (rotaryContainer == null) {
433             return false;
434         }
435         return findFirstSelectedFocusableDescendant(rotaryContainer) == view
436                 || findFirstFocusableDescendant(rotaryContainer) == view;
437     }
438 
isRotaryContainer(@onNull View view)439     private static boolean isRotaryContainer(@NonNull View view) {
440         CharSequence contentDescription = view.getContentDescription();
441         return TextUtils.equals(contentDescription, ROTARY_CONTAINER)
442                 || TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
443                 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
444     }
445 
isScrollableContainer(@onNull View view)446     private static boolean isScrollableContainer(@NonNull View view) {
447         CharSequence contentDescription = view.getContentDescription();
448         return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
449                 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
450     }
451 
isFocusDelegatingContainer(@onNull View view)452     private static boolean isFocusDelegatingContainer(@NonNull View view) {
453         CharSequence contentDescription = view.getContentDescription();
454         return TextUtils.equals(contentDescription, ROTARY_FOCUS_DELEGATING_CONTAINER);
455     }
456 
457     /**
458      * Focuses on the first {@code app:defaultFocus} view in the view tree, if any.
459      *
460      * @param root the root of the view tree
461      * @return whether succeeded
462      */
focusOnDefaultFocusView(@onNull View root)463     private static boolean focusOnDefaultFocusView(@NonNull View root) {
464         View defaultFocus = findDefaultFocusView(root);
465         return requestFocus(defaultFocus);
466     }
467 
468     /**
469      * Focuses on the first {@code android:focusedByDefault} view in the view tree, if any.
470      *
471      * @param root the root of the view tree
472      * @return whether succeeded
473      */
focusOnFocusedByDefaultView(@onNull View root)474     private static boolean focusOnFocusedByDefaultView(@NonNull View root) {
475         View focusedByDefault = findFocusedByDefaultView(root);
476         return requestFocus(focusedByDefault);
477     }
478 
479     /**
480      * Focuses on the first implicit default focus view in the view tree, if any.
481      *
482      * @param root the root of the view tree
483      * @return whether succeeded
484      */
focusOnImplicitDefaultFocusView(@onNull View root)485     private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) {
486         View implicitDefaultFocus = findImplicitDefaultFocusView(root);
487         return requestFocus(implicitDefaultFocus);
488     }
489 
490     /**
491      * Tries to focus on the first focusable view in the view tree in depth first order, excluding
492      * the FocusParkingView and scrollable containers. If focusing on the first such view fails,
493      * keeps trying other views in depth first order until succeeds or there are no more such views.
494      *
495      * @param root the root of the view tree
496      * @return whether succeeded
497      */
focusOnFirstRegularView(@onNull View root)498     private static boolean focusOnFirstRegularView(@NonNull View root) {
499         View focusedView = ViewUtils.depthFirstSearch(root,
500                 /* targetPredicate= */
501                 v -> !isScrollableContainer(v) && canTakeFocus(v) && requestFocus(v),
502                 /* skipPredicate= */ v -> !v.isShown());
503         return focusedView != null;
504     }
505 
506     /**
507      * Focuses on the first scrollable container in the view tree, if any.
508      *
509      * @param root the root of the view tree
510      * @return whether succeeded
511      */
focusOnScrollableContainer(@onNull View root)512     private static boolean focusOnScrollableContainer(@NonNull View root) {
513         View focusedView = ViewUtils.depthFirstSearch(root,
514                 /* targetPredicate= */ v -> isScrollableContainer(v) && canTakeFocus(v),
515                 /* skipPredicate= */ v -> !v.isShown());
516         return requestFocus(focusedView);
517     }
518 
519     /**
520      * Searches the {@code root}'s descendants in depth first order, and returns the first
521      * {@code app:defaultFocus} view that can take focus. Returns null if not found.
522      */
523     @Nullable
findDefaultFocusView(@onNull View view)524     private static View findDefaultFocusView(@NonNull View view) {
525         if (!view.isShown()) {
526             return null;
527         }
528         if (view instanceof FocusArea) {
529             FocusArea focusArea = (FocusArea) view;
530             View defaultFocus = focusArea.getDefaultFocusView();
531             if (defaultFocus != null && canTakeFocus(defaultFocus)) {
532                 return defaultFocus;
533             }
534         } else if (view instanceof ViewGroup) {
535             ViewGroup parent = (ViewGroup) view;
536             for (int i = 0; i < parent.getChildCount(); i++) {
537                 View child = parent.getChildAt(i);
538                 View defaultFocus = findDefaultFocusView(child);
539                 if (defaultFocus != null) {
540                     return defaultFocus;
541                 }
542             }
543         }
544         return null;
545     }
546 
547     /**
548      * Searches the {@code view} and its descendants in depth first order, and returns the first
549      * {@code android:focusedByDefault} view that can take focus. Returns null if not found.
550      */
551     @VisibleForTesting
552     @Nullable
findFocusedByDefaultView(@onNull View view)553     static View findFocusedByDefaultView(@NonNull View view) {
554         return depthFirstSearch(view,
555                 /* targetPredicate= */ v -> v.isFocusedByDefault() && canTakeFocus(v),
556                 /* skipPredicate= */ v -> !v.isShown());
557     }
558 
559     /**
560      * Searches the {@code view} and its descendants in depth first order, and returns the first
561      * implicit default focus view, i.e., the selected item or the first focusable item in the
562      * first rotary container. Returns null if not found.
563      */
564     @VisibleForTesting
565     @Nullable
findImplicitDefaultFocusView(@onNull View view)566     static View findImplicitDefaultFocusView(@NonNull View view) {
567         View rotaryContainer = findRotaryContainer(view);
568         if (rotaryContainer == null) {
569             return null;
570         }
571 
572         View selectedItem = findFirstSelectedFocusableDescendant(rotaryContainer);
573 
574         return selectedItem != null
575                 ? selectedItem
576                 : findFirstFocusableDescendant(rotaryContainer);
577     }
578 
579     /**
580      * Searches the {@code view}'s descendants in depth first order, and returns the first view
581      * that can take focus, or null if not found.
582      */
583     @VisibleForTesting
584     @Nullable
findFirstFocusableDescendant(@onNull View view)585     static View findFirstFocusableDescendant(@NonNull View view) {
586         return depthFirstSearch(view,
587                 /* targetPredicate= */ v -> v != view && canTakeFocus(v),
588                 /* skipPredicate= */ v -> !v.isShown());
589     }
590 
591     /**
592      * Searches the {@code view}'s descendants in depth first order, and returns the first view
593      * that is selected and can take focus, or null if not found.
594      */
595     @VisibleForTesting
596     @Nullable
findFirstSelectedFocusableDescendant(@onNull View view)597     static View findFirstSelectedFocusableDescendant(@NonNull View view) {
598         return depthFirstSearch(view,
599                 /* targetPredicate= */ v -> v != view && v.isSelected() && canTakeFocus(v),
600                 /* skipPredicate= */ v -> !v.isShown());
601     }
602 
603     /**
604      * Searches the {@code view} and its descendants in depth first order, and returns the first
605      * rotary container shown on the screen. Returns null if not found.
606      */
607     @Nullable
findRotaryContainer(@onNull View view)608     private static View findRotaryContainer(@NonNull View view) {
609         return depthFirstSearch(view,
610                 /* targetPredicate= */ v -> isRotaryContainer(v),
611                 /* skipPredicate= */ v -> !v.isShown());
612     }
613 
614     /**
615      * Searches the {@code view} and its descendants in depth first order, and returns the first
616      * LazyLayoutView shown on the screen. Returns null if not found.
617      */
618     @Nullable
findLazyLayoutView(@onNull View view)619     private static LazyLayoutView findLazyLayoutView(@NonNull View view) {
620         return (LazyLayoutView) depthFirstSearch(view,
621                 /* targetPredicate= */ v -> v instanceof LazyLayoutView,
622                 /* skipPredicate= */ v -> !v.isShown());
623     }
624 
625     /**
626      * Searches the {@code view} and its descendants in depth first order, skips the views that
627      * match {@code skipPredicate} and their descendants, and returns the first view that matches
628      * {@code targetPredicate}. Returns null if not found.
629      */
630     @Nullable
depthFirstSearch(@onNull View view, @NonNull Predicate<View> targetPredicate, @Nullable Predicate<View> skipPredicate)631     private static View depthFirstSearch(@NonNull View view,
632             @NonNull Predicate<View> targetPredicate,
633             @Nullable Predicate<View> skipPredicate) {
634         if (skipPredicate != null && skipPredicate.test(view)) {
635             return null;
636         }
637         if (targetPredicate.test(view)) {
638             return view;
639         }
640         if (view instanceof ViewGroup) {
641             ViewGroup parent = (ViewGroup) view;
642             for (int i = 0; i < parent.getChildCount(); i++) {
643                 View child = parent.getChildAt(i);
644                 View target = depthFirstSearch(child, targetPredicate, skipPredicate);
645                 if (target != null) {
646                     return target;
647                 }
648             }
649         }
650         return null;
651     }
652 
653     /** Returns whether {@code view} can be focused. */
canTakeFocus(@onNull View view)654     private static boolean canTakeFocus(@NonNull View view) {
655         boolean focusable = view.isFocusable() || isFocusDelegatingContainer(view);
656         return focusable && view.isEnabled() && view.isShown()
657                 && view.getWidth() > 0 && view.getHeight() > 0 && view.isAttachedToWindow()
658                 && !(view instanceof FocusParkingView)
659                 // If it's a scrollable container, it can be focused only when it has no focusable
660                 // descendants. We focus on it so that the rotary controller can scroll it.
661                 && (!isScrollableContainer(view) || findFirstFocusableDescendant(view) == null);
662     }
663 
664     /**
665      * Enables rotary scrolling for {@code view}, either vertically (if {@code isVertical} is true)
666      * or horizontally (if {@code isVertical} is false). With rotary scrolling enabled, rotating the
667      * rotary controller will scroll rather than moving the focus when moving the focus would cause
668      * a lot of scrolling. Rotary scrolling should be enabled for scrolling views which contain
669      * content which the user may want to see but can't interact with, either alone or along with
670      * interactive (focusable) content.
671      */
setRotaryScrollEnabled(@onNull View view, boolean isVertical)672     public static void setRotaryScrollEnabled(@NonNull View view, boolean isVertical) {
673         view.setContentDescription(
674                 isVertical ? ROTARY_VERTICALLY_SCROLLABLE : ROTARY_HORIZONTALLY_SCROLLABLE);
675     }
676 }
677