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.app.Activity;
27 import android.content.Context;
28 import android.content.ContextWrapper;
29 import android.text.TextUtils;
30 import android.util.Log;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.view.ViewParent;
34 import android.view.ViewTreeObserver;
35 
36 import androidx.annotation.IntDef;
37 import androidx.annotation.NonNull;
38 import androidx.annotation.Nullable;
39 import androidx.annotation.VisibleForTesting;
40 
41 import com.android.car.ui.FocusParkingView;
42 import com.android.car.ui.IFocusArea;
43 
44 import java.lang.annotation.Retention;
45 import java.lang.annotation.RetentionPolicy;
46 import java.util.function.Predicate;
47 
48 /** Utility class for helpful methods related to {@link View} objects. */
49 @SuppressWarnings("AndroidJdkLibsChecker")
50 public final class ViewUtils {
51 
52     private static final String TAG = "ViewUtils";
53 
54     /**
55      * How many milliseconds to wait before trying to restore the focus inside the LazyLayoutView
56      * the second time.
57      */
58     @VisibleForTesting
59     static final int RESTORE_FOCUS_RETRY_DELAY_MS = 3000;
60 
61     /**
62      * No view is focused, the focused view is not shown, or the focused view is a FocusParkingView.
63      */
64     @VisibleForTesting
65     static final int NO_FOCUS = 1;
66 
67     /** A scrollable container is focused. */
68     @VisibleForTesting
69     static final int SCROLLABLE_CONTAINER_FOCUS = 2;
70 
71     /**
72      * A regular view is focused. A regular View is a View that is neither a FocusParkingView nor a
73      * scrollable container.
74      */
75     @VisibleForTesting
76     static final int REGULAR_FOCUS = 3;
77 
78     /** The selected view is focused. */
79     @VisibleForTesting
80     static final int SELECTED_FOCUS = 4;
81 
82     /**
83      * An implicit default focus view (i.e., the selected item or the first focusable item in a
84      * scrollable container) is focused.
85      */
86     @VisibleForTesting
87     static final int IMPLICIT_DEFAULT_FOCUS = 5;
88 
89     /** The {@code app:defaultFocus} view is focused. */
90     @VisibleForTesting
91     static final int DEFAULT_FOCUS = 6;
92 
93     /** The {@code android:focusedByDefault} view is focused. */
94     @VisibleForTesting
95     static final int FOCUSED_BY_DEFAULT = 7;
96 
97     /**
98      * Focus level of a view. When adjusting the focus, the view with the highest focus level will
99      * be focused.
100      */
101     @IntDef(flag = true, value = {NO_FOCUS, SCROLLABLE_CONTAINER_FOCUS, REGULAR_FOCUS,
102             SELECTED_FOCUS, IMPLICIT_DEFAULT_FOCUS, DEFAULT_FOCUS, FOCUSED_BY_DEFAULT})
103     @Retention(RetentionPolicy.SOURCE)
104     private @interface FocusLevel {
105     }
106 
107     /** This is a utility class. */
ViewUtils()108     private ViewUtils() {
109     }
110 
111     /**
112      * An interface used to restore focus inside a view when its layout is completed.
113      * <p>
114      * The view that needs to restore focus lazily should implement this interface.
115      */
116     public interface LazyLayoutView {
117 
118         /**
119          * Returns whether the view's layout is completed and ready to restore focus inside it.
120          */
isLayoutCompleted()121         boolean isLayoutCompleted();
122 
123         /**
124          * Adds a listener to be called when the view's layout is completed.
125          */
addOnLayoutCompleteListener(@ullable Runnable runnable)126         void addOnLayoutCompleteListener(@Nullable Runnable runnable);
127 
128         /**
129          * Removes a listener to be called when the view's layout is completed.
130          */
removeOnLayoutCompleteListener(@ullable Runnable runnable)131         void removeOnLayoutCompleteListener(@Nullable Runnable runnable);
132     }
133 
134     /** Returns whether the {@code view} is in multi-window mode. */
isInMultiWindowMode(@onNull View view)135     public static boolean isInMultiWindowMode(@NonNull View view) {
136         Context context = view.getContext();
137         // Find the Activity context in case the view was inflated with Hilt dependency injector.
138         Activity activity = findActivity(context);
139         return activity != null && activity.isInMultiWindowMode();
140     }
141 
142     /** Returns the Activity of the given {@code context}. */
143     @Nullable
findActivity(@ullable Context context)144     public static Activity findActivity(@Nullable Context context) {
145         while (context instanceof ContextWrapper
146                 && !(context instanceof Activity)) {
147             context = ((ContextWrapper) context).getBaseContext();
148         }
149         if (context instanceof Activity) {
150             return (Activity) context;
151         }
152         return null;
153     }
154 
155     /** Returns whether the {@code descendant} view is a descendant of the {@code view}. */
isDescendant(@ullable View descendant, @Nullable View view)156     public static boolean isDescendant(@Nullable View descendant, @Nullable View view) {
157         if (descendant == null || view == null) {
158             return false;
159         }
160         ViewParent parent = descendant.getParent();
161         while (parent != null) {
162             if (parent == view) {
163                 return true;
164             }
165             parent = parent.getParent();
166         }
167         return false;
168     }
169 
170     /**
171      * Hides the focus by searching the view tree for the {@link FocusParkingView}
172      * and focusing on it.
173      *
174      * @param root the root view to search from
175      * @return true if the FocusParkingView was successfully found and focused
176      *         or if it was already focused
177      */
hideFocus(@onNull View root)178     public static boolean hideFocus(@NonNull View root) {
179         FocusParkingView fpv = findFocusParkingView(root);
180         if (fpv == null) {
181             return false;
182         }
183         if (fpv.isFocused()) {
184             return true;
185         }
186         return fpv.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
187     }
188 
189     /**
190      * Returns the first {@link FocusParkingView} of the view tree, if any. Returns null if not
191      * found.
192      */
193     @VisibleForTesting
findFocusParkingView(@onNull View root)194     public static FocusParkingView findFocusParkingView(@NonNull View root) {
195         return (FocusParkingView) depthFirstSearch(root,
196                 /* targetPredicate= */ v -> v instanceof FocusParkingView,
197                 /* skipPredicate= */ null);
198     }
199 
200     /** Gets the ancestor IFocusArea of the {@code view}, if any. Returns null if not found. */
201     @Nullable
getAncestorFocusArea(@onNull View view)202     public static IFocusArea getAncestorFocusArea(@NonNull View view) {
203         ViewParent parent = view.getParent();
204         while (parent != null) {
205             if (parent instanceof IFocusArea) {
206                 return (IFocusArea) parent;
207             }
208             parent = parent.getParent();
209         }
210         return null;
211     }
212 
213     /**
214      * Gets the ancestor scrollable container of the {@code view}, if any. Returns null if not
215      * found.
216      */
217     @Nullable
getAncestorScrollableContainer(@ullable View view)218     public static ViewGroup getAncestorScrollableContainer(@Nullable View view) {
219         if (view == null) {
220             return null;
221         }
222         ViewParent parent = view.getParent();
223         // A scrollable container can't contain an IFocusArea, so let's return earlier if we found
224         // an IFocusArea.
225         while (parent != null && parent instanceof ViewGroup && !(parent instanceof IFocusArea)) {
226             ViewGroup viewGroup = (ViewGroup) parent;
227             if (isScrollableContainer(viewGroup)) {
228                 return viewGroup;
229             }
230             parent = parent.getParent();
231         }
232         return null;
233     }
234 
235     /**
236      * Focuses on the {@code view} if it can be focused.
237      *
238      * @return whether it was successfully focused or already focused
239      */
requestFocus(@ullable View view)240     public static boolean requestFocus(@Nullable View view) {
241         if (view == null || !canTakeFocus(view)) {
242             return false;
243         }
244         if (view.isFocused()) {
245             return true;
246         }
247         // Exit touch mode and focus the view. The view may not be focusable in touch mode, so we
248         // need to exit touch mode before focusing it.
249         return view.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
250     }
251 
252     /**
253      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
254      * the view's FocusLevel is higher than the {@code currentFocus}'s FocusLevel, focuses on the
255      * view. If it tried to focus on a LazyLayoutView but failed, requests to adjust the focus
256      * inside the LazyLayoutView later.
257      *
258      * @return whether the view is focused
259      */
adjustFocus(@onNull View root, @Nullable View currentFocus)260     public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) {
261         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
262         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
263                 /* defaultFocusOverridesHistory= */ false);
264     }
265 
266     /**
267      * Similar to {@link #adjustFocus(View, View)} but without requesting to adjust the focus
268      * inside the LazyLayoutView later.
269      */
adjustFocusImmediately(@onNull View root, @Nullable View currentFocus)270     public static boolean adjustFocusImmediately(@NonNull View root, @Nullable View currentFocus) {
271         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
272         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
273                 /* defaultFocusOverridesHistory= */ false, /* delayed= */ false);
274     }
275 
276     /**
277      * If the {@code currentFocus}'s FocusLevel is lower than REGULAR_FOCUS, adjusts focus within
278      * {@code root}. See {@link #adjustFocus(View, int)}. Otherwise no-op.
279      *
280      * @return whether the focus has changed
281      */
initFocus(@onNull View root, @Nullable View currentFocus)282     public static boolean initFocus(@NonNull View root, @Nullable View currentFocus) {
283         @FocusLevel int currentLevel = getFocusLevel(currentFocus);
284         if (currentLevel >= REGULAR_FOCUS) {
285             return false;
286         }
287         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
288                 /* defaultFocusOverridesHistory= */ false);
289     }
290 
291     /**
292      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
293      * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view.
294      *
295      * @return whether the view is focused
296      */
297     @VisibleForTesting
adjustFocus(@onNull View root, @FocusLevel int currentLevel)298     static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) {
299         return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
300                 /* defaultFocusOverridesHistory= */ false);
301     }
302 
303     /**
304      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel} and
305      * focuses on it or the {@code cachedFocusedView}.
306      *
307      * @return whether the view is focused
308      */
adjustFocus(@onNull View root, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)309     public static boolean adjustFocus(@NonNull View root,
310             @Nullable View cachedFocusedView,
311             boolean defaultFocusOverridesHistory) {
312         return adjustFocus(root, NO_FOCUS, cachedFocusedView, defaultFocusOverridesHistory);
313     }
314 
adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)315     private static boolean adjustFocus(@NonNull View root,
316             @FocusLevel int currentLevel,
317             @Nullable View cachedFocusedView,
318             boolean defaultFocusOverridesHistory) {
319         return adjustFocus(root, currentLevel, cachedFocusedView, defaultFocusOverridesHistory,
320                 /* delayed= */ true);
321     }
322 
323     /**
324      * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
325      * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view or {@code
326      * cachedFocusedView}.
327      *
328      * @return whether the view is focused
329      */
adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory, boolean delayed)330     private static boolean adjustFocus(@NonNull View root,
331             @FocusLevel int currentLevel,
332             @Nullable View cachedFocusedView,
333             boolean defaultFocusOverridesHistory,
334             boolean delayed) {
335         // If the previously focused view has higher priority than the default focus, try to focus
336         // on the previously focused view.
337         if (!defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
338             return true;
339         }
340 
341         // Try to focus on the default focus view.
342         if (currentLevel < FOCUSED_BY_DEFAULT && focusOnFocusedByDefaultView(root)) {
343             return true;
344         }
345         if (currentLevel < DEFAULT_FOCUS && focusOnDefaultFocusView(root)) {
346             return true;
347         }
348         if (currentLevel < IMPLICIT_DEFAULT_FOCUS && focusOnImplicitDefaultFocusView(root)) {
349             return true;
350         }
351         if (currentLevel < SELECTED_FOCUS && focusOnSelectedView(root)) {
352             return true;
353         }
354 
355         // When delayed is true, if there is a LazyLayoutView but it failed to adjust focus
356         // inside it because it hasn't loaded yet or it's loaded but has no descendants, request to
357         // restore focus inside it later, and return false for now.
358         if (delayed && currentLevel < IMPLICIT_DEFAULT_FOCUS) {
359             LazyLayoutView lazyLayoutView = findLazyLayoutView(root);
360             if (lazyLayoutView != null && !lazyLayoutView.isLayoutCompleted()) {
361                 initFocusDelayed(lazyLayoutView);
362                 return false;
363             }
364         }
365 
366         // If the previously focused view has lower priority than the default focus, try to focus
367         // on the previously focused view.
368         if (defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
369             return true;
370         }
371 
372         // Try to focus on other views with low focus levels.
373         if (currentLevel < REGULAR_FOCUS && focusOnFirstRegularView(root)) {
374             return true;
375         }
376         if (currentLevel < SCROLLABLE_CONTAINER_FOCUS) {
377             return focusOnScrollableContainer(root);
378         }
379         return false;
380     }
381 
382     /**
383      * If the {code lazyLayoutView} has a focusable descendant and no visible view is focused,
384      * focuses on the descendant. Otherwise tries again when the {code lazyLayoutView} completes
385      * layout, shows up on the screen, or after a timeout, whichever comes first.
386      */
initFocus(@onNull LazyLayoutView lazyLayoutView)387     public static void initFocus(@NonNull LazyLayoutView lazyLayoutView) {
388         if (initFocusImmediately(lazyLayoutView)) {
389             return;
390         }
391         initFocusDelayed(lazyLayoutView);
392     }
393 
initFocusDelayed(@onNull LazyLayoutView lazyLayoutView)394     private static void initFocusDelayed(@NonNull LazyLayoutView lazyLayoutView) {
395         if (!(lazyLayoutView instanceof View)) {
396             return;
397         }
398         View lazyView = (View) lazyLayoutView;
399         Runnable[] onLayoutCompleteListener = new Runnable[1];
400         Runnable[] delayedTask = new Runnable[1];
401         ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener =
402                 new ViewTreeObserver.OnGlobalLayoutListener[1];
403 
404         // If the lazyLayoutView has not completed layout yet, try to restore focus inside it once
405         // it's completed.
406         if (!lazyLayoutView.isLayoutCompleted()) {
407             Log.v(TAG, "The lazyLayoutView has not completed layout: " + lazyLayoutView);
408             onLayoutCompleteListener[0] = () -> {
409                 Log.v(TAG, "The lazyLayoutView completed layout: "
410                         + lazyLayoutView);
411                 if (initFocusImmediately(lazyLayoutView)) {
412                     Log.v(TAG, "Focus restored after lazyLayoutView completed layout");
413                     // Remove the other tasks only when onLayoutCompleteListener has initialized the
414                     // focus successfully, because the other tasks need to kick in when it fails,
415                     // such as when it has completed layout but has no descendants to take focus,
416                     // or it's not shown (e.g., its ancestor is invisible). In the former case,
417                     // the delayedTask needs to run after a timeout, while in the latter case the
418                     // onGlobalLayoutListener needs to run when it shows up on the screen.
419                     removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
420                             onLayoutCompleteListener, delayedTask);
421                 }
422             };
423             lazyLayoutView.addOnLayoutCompleteListener(onLayoutCompleteListener[0]);
424         }
425 
426         // If the lazyLayoutView is not shown yet, try to restore focus inside it once it's shown.
427         if (!lazyView.isShown()) {
428             Log.d(TAG, "The lazyLayoutView is not shown: " + lazyLayoutView);
429             onGlobalLayoutListener[0] = () -> {
430                 Log.d(TAG, "onGlobalLayoutListener is called");
431                 if (lazyView.isShown()) {
432                     Log.d(TAG, "The lazyLayoutView is shown");
433                     if (initFocusImmediately(lazyLayoutView)) {
434                         Log.v(TAG, "Focus restored after showing lazyLayoutView");
435                         removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
436                                 onLayoutCompleteListener, delayedTask);
437                     }
438                 }
439             };
440             lazyView.getViewTreeObserver()
441                     .addOnGlobalLayoutListener(onGlobalLayoutListener[0]);
442         }
443 
444         // Run a delayed task as fallback.
445         delayedTask[0] = () -> {
446             Log.d(TAG, "Starting delayedTask");
447             removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
448                     onLayoutCompleteListener, delayedTask);
449             if (!hasVisibleFocusInRoot(lazyView)) {
450                 // Make one last attempt to restore focus inside the lazyLayoutView. For example,
451                 // in ViewUtilsTest.testInitFocus_inLazyLayoutView5(), when lazyLayoutView's parent
452                 // becomes visible, onGlobalLayoutListener won't be triggered, so it won't try to
453                 // restore focus there.
454                 if (lazyLayoutView.isLayoutCompleted() && lazyView.isShown()) {
455                     Log.d(TAG, "Last attempt to restore focus inside the lazyLayoutView");
456                     if (initFocusImmediately(lazyLayoutView)) {
457                         Log.d(TAG, "Restored focus inside the lazyLayoutView");
458                         return;
459                     }
460                 }
461                 // Search the view tree and find the view to focus when it failed to restore focus
462                 // inside the lazyLayoutView.
463                 adjustFocus(lazyView.getRootView(), NO_FOCUS, /* cachedFocusedView= */ null,
464                         /* defaultFocusOverridesHistory= */ false, /* delayed= */ false);
465             }
466         };
467         lazyView.postDelayed(delayedTask[0], RESTORE_FOCUS_RETRY_DELAY_MS);
468     }
469 
removeCallbacks(@onNull LazyLayoutView lazyLayoutView, ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener, Runnable[] onLayoutCompleteListener, Runnable[] delayedTask)470     private static void removeCallbacks(@NonNull LazyLayoutView lazyLayoutView,
471             ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener,
472             Runnable[] onLayoutCompleteListener,
473             Runnable[] delayedTask) {
474         lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]);
475         if (!(lazyLayoutView instanceof View)) {
476             return;
477         }
478         View lazyView = (View) lazyLayoutView;
479         lazyView.removeCallbacks(delayedTask[0]);
480         lazyView.getViewTreeObserver()
481                 .removeOnGlobalLayoutListener(onGlobalLayoutListener[0]);
482     }
483 
initFocusImmediately(@onNull LazyLayoutView lazyLayoutView)484     private static boolean initFocusImmediately(@NonNull LazyLayoutView lazyLayoutView) {
485         if (!(lazyLayoutView instanceof View)) {
486             return false;
487         }
488         View lazyView = (View) lazyLayoutView;
489         // If there is a visible view focused in the view tree, just return true.
490         if (hasVisibleFocusInRoot(lazyView)) {
491             return true;
492         }
493         return ViewUtils.adjustFocusImmediately(lazyView, /* currentFocus= */ null);
494     }
495 
hasVisibleFocusInRoot(@onNull View view)496     private static boolean hasVisibleFocusInRoot(@NonNull View view) {
497         View focus = view.getRootView().findFocus();
498         return focus != null && !(focus instanceof FocusParkingView);
499     }
500 
501     @VisibleForTesting
502     @FocusLevel
getFocusLevel(@ullable View view)503     static int getFocusLevel(@Nullable View view) {
504         if (view == null || view instanceof FocusParkingView || !view.isShown()) {
505             return NO_FOCUS;
506         }
507         if (view.isFocusedByDefault()) {
508             return FOCUSED_BY_DEFAULT;
509         }
510         if (isDefaultFocus(view)) {
511             return DEFAULT_FOCUS;
512         }
513         if (isImplicitDefaultFocusView(view)) {
514             return IMPLICIT_DEFAULT_FOCUS;
515         }
516         if (view.isSelected()) {
517             return SELECTED_FOCUS;
518         }
519         if (isScrollableContainer(view)) {
520             return SCROLLABLE_CONTAINER_FOCUS;
521         }
522         return REGULAR_FOCUS;
523     }
524 
525     /** Returns whether the {@code view} is a {@code app:defaultFocus} view. */
isDefaultFocus(@onNull View view)526     private static boolean isDefaultFocus(@NonNull View view) {
527         IFocusArea parent = getAncestorFocusArea(view);
528         return parent != null && view == parent.getDefaultFocusView();
529     }
530 
531     /**
532      * Returns whether the {@code view} is an implicit default focus view, i.e., the selected
533      * item or the first focusable item in a rotary container.
534      */
535     @VisibleForTesting
isImplicitDefaultFocusView(@onNull View view)536     static boolean isImplicitDefaultFocusView(@NonNull View view) {
537         ViewGroup rotaryContainer = null;
538         ViewParent parent = view.getParent();
539         while (parent != null && parent instanceof ViewGroup) {
540             ViewGroup viewGroup = (ViewGroup) parent;
541             if (isRotaryContainer(viewGroup)) {
542                 rotaryContainer = viewGroup;
543                 break;
544             }
545             parent = parent.getParent();
546         }
547         if (rotaryContainer == null) {
548             return false;
549         }
550         return findFirstSelectedFocusableDescendant(rotaryContainer) == view
551                 || findFirstFocusableDescendant(rotaryContainer) == view;
552     }
553 
isRotaryContainer(@onNull View view)554     private static boolean isRotaryContainer(@NonNull View view) {
555         CharSequence contentDescription = view.getContentDescription();
556         return TextUtils.equals(contentDescription, ROTARY_CONTAINER)
557                 || TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
558                 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
559     }
560 
isScrollableContainer(@onNull View view)561     private static boolean isScrollableContainer(@NonNull View view) {
562         CharSequence contentDescription = view.getContentDescription();
563         return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
564                 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
565     }
566 
isFocusDelegatingContainer(@onNull View view)567     private static boolean isFocusDelegatingContainer(@NonNull View view) {
568         CharSequence contentDescription = view.getContentDescription();
569         return TextUtils.equals(contentDescription, ROTARY_FOCUS_DELEGATING_CONTAINER);
570     }
571 
572     /**
573      * Searches the {@code root}'s descendants for the first {@code app:defaultFocus} view and
574      * focuses on it, if any.
575      *
576      * @param root the root view to search from
577      * @return whether succeeded
578      */
focusOnDefaultFocusView(@onNull View root)579     private static boolean focusOnDefaultFocusView(@NonNull View root) {
580         View defaultFocus = findDefaultFocusView(root);
581         return requestFocus(defaultFocus);
582     }
583 
584     /**
585      * Searches the {@code root}'s descendants for the first {@code android:focusedByDefault} view
586      * and focuses on it if any.
587      *
588      * @param root the root view to search from
589      * @return whether succeeded
590      */
focusOnFocusedByDefaultView(@onNull View root)591     private static boolean focusOnFocusedByDefaultView(@NonNull View root) {
592         View focusedByDefault = findFocusedByDefaultView(root);
593         return requestFocus(focusedByDefault);
594     }
595 
596     /**
597      * Searches the {@code root}'s descendants for the first implicit default focus view and focuses
598      * on it, if any.
599      *
600      * @param root the root view to search from
601      * @return whether succeeded
602      */
focusOnImplicitDefaultFocusView(@onNull View root)603     private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) {
604         View implicitDefaultFocus = findImplicitDefaultFocusView(root);
605         return requestFocus(implicitDefaultFocus);
606     }
607 
608     /**
609      * Searches the {@code root}'s descendants for the first selected view and focuses on it, if
610      * any.
611      *
612      * @param root the root view to search from
613      * @return whether succeeded
614      */
focusOnSelectedView(@onNull View root)615     private static boolean focusOnSelectedView(@NonNull View root) {
616         View selectedView = findFirstSelectedFocusableDescendant(root);
617         return requestFocus(selectedView);
618     }
619 
620     /**
621      * Searches the {@code root}'s descendants for the focusable view in depth first order
622      * (excluding the FocusParkingView and scrollable containers), and tries to focus on it.
623      * If focusing on the first such view fails, keeps trying other views in depth first order
624      * until succeeds or there are no more such views.
625      *
626      * @param root the root view to search from
627      * @return whether succeeded
628      */
focusOnFirstRegularView(@onNull View root)629     public static boolean focusOnFirstRegularView(@NonNull View root) {
630         View focusedView = ViewUtils.depthFirstSearch(root,
631                 /* targetPredicate= */
632                 v -> v != root && !isScrollableContainer(v) && canTakeFocus(v) && requestFocus(v),
633                 /* skipPredicate= */ v -> !v.isShown());
634         return focusedView != null;
635     }
636 
637     /**
638      * Focuses on the first scrollable container in the view tree, if any.
639      *<p>
640      * Unlike other similar methods, don't skip the {@code root} because some callers may pass
641      * a scrollable container as parameter.
642      *
643      * @param root the root of the view tree
644      * @return whether succeeded
645      */
focusOnScrollableContainer(@onNull View root)646     private static boolean focusOnScrollableContainer(@NonNull View root) {
647         View focusedView = ViewUtils.depthFirstSearch(root,
648                 /* targetPredicate= */ v -> isScrollableContainer(v) && canTakeFocus(v),
649                 /* skipPredicate= */ v -> !v.isShown());
650         return requestFocus(focusedView);
651     }
652 
653     /**
654      * Searches the {@code root}'s descendants in depth first order, and returns the first
655      * {@code app:defaultFocus} view that can take focus. Returns null if not found.
656      */
657     @Nullable
findDefaultFocusView(@onNull View view)658     private static View findDefaultFocusView(@NonNull View view) {
659         if (!view.isShown()) {
660             return null;
661         }
662         if (view instanceof IFocusArea) {
663             IFocusArea focusArea = (IFocusArea) view;
664             View defaultFocus = focusArea.getDefaultFocusView();
665             if (defaultFocus != null && canTakeFocus(defaultFocus)) {
666                 return defaultFocus;
667             }
668         } else if (view instanceof ViewGroup) {
669             ViewGroup parent = (ViewGroup) view;
670             for (int i = 0; i < parent.getChildCount(); i++) {
671                 View child = parent.getChildAt(i);
672                 View defaultFocus = findDefaultFocusView(child);
673                 if (defaultFocus != null) {
674                     return defaultFocus;
675                 }
676             }
677         }
678         return null;
679     }
680 
681     /**
682      * Searches the {@code view}'s descendants in depth first order, and returns the first
683      * {@code android:focusedByDefault} view that can take focus. Returns null if not found.
684      */
685     @VisibleForTesting
686     @Nullable
findFocusedByDefaultView(@onNull View view)687     static View findFocusedByDefaultView(@NonNull View view) {
688         return depthFirstSearch(view,
689                 /* targetPredicate= */ v -> v != view && v.isFocusedByDefault() && canTakeFocus(v),
690                 /* skipPredicate= */ v -> !v.isShown());
691     }
692 
693     /**
694      * Searches the {@code view}'s descendants in depth first order, and returns the first
695      * implicit default focus view, i.e., the selected item or the first focusable item in the
696      * first rotary container. Returns null if not found.
697      */
698     @VisibleForTesting
699     @Nullable
findImplicitDefaultFocusView(@onNull View view)700     static View findImplicitDefaultFocusView(@NonNull View view) {
701         View rotaryContainer = findRotaryContainer(view);
702         if (rotaryContainer == null) {
703             return null;
704         }
705 
706         View selectedItem = findFirstSelectedFocusableDescendant(rotaryContainer);
707 
708         return selectedItem != null
709                 ? selectedItem
710                 : findFirstFocusableDescendant(rotaryContainer);
711     }
712 
713     /**
714      * Searches the {@code view}'s descendants in depth first order, and returns the first view
715      * that can take focus, or null if not found.
716      */
717     @VisibleForTesting
718     @Nullable
findFirstFocusableDescendant(@onNull View view)719     static View findFirstFocusableDescendant(@NonNull View view) {
720         return depthFirstSearch(view,
721                 /* targetPredicate= */ v -> v != view && canTakeFocus(v),
722                 /* skipPredicate= */ v -> !v.isShown());
723     }
724 
725     /**
726      * Searches the {@code view}'s descendants in depth first order, and returns the first view
727      * that is selected and can take focus, or null if not found.
728      */
729     @VisibleForTesting
730     @Nullable
findFirstSelectedFocusableDescendant(@onNull View view)731     static View findFirstSelectedFocusableDescendant(@NonNull View view) {
732         return depthFirstSearch(view,
733                 /* targetPredicate= */ v -> v != view && v.isSelected() && canTakeFocus(v),
734                 /* skipPredicate= */ v -> !v.isShown());
735     }
736 
737     /**
738      * Searches the {@code view} and its descendants in depth first order, and returns the first
739      * rotary container shown on the screen. If the rotary containers are LazyLayoutViews, returns
740      * the first layout completed one. Returns null if not found.
741      * <p>
742      * Unlike other similar methods, don't skip the {@code root} because some callers may pass
743      * a rotary container as parameter.
744      */
745     @Nullable
findRotaryContainer(@onNull View view)746     private static View findRotaryContainer(@NonNull View view) {
747         return depthFirstSearch(view,
748                 /* targetPredicate= */ ViewUtils::isRotaryContainer,
749                 /* skipPredicate= */ v -> {
750                     if (!v.isShown()) {
751                         return true;
752                     }
753                     if (v instanceof LazyLayoutView) {
754                         LazyLayoutView lazyLayoutView = (LazyLayoutView) v;
755                         return !lazyLayoutView.isLayoutCompleted();
756                     }
757                     return false;
758                 });
759     }
760 
761     /**
762      * Searches the {@code view} and its descendants in depth first order, and returns the first
763      * LazyLayoutView shown on the screen. Returns null if not found.
764      * <p>
765      * Unlike other similar methods, don't skip the {@code root} because some callers may pass
766      * a LazyLayoutView as parameter.
767      */
768     @Nullable
769     private static LazyLayoutView findLazyLayoutView(@NonNull View view) {
770         return (LazyLayoutView) depthFirstSearch(view,
771                 /* targetPredicate= */ v -> v instanceof LazyLayoutView,
772                 /* skipPredicate= */ v -> !v.isShown());
773     }
774 
775     /**
776      * Searches the {@code view} and its descendants in depth first order, skips the views that
777      * match {@code skipPredicate} and their descendants, and returns the first view that matches
778      * {@code targetPredicate}. Returns null if not found.
779      */
780     @Nullable
781     private static View depthFirstSearch(@NonNull View view,
782             @NonNull Predicate<View> targetPredicate,
783             @Nullable Predicate<View> skipPredicate) {
784         if (skipPredicate != null && skipPredicate.test(view)) {
785             return null;
786         }
787         if (targetPredicate.test(view)) {
788             return view;
789         }
790         if (view instanceof ViewGroup) {
791             ViewGroup parent = (ViewGroup) view;
792             for (int i = 0; i < parent.getChildCount(); i++) {
793                 View child = parent.getChildAt(i);
794                 View target = depthFirstSearch(child, targetPredicate, skipPredicate);
795                 if (target != null) {
796                     return target;
797                 }
798             }
799         }
800         return null;
801     }
802 
803     /** Returns whether {@code view} can be focused. */
804     private static boolean canTakeFocus(@NonNull View view) {
805         boolean focusable = view.isFocusable() || isFocusDelegatingContainer(view);
806         return focusable && view.isEnabled() && view.isShown()
807                 && view.getWidth() > 0 && view.getHeight() > 0 && view.isAttachedToWindow()
808                 && !(view instanceof FocusParkingView)
809                 // If it's a scrollable container, it can be focused only when it has no focusable
810                 // descendants. We focus on it so that the rotary controller can scroll it.
811                 && (!isScrollableContainer(view) || findFirstFocusableDescendant(view) == null);
812     }
813 
814     /**
815      * Enables rotary scrolling for {@code view}, either vertically (if {@code isVertical} is true)
816      * or horizontally (if {@code isVertical} is false). With rotary scrolling enabled, rotating the
817      * rotary controller will scroll rather than moving the focus when moving the focus would cause
818      * a lot of scrolling. Rotary scrolling should be enabled for scrolling views which contain
819      * content which the user may want to see but can't interact with, either alone or along with
820      * interactive (focusable) content.
821      */
822     public static void setRotaryScrollEnabled(@NonNull View view, boolean isVertical) {
823         view.setContentDescription(
824                 isVertical ? ROTARY_VERTICALLY_SCROLLABLE : ROTARY_HORIZONTALLY_SCROLLABLE);
825     }
826 }
827