blob: f1ffad61cb3dcec724f4ad4a0af0aa1c739652e5 [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_HORIZONTALLY_SCROLLABLE;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.android.car.ui.FocusArea;
import com.android.car.ui.FocusParkingView;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* Utility class used by {@link com.android.car.ui.FocusArea} and {@link
* com.android.car.ui.FocusParkingView}.
*
* @hide
*/
public final class ViewUtils {
/**
* No view is focused, the focused view is not shown, or the focused view is a FocusParkingView.
*/
public static final int NO_FOCUS = 1;
/** A regular view is focused. */
public static final int REGULAR_FOCUS = 2;
/**
* An implicit default focus view (i.e., the first focusable item in a scrollable container) is
* focused.
*/
public static final int IMPLICIT_DEFAULT_FOCUS = 3;
/** The {@code app:defaultFocus} view is focused. */
public static final int DEFAULT_FOCUS = 4;
/** The {@code android:focusedByDefault} view is focused. */
public static final int FOCUSED_BY_DEFAULT = 5;
/**
* 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, REGULAR_FOCUS, IMPLICIT_DEFAULT_FOCUS,
DEFAULT_FOCUS, FOCUSED_BY_DEFAULT})
@Retention(RetentionPolicy.SOURCE)
public @interface FocusLevel {
}
/** This is a utility class. */
private ViewUtils() {
}
/**
* This is a functional interface and can therefore be used as the assignment target for a
* lambda expression or method reference.
*
* @param <T> the type of the input to the predicate
*/
private interface Predicate<T> {
/** Evaluates this predicate on the given argument. */
boolean test(@NonNull T t);
}
/** Gets the ancestor FocusArea of the {@code view}, if any. Returns null if not found. */
@Nullable
public static FocusArea getAncestorFocusArea(@NonNull View view) {
ViewParent parent = view.getParent();
while (parent != null) {
if (parent instanceof FocusArea) {
return (FocusArea) 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 a FocusArea, so let's return earlier if we found
// a FocusArea.
while (parent != null && parent instanceof ViewGroup && !(parent instanceof FocusArea)) {
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.
*
* @return whether the view is focused
*/
public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) {
@FocusLevel int level = getFocusLevel(currentFocus);
return adjustFocus(root, level);
}
/**
* 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
*/
public static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) {
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 < REGULAR_FOCUS) {
return focusOnFirstFocus(root);
}
return false;
}
@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;
}
return REGULAR_FOCUS;
}
/** Returns whether the {@code view} is a {@code app:defaultFocus} view. */
private static boolean isDefaultFocus(@NonNull View view) {
FocusArea parent = getAncestorFocusArea(view);
return parent != null && view == parent.getDefaultFocusView();
}
/**
* Returns whether the {@code view} is an implicit default focus view, i.e., 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 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);
}
/**
* Focuses on the first {@code app:defaultFocus} view in the view tree, if any.
*
* @param root the root of the view tree
* @return whether succeeded
*/
private static boolean focusOnDefaultFocusView(@NonNull View root) {
View defaultFocus = findDefaultFocusView(root);
return requestFocus(defaultFocus);
}
/**
* Focuses on the first {@code android:focusedByDefault} view in the view tree, if any.
*
* @param root the root of the view tree
* @return whether succeeded
*/
private static boolean focusOnFocusedByDefaultView(@NonNull View root) {
View focusedByDefault = findFocusedByDefaultView(root);
return requestFocus(focusedByDefault);
}
/**
* Focuses on the first implicit default focus view in the view tree, if any.
*
* @param root the root of the view tree
* @return whether succeeded
*/
private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) {
View implicitDefaultFocus = findImplicitDefaultFocusView(root);
return requestFocus(implicitDefaultFocus);
}
/**
* Tries to focus on the first focusable view in the view tree in depth first order. If failed,
* keeps trying other views in depth first order until succeeded.
*
* @param root the root of the view tree
* @return whether succeeded
*/
private static boolean focusOnFirstFocus(@NonNull View root) {
View focusedView = ViewUtils.depthFirstSearch(root,
/* targetPredicate= */ v -> canTakeFocus(v) && requestFocus(v),
/* skipPredicate= */ v -> !v.isShown());
return focusedView != null;
}
/**
* 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 FocusArea) {
FocusArea focusArea = (FocusArea) 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} and its 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.isFocusedByDefault() && canTakeFocus(v),
/* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view} and its descendants in depth first order, and returns the first
* implicit default focus view, i.e., 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);
return rotaryContainer == null
? null
: 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} and its descendants in depth first order, and returns the first
* rotary container shown on the screen. Returns null if not found.
*/
@Nullable
private static View findRotaryContainer(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ v -> isRotaryContainer(v),
/* 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) {
return view.isFocusable() && 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);
}
}