blob: f5abd4416d15b7ae2efed0460574c30a4d9e4e90 [file] [log] [blame]
/*
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.car.ui.utils;
import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_FOCUS_DELEGATING_CONTAINER;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
import android.app.Activity;
import android.content.Context;
import android.content.ContextWrapper;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.ViewTreeObserver;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.car.ui.FocusParkingView;
import com.android.car.ui.IFocusArea;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.Predicate;
/** Utility class for helpful methods related to {@link View} objects. */
@SuppressWarnings("AndroidJdkLibsChecker")
public final class ViewUtils {
private static final String TAG = "ViewUtils";
/**
* How many milliseconds to wait before trying to restore the focus inside the LazyLayoutView
* the second time.
*/
@VisibleForTesting
static final int RESTORE_FOCUS_RETRY_DELAY_MS = 3000;
/**
* No view is focused, the focused view is not shown, or the focused view is a FocusParkingView.
*/
@VisibleForTesting
static final int NO_FOCUS = 1;
/** A scrollable container is focused. */
@VisibleForTesting
static final int SCROLLABLE_CONTAINER_FOCUS = 2;
/**
* A regular view is focused. A regular View is a View that is neither a FocusParkingView nor a
* scrollable container.
*/
@VisibleForTesting
static final int REGULAR_FOCUS = 3;
/** The selected view is focused. */
@VisibleForTesting
static final int SELECTED_FOCUS = 4;
/**
* An implicit default focus view (i.e., the selected item or the first focusable item in a
* scrollable container) is focused.
*/
@VisibleForTesting
static final int IMPLICIT_DEFAULT_FOCUS = 5;
/** The {@code app:defaultFocus} view is focused. */
@VisibleForTesting
static final int DEFAULT_FOCUS = 6;
/** The {@code android:focusedByDefault} view is focused. */
@VisibleForTesting
static final int FOCUSED_BY_DEFAULT = 7;
/**
* Focus level of a view. When adjusting the focus, the view with the highest focus level will
* be focused.
*/
@IntDef(flag = true, value = {NO_FOCUS, SCROLLABLE_CONTAINER_FOCUS, REGULAR_FOCUS,
SELECTED_FOCUS, IMPLICIT_DEFAULT_FOCUS, DEFAULT_FOCUS, FOCUSED_BY_DEFAULT})
@Retention(RetentionPolicy.SOURCE)
private @interface FocusLevel {
}
/** This is a utility class. */
private ViewUtils() {
}
/**
* An interface used to restore focus inside a view when its layout is completed.
* <p>
* The view that needs to restore focus lazily should implement this interface.
*/
public interface LazyLayoutView {
/**
* Returns whether the view's layout is completed and ready to restore focus inside it.
*/
boolean isLayoutCompleted();
/**
* Adds a listener to be called when the view's layout is completed.
*/
void addOnLayoutCompleteListener(@Nullable Runnable runnable);
/**
* Removes a listener to be called when the view's layout is completed.
*/
void removeOnLayoutCompleteListener(@Nullable Runnable runnable);
}
/** Returns whether the {@code view} is in multi-window mode. */
public static boolean isInMultiWindowMode(@NonNull View view) {
Context context = view.getContext();
// Find the Activity context in case the view was inflated with Hilt dependency injector.
Activity activity = findActivity(context);
return activity != null && activity.isInMultiWindowMode();
}
/** Returns the Activity of the given {@code context}. */
@Nullable
public static Activity findActivity(@Nullable Context context) {
while (context instanceof ContextWrapper
&& !(context instanceof Activity)) {
context = ((ContextWrapper) context).getBaseContext();
}
if (context instanceof Activity) {
return (Activity) context;
}
return null;
}
/** Returns whether the {@code descendant} view is a descendant of the {@code view}. */
public static boolean isDescendant(@Nullable View descendant, @Nullable View view) {
if (descendant == null || view == null) {
return false;
}
ViewParent parent = descendant.getParent();
while (parent != null) {
if (parent == view) {
return true;
}
parent = parent.getParent();
}
return false;
}
/**
* Hides the focus by searching the view tree for the {@link FocusParkingView}
* and focusing on it.
*
* @param root the root view to search from
* @return true if the FocusParkingView was successfully found and focused
* or if it was already focused
*/
public static boolean hideFocus(@NonNull View root) {
FocusParkingView fpv = findFocusParkingView(root);
if (fpv == null) {
return false;
}
if (fpv.isFocused()) {
return true;
}
return fpv.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
}
/**
* Returns the first {@link FocusParkingView} of the view tree, if any. Returns null if not
* found.
*/
@VisibleForTesting
public static FocusParkingView findFocusParkingView(@NonNull View root) {
return (FocusParkingView) depthFirstSearch(root,
/* targetPredicate= */ v -> v instanceof FocusParkingView,
/* skipPredicate= */ null);
}
/** Gets the ancestor IFocusArea of the {@code view}, if any. Returns null if not found. */
@Nullable
public static IFocusArea getAncestorFocusArea(@NonNull View view) {
ViewParent parent = view.getParent();
while (parent != null) {
if (parent instanceof IFocusArea) {
return (IFocusArea) parent;
}
parent = parent.getParent();
}
return null;
}
/**
* Gets the ancestor scrollable container of the {@code view}, if any. Returns null if not
* found.
*/
@Nullable
public static ViewGroup getAncestorScrollableContainer(@Nullable View view) {
if (view == null) {
return null;
}
ViewParent parent = view.getParent();
// A scrollable container can't contain an IFocusArea, so let's return earlier if we found
// an IFocusArea.
while (parent != null && parent instanceof ViewGroup && !(parent instanceof IFocusArea)) {
ViewGroup viewGroup = (ViewGroup) parent;
if (isScrollableContainer(viewGroup)) {
return viewGroup;
}
parent = parent.getParent();
}
return null;
}
/**
* Focuses on the {@code view} if it can be focused.
*
* @return whether it was successfully focused or already focused
*/
public static boolean requestFocus(@Nullable View view) {
if (view == null || !canTakeFocus(view)) {
return false;
}
if (view.isFocused()) {
return true;
}
// Exit touch mode and focus the view. The view may not be focusable in touch mode, so we
// need to exit touch mode before focusing it.
return view.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null);
}
/**
* Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
* the view's FocusLevel is higher than the {@code currentFocus}'s FocusLevel, focuses on the
* view. If it tried to focus on a LazyLayoutView but failed, requests to adjust the focus
* inside the LazyLayoutView later.
*
* @return whether the view is focused
*/
public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) {
@FocusLevel int currentLevel = getFocusLevel(currentFocus);
return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
/* defaultFocusOverridesHistory= */ false);
}
/**
* Similar to {@link #adjustFocus(View, View)} but without requesting to adjust the focus
* inside the LazyLayoutView later.
*/
public static boolean adjustFocusImmediately(@NonNull View root, @Nullable View currentFocus) {
@FocusLevel int currentLevel = getFocusLevel(currentFocus);
return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
/* defaultFocusOverridesHistory= */ false, /* delayed= */ false);
}
/**
* If the {@code currentFocus}'s FocusLevel is lower than REGULAR_FOCUS, adjusts focus within
* {@code root}. See {@link #adjustFocus(View, int)}. Otherwise no-op.
*
* @return whether the focus has changed
*/
public static boolean initFocus(@NonNull View root, @Nullable View currentFocus) {
@FocusLevel int currentLevel = getFocusLevel(currentFocus);
if (currentLevel >= REGULAR_FOCUS) {
return false;
}
return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
/* defaultFocusOverridesHistory= */ false);
}
/**
* Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
* the view's FocusLevel is higher than {@code currentLevel}, focuses on the view.
*
* @return whether the view is focused
*/
@VisibleForTesting
static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) {
return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null,
/* defaultFocusOverridesHistory= */ false);
}
/**
* Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel} and
* focuses on it or the {@code cachedFocusedView}.
*
* @return whether the view is focused
*/
public static boolean adjustFocus(@NonNull View root,
@Nullable View cachedFocusedView,
boolean defaultFocusOverridesHistory) {
return adjustFocus(root, NO_FOCUS, cachedFocusedView, defaultFocusOverridesHistory);
}
private static boolean adjustFocus(@NonNull View root,
@FocusLevel int currentLevel,
@Nullable View cachedFocusedView,
boolean defaultFocusOverridesHistory) {
return adjustFocus(root, currentLevel, cachedFocusedView, defaultFocusOverridesHistory,
/* delayed= */ true);
}
/**
* Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If
* the view's FocusLevel is higher than {@code currentLevel}, focuses on the view or {@code
* cachedFocusedView}.
*
* @return whether the view is focused
*/
private static boolean adjustFocus(@NonNull View root,
@FocusLevel int currentLevel,
@Nullable View cachedFocusedView,
boolean defaultFocusOverridesHistory,
boolean delayed) {
// If the previously focused view has higher priority than the default focus, try to focus
// on the previously focused view.
if (!defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
return true;
}
// Try to focus on the default focus view.
if (currentLevel < FOCUSED_BY_DEFAULT && focusOnFocusedByDefaultView(root)) {
return true;
}
if (currentLevel < DEFAULT_FOCUS && focusOnDefaultFocusView(root)) {
return true;
}
if (currentLevel < IMPLICIT_DEFAULT_FOCUS && focusOnImplicitDefaultFocusView(root)) {
return true;
}
if (currentLevel < SELECTED_FOCUS && focusOnSelectedView(root)) {
return true;
}
// When delayed is true, if there is a LazyLayoutView but it failed to adjust focus
// inside it because it hasn't loaded yet or it's loaded but has no descendants, request to
// restore focus inside it later, and return false for now.
if (delayed && currentLevel < IMPLICIT_DEFAULT_FOCUS) {
LazyLayoutView lazyLayoutView = findLazyLayoutView(root);
if (lazyLayoutView != null && !lazyLayoutView.isLayoutCompleted()) {
initFocusDelayed(lazyLayoutView);
return false;
}
}
// If the previously focused view has lower priority than the default focus, try to focus
// on the previously focused view.
if (defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) {
return true;
}
// Try to focus on other views with low focus levels.
if (currentLevel < REGULAR_FOCUS && focusOnFirstRegularView(root)) {
return true;
}
if (currentLevel < SCROLLABLE_CONTAINER_FOCUS) {
return focusOnScrollableContainer(root);
}
return false;
}
/**
* If the {code lazyLayoutView} has a focusable descendant and no visible view is focused,
* focuses on the descendant. Otherwise tries again when the {code lazyLayoutView} completes
* layout, shows up on the screen, or after a timeout, whichever comes first.
*/
public static void initFocus(@NonNull LazyLayoutView lazyLayoutView) {
if (initFocusImmediately(lazyLayoutView)) {
return;
}
initFocusDelayed(lazyLayoutView);
}
private static void initFocusDelayed(@NonNull LazyLayoutView lazyLayoutView) {
if (!(lazyLayoutView instanceof View)) {
return;
}
View lazyView = (View) lazyLayoutView;
Runnable[] onLayoutCompleteListener = new Runnable[1];
Runnable[] delayedTask = new Runnable[1];
ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener =
new ViewTreeObserver.OnGlobalLayoutListener[1];
// If the lazyLayoutView has not completed layout yet, try to restore focus inside it once
// it's completed.
if (!lazyLayoutView.isLayoutCompleted()) {
Log.v(TAG, "The lazyLayoutView has not completed layout: " + lazyLayoutView);
onLayoutCompleteListener[0] = () -> {
Log.v(TAG, "The lazyLayoutView completed layout: "
+ lazyLayoutView);
if (initFocusImmediately(lazyLayoutView)) {
Log.v(TAG, "Focus restored after lazyLayoutView completed layout");
// Remove the other tasks only when onLayoutCompleteListener has initialized the
// focus successfully, because the other tasks need to kick in when it fails,
// such as when it has completed layout but has no descendants to take focus,
// or it's not shown (e.g., its ancestor is invisible). In the former case,
// the delayedTask needs to run after a timeout, while in the latter case the
// onGlobalLayoutListener needs to run when it shows up on the screen.
removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
onLayoutCompleteListener, delayedTask);
}
};
lazyLayoutView.addOnLayoutCompleteListener(onLayoutCompleteListener[0]);
}
// If the lazyLayoutView is not shown yet, try to restore focus inside it once it's shown.
if (!lazyView.isShown()) {
Log.d(TAG, "The lazyLayoutView is not shown: " + lazyLayoutView);
onGlobalLayoutListener[0] = () -> {
Log.d(TAG, "onGlobalLayoutListener is called");
if (lazyView.isShown()) {
Log.d(TAG, "The lazyLayoutView is shown");
if (initFocusImmediately(lazyLayoutView)) {
Log.v(TAG, "Focus restored after showing lazyLayoutView");
removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
onLayoutCompleteListener, delayedTask);
}
}
};
lazyView.getViewTreeObserver()
.addOnGlobalLayoutListener(onGlobalLayoutListener[0]);
}
// Run a delayed task as fallback.
delayedTask[0] = () -> {
Log.d(TAG, "Starting delayedTask");
removeCallbacks(lazyLayoutView, onGlobalLayoutListener,
onLayoutCompleteListener, delayedTask);
if (!hasVisibleFocusInRoot(lazyView)) {
// Make one last attempt to restore focus inside the lazyLayoutView. For example,
// in ViewUtilsTest.testInitFocus_inLazyLayoutView5(), when lazyLayoutView's parent
// becomes visible, onGlobalLayoutListener won't be triggered, so it won't try to
// restore focus there.
if (lazyLayoutView.isLayoutCompleted() && lazyView.isShown()) {
Log.d(TAG, "Last attempt to restore focus inside the lazyLayoutView");
if (initFocusImmediately(lazyLayoutView)) {
Log.d(TAG, "Restored focus inside the lazyLayoutView");
return;
}
}
// Search the view tree and find the view to focus when it failed to restore focus
// inside the lazyLayoutView.
adjustFocus(lazyView.getRootView(), NO_FOCUS, /* cachedFocusedView= */ null,
/* defaultFocusOverridesHistory= */ false, /* delayed= */ false);
}
};
lazyView.postDelayed(delayedTask[0], RESTORE_FOCUS_RETRY_DELAY_MS);
}
private static void removeCallbacks(@NonNull LazyLayoutView lazyLayoutView,
ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener,
Runnable[] onLayoutCompleteListener,
Runnable[] delayedTask) {
lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]);
if (!(lazyLayoutView instanceof View)) {
return;
}
View lazyView = (View) lazyLayoutView;
lazyView.removeCallbacks(delayedTask[0]);
lazyView.getViewTreeObserver()
.removeOnGlobalLayoutListener(onGlobalLayoutListener[0]);
}
private static boolean initFocusImmediately(@NonNull LazyLayoutView lazyLayoutView) {
if (!(lazyLayoutView instanceof View)) {
return false;
}
View lazyView = (View) lazyLayoutView;
// If there is a visible view focused in the view tree, just return true.
if (hasVisibleFocusInRoot(lazyView)) {
return true;
}
return ViewUtils.adjustFocusImmediately(lazyView, /* currentFocus= */ null);
}
private static boolean hasVisibleFocusInRoot(@NonNull View view) {
View focus = view.getRootView().findFocus();
return focus != null && !(focus instanceof FocusParkingView);
}
@VisibleForTesting
@FocusLevel
static int getFocusLevel(@Nullable View view) {
if (view == null || view instanceof FocusParkingView || !view.isShown()) {
return NO_FOCUS;
}
if (view.isFocusedByDefault()) {
return FOCUSED_BY_DEFAULT;
}
if (isDefaultFocus(view)) {
return DEFAULT_FOCUS;
}
if (isImplicitDefaultFocusView(view)) {
return IMPLICIT_DEFAULT_FOCUS;
}
if (view.isSelected()) {
return SELECTED_FOCUS;
}
if (isScrollableContainer(view)) {
return SCROLLABLE_CONTAINER_FOCUS;
}
return REGULAR_FOCUS;
}
/** Returns whether the {@code view} is a {@code app:defaultFocus} view. */
private static boolean isDefaultFocus(@NonNull View view) {
IFocusArea parent = getAncestorFocusArea(view);
return parent != null && view == parent.getDefaultFocusView();
}
/**
* Returns whether the {@code view} is an implicit default focus view, i.e., the selected
* item or the first focusable item in a rotary container.
*/
@VisibleForTesting
static boolean isImplicitDefaultFocusView(@NonNull View view) {
ViewGroup rotaryContainer = null;
ViewParent parent = view.getParent();
while (parent != null && parent instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) parent;
if (isRotaryContainer(viewGroup)) {
rotaryContainer = viewGroup;
break;
}
parent = parent.getParent();
}
if (rotaryContainer == null) {
return false;
}
return findFirstSelectedFocusableDescendant(rotaryContainer) == view
|| findFirstFocusableDescendant(rotaryContainer) == view;
}
private static boolean isRotaryContainer(@NonNull View view) {
CharSequence contentDescription = view.getContentDescription();
return TextUtils.equals(contentDescription, ROTARY_CONTAINER)
|| TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
|| TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
}
private static boolean isScrollableContainer(@NonNull View view) {
CharSequence contentDescription = view.getContentDescription();
return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
|| TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
}
private static boolean isFocusDelegatingContainer(@NonNull View view) {
CharSequence contentDescription = view.getContentDescription();
return TextUtils.equals(contentDescription, ROTARY_FOCUS_DELEGATING_CONTAINER);
}
/**
* Searches the {@code root}'s descendants for the first {@code app:defaultFocus} view and
* focuses on it, if any.
*
* @param root the root view to search from
* @return whether succeeded
*/
private static boolean focusOnDefaultFocusView(@NonNull View root) {
View defaultFocus = findDefaultFocusView(root);
return requestFocus(defaultFocus);
}
/**
* Searches the {@code root}'s descendants for the first {@code android:focusedByDefault} view
* and focuses on it if any.
*
* @param root the root view to search from
* @return whether succeeded
*/
private static boolean focusOnFocusedByDefaultView(@NonNull View root) {
View focusedByDefault = findFocusedByDefaultView(root);
return requestFocus(focusedByDefault);
}
/**
* Searches the {@code root}'s descendants for the first implicit default focus view and focuses
* on it, if any.
*
* @param root the root view to search from
* @return whether succeeded
*/
private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) {
View implicitDefaultFocus = findImplicitDefaultFocusView(root);
return requestFocus(implicitDefaultFocus);
}
/**
* Searches the {@code root}'s descendants for the first selected view and focuses on it, if
* any.
*
* @param root the root view to search from
* @return whether succeeded
*/
private static boolean focusOnSelectedView(@NonNull View root) {
View selectedView = findFirstSelectedFocusableDescendant(root);
return requestFocus(selectedView);
}
/**
* Searches the {@code root}'s descendants for the focusable view in depth first order
* (excluding the FocusParkingView and scrollable containers), and tries to focus on it.
* If focusing on the first such view fails, keeps trying other views in depth first order
* until succeeds or there are no more such views.
*
* @param root the root view to search from
* @return whether succeeded
*/
public static boolean focusOnFirstRegularView(@NonNull View root) {
View focusedView = ViewUtils.depthFirstSearch(root,
/* targetPredicate= */
v -> v != root && !isScrollableContainer(v) && canTakeFocus(v) && requestFocus(v),
/* skipPredicate= */ v -> !v.isShown());
return focusedView != null;
}
/**
* Focuses on the first scrollable container in the view tree, if any.
*<p>
* Unlike other similar methods, don't skip the {@code root} because some callers may pass
* a scrollable container as parameter.
*
* @param root the root of the view tree
* @return whether succeeded
*/
private static boolean focusOnScrollableContainer(@NonNull View root) {
View focusedView = ViewUtils.depthFirstSearch(root,
/* targetPredicate= */ v -> isScrollableContainer(v) && canTakeFocus(v),
/* skipPredicate= */ v -> !v.isShown());
return requestFocus(focusedView);
}
/**
* Searches the {@code root}'s descendants in depth first order, and returns the first
* {@code app:defaultFocus} view that can take focus. Returns null if not found.
*/
@Nullable
private static View findDefaultFocusView(@NonNull View view) {
if (!view.isShown()) {
return null;
}
if (view instanceof IFocusArea) {
IFocusArea focusArea = (IFocusArea) view;
View defaultFocus = focusArea.getDefaultFocusView();
if (defaultFocus != null && canTakeFocus(defaultFocus)) {
return defaultFocus;
}
} else if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
View defaultFocus = findDefaultFocusView(child);
if (defaultFocus != null) {
return defaultFocus;
}
}
}
return null;
}
/**
* Searches the {@code view}'s descendants in depth first order, and returns the first
* {@code android:focusedByDefault} view that can take focus. Returns null if not found.
*/
@VisibleForTesting
@Nullable
static View findFocusedByDefaultView(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ v -> v != view && v.isFocusedByDefault() && canTakeFocus(v),
/* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view}'s descendants in depth first order, and returns the first
* implicit default focus view, i.e., the selected item or the first focusable item in the
* first rotary container. Returns null if not found.
*/
@VisibleForTesting
@Nullable
static View findImplicitDefaultFocusView(@NonNull View view) {
View rotaryContainer = findRotaryContainer(view);
if (rotaryContainer == null) {
return null;
}
View selectedItem = findFirstSelectedFocusableDescendant(rotaryContainer);
return selectedItem != null
? selectedItem
: findFirstFocusableDescendant(rotaryContainer);
}
/**
* Searches the {@code view}'s descendants in depth first order, and returns the first view
* that can take focus, or null if not found.
*/
@VisibleForTesting
@Nullable
static View findFirstFocusableDescendant(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ v -> v != view && canTakeFocus(v),
/* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view}'s descendants in depth first order, and returns the first view
* that is selected and can take focus, or null if not found.
*/
@VisibleForTesting
@Nullable
static View findFirstSelectedFocusableDescendant(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ v -> v != view && v.isSelected() && canTakeFocus(v),
/* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view} and its descendants in depth first order, and returns the first
* rotary container shown on the screen. If the rotary containers are LazyLayoutViews, returns
* the first layout completed one. Returns null if not found.
* <p>
* Unlike other similar methods, don't skip the {@code root} because some callers may pass
* a rotary container as parameter.
*/
@Nullable
private static View findRotaryContainer(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ ViewUtils::isRotaryContainer,
/* skipPredicate= */ v -> {
if (!v.isShown()) {
return true;
}
if (v instanceof LazyLayoutView) {
LazyLayoutView lazyLayoutView = (LazyLayoutView) v;
return !lazyLayoutView.isLayoutCompleted();
}
return false;
});
}
/**
* Searches the {@code view} and its descendants in depth first order, and returns the first
* LazyLayoutView shown on the screen. Returns null if not found.
* <p>
* Unlike other similar methods, don't skip the {@code root} because some callers may pass
* a LazyLayoutView as parameter.
*/
@Nullable
private static LazyLayoutView findLazyLayoutView(@NonNull View view) {
return (LazyLayoutView) depthFirstSearch(view,
/* targetPredicate= */ v -> v instanceof LazyLayoutView,
/* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view} and its descendants in depth first order, skips the views that
* match {@code skipPredicate} and their descendants, and returns the first view that matches
* {@code targetPredicate}. Returns null if not found.
*/
@Nullable
private static View depthFirstSearch(@NonNull View view,
@NonNull Predicate<View> targetPredicate,
@Nullable Predicate<View> skipPredicate) {
if (skipPredicate != null && skipPredicate.test(view)) {
return null;
}
if (targetPredicate.test(view)) {
return view;
}
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
for (int i = 0; i < parent.getChildCount(); i++) {
View child = parent.getChildAt(i);
View target = depthFirstSearch(child, targetPredicate, skipPredicate);
if (target != null) {
return target;
}
}
}
return null;
}
/** Returns whether {@code view} can be focused. */
private static boolean canTakeFocus(@NonNull View view) {
boolean focusable = view.isFocusable() || isFocusDelegatingContainer(view);
return focusable && view.isEnabled() && view.isShown()
&& view.getWidth() > 0 && view.getHeight() > 0 && view.isAttachedToWindow()
&& !(view instanceof FocusParkingView)
// If it's a scrollable container, it can be focused only when it has no focusable
// descendants. We focus on it so that the rotary controller can scroll it.
&& (!isScrollableContainer(view) || findFirstFocusableDescendant(view) == null);
}
/**
* Enables rotary scrolling for {@code view}, either vertically (if {@code isVertical} is true)
* or horizontally (if {@code isVertical} is false). With rotary scrolling enabled, rotating the
* rotary controller will scroll rather than moving the focus when moving the focus would cause
* a lot of scrolling. Rotary scrolling should be enabled for scrolling views which contain
* content which the user may want to see but can't interact with, either alone or along with
* interactive (focusable) content.
*/
public static void setRotaryScrollEnabled(@NonNull View view, boolean isVertical) {
view.setContentDescription(
isVertical ? ROTARY_VERTICALLY_SCROLLABLE : ROTARY_HORIZONTALLY_SCROLLABLE);
}
}