Update the focus logic of FocusArea and FocusParkingView
When the framework wants to restore the focus, focus on the default
view defined by us. As a result, RotaryService doesn't need to adjust
the focus any more.
Fixes: 169252130
Fixes: 168240318
Fixes: 164218771
Fixes: 159929766
Test: atest CarUILibUnitTests, and manual test
Change-Id: I5d546654437b5e7874b6478026ec92e8b9e492c5
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/AndroidManifest.xml b/car-ui-lib/car-ui-lib/src/androidTest/AndroidManifest.xml
index 81df306..2482a5b 100644
--- a/car-ui-lib/car-ui-lib/src/androidTest/AndroidManifest.xml
+++ b/car-ui-lib/car-ui-lib/src/androidTest/AndroidManifest.xml
@@ -23,6 +23,7 @@
<activity android:name="com.android.car.ui.FocusAreaTestActivity" />
<activity android:name="com.android.car.ui.FocusParkingViewTestActivity" />
<activity android:name="com.android.car.ui.preference.PreferenceTestActivity" />
+ <activity android:name="com.android.car.ui.utils.ViewUtilsTestActivity" />
<activity
android:name="com.android.car.ui.toolbar.ToolbarTestActivity"
android:theme="@style/Theme.CarUi.WithToolbar"/>
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusAreaTest.java b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusAreaTest.java
index 9c1ce88..51f166c 100644
--- a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusAreaTest.java
+++ b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusAreaTest.java
@@ -111,8 +111,6 @@
@Test
public void testFocusOnDefaultFocus() throws Exception {
- assertThat(mDefaultFocus.isFocused()).isFalse();
-
Bundle bundle = new Bundle();
CountDownLatch latch = new CountDownLatch(1);
mFocusArea.post(() -> {
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusParkingViewTest.java b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusParkingViewTest.java
index 887ac8e..9f24d6a 100644
--- a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusParkingViewTest.java
+++ b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/FocusParkingViewTest.java
@@ -46,7 +46,7 @@
}
@Test
- public void testFocusParkingViewCanTakeFocus() throws Exception {
+ public void testFocusParkingViewCannotTakeFocus() throws Exception {
FocusParkingView focusParkingView = mActivity.findViewById(R.id.focus_parking);
CountDownLatch latch = new CountDownLatch(1);
@@ -58,7 +58,7 @@
});
latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
- assertThat(focusParkingView.isFocused()).isTrue();
+ assertThat(focusParkingView.isFocused()).isFalse();
}
@Test
public void testFocusParkingViewFocusedWhenWindowLostFocus() throws Exception {
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTest.java b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTest.java
new file mode 100644
index 0000000..76797e3
--- /dev/null
+++ b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTest.java
@@ -0,0 +1,399 @@
+/*
+ * 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.View.GONE;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.test.rule.ActivityTestRule;
+
+import com.android.car.ui.FocusArea;
+import com.android.car.ui.FocusParkingView;
+import com.android.car.ui.recyclerview.CarUiRecyclerView;
+import com.android.car.ui.test.R;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.List;
+
+/** Unit tests for {@link ViewUtils}. */
+public class ViewUtilsTest {
+
+ @Rule
+ public ActivityTestRule<ViewUtilsTestActivity> mActivityRule =
+ new ActivityTestRule<>(ViewUtilsTestActivity.class);
+
+ private ViewUtilsTestActivity mActivity;
+ private FocusArea mFocusArea1;
+ private FocusArea mFocusArea2;
+ private FocusArea mFocusArea3;
+ private FocusArea mFocusArea4;
+ private FocusArea mFocusArea5;
+ private FocusParkingView mFpv;
+ private View mView2;
+ private View mFocusedByDefault3;
+ private View mView4;
+ private View mDefaultFocus4;
+ private CarUiRecyclerView mList5;
+ private View mRoot;
+
+ @Before
+ public void setUp() {
+ mActivity = mActivityRule.getActivity();
+ mFocusArea1 = mActivity.findViewById(R.id.focus_area1);
+ mFocusArea2 = mActivity.findViewById(R.id.focus_area2);
+ mFocusArea3 = mActivity.findViewById(R.id.focus_area3);
+ mFocusArea4 = mActivity.findViewById(R.id.focus_area4);
+ mFocusArea5 = mActivity.findViewById(R.id.focus_area5);
+ mFpv = mActivity.findViewById(R.id.fpv);
+ mView2 = mActivity.findViewById(R.id.view2);
+ mFocusedByDefault3 = mActivity.findViewById(R.id.focused_by_default3);
+ mView4 = mActivity.findViewById(R.id.view4);
+ mDefaultFocus4 = mActivity.findViewById(R.id.default_focus4);
+ mList5 = mActivity.findViewById(R.id.list5);
+ mRoot = mFocusArea1.getRootView();
+
+ mRoot.post(() -> {
+ setUpRecyclerView(mList5);
+ });
+ }
+
+ @Test
+ public void testRootVisible() {
+ mRoot.post(() -> assertThat(mRoot.getVisibility()).isEqualTo(VISIBLE));
+ }
+
+ @Test
+ public void testFindFocusedByDefaultView() {
+ mRoot.post(() -> {
+ View focusedByDefault = ViewUtils.findFocusedByDefaultView(mRoot);
+ assertThat(focusedByDefault).isEqualTo(mFocusedByDefault3);
+ });
+ }
+
+ @Test
+ public void testFindFocusedByDefaultView_skipNotFocusable() {
+ mRoot.post(() -> {
+ mFocusedByDefault3.setFocusable(false);
+ View focusedByDefault = ViewUtils.findFocusedByDefaultView(mRoot);
+ assertThat(focusedByDefault).isNull();
+ });
+ }
+
+ @Test
+ public void testFindFocusedByDefaultView_skipInvisibleView() {
+ mRoot.post(() -> {
+ mFocusArea3.setVisibility(INVISIBLE);
+ assertThat(mFocusArea3.getVisibility()).isEqualTo(INVISIBLE);
+ View focusedByDefault = ViewUtils.findFocusedByDefaultView(mRoot);
+ assertThat(focusedByDefault).isNull();
+ });
+ }
+
+ @Test
+ public void testFindFocusedByDefaultView_skipInvisibleAncestor() {
+ mRoot.post(() -> {
+ mRoot.setVisibility(INVISIBLE);
+ View focusedByDefault = ViewUtils.findFocusedByDefaultView(mFocusArea3);
+ assertThat(focusedByDefault).isNull();
+ });
+ }
+
+ @Test
+ public void testFindImplicitDefaultFocusView_inRoot() {
+ mRoot.post(() -> {
+ View firstItem = mList5.getLayoutManager().findViewByPosition(0);
+ View implicitDefaultFocus = ViewUtils.findImplicitDefaultFocusView(mRoot);
+ assertThat(implicitDefaultFocus).isEqualTo(firstItem);
+ });
+ }
+
+ @Test
+ public void testFindImplicitDefaultFocusView_inFocusArea() {
+ mRoot.post(() -> {
+ View firstItem = mList5.getLayoutManager().findViewByPosition(0);
+ View implicitDefaultFocus = ViewUtils.findImplicitDefaultFocusView(mFocusArea5);
+ assertThat(implicitDefaultFocus).isEqualTo(firstItem);
+ });
+ }
+
+ @Test
+ public void testFindImplicitDefaultFocusView_skipInvisibleAncestor() {
+ mRoot.post(() -> {
+ mRoot.setVisibility(INVISIBLE);
+ View implicitDefaultFocus = ViewUtils.findImplicitDefaultFocusView(mFocusArea5);
+ assertThat(implicitDefaultFocus).isNull();
+ });
+ }
+
+ @Test
+ public void testFindFirstFocusableDescendant() {
+ mRoot.post(() -> {
+ mFocusArea2.setFocusable(true);
+ View firstFocusable = ViewUtils.findFirstFocusableDescendant(mRoot);
+ assertThat(firstFocusable).isEqualTo(mFocusArea2);
+ });
+ }
+
+ @Test
+ public void testFindFirstFocusableDescendant_skipItself() {
+ mRoot.post(() -> {
+ mFocusArea2.setFocusable(true);
+ View firstFocusable = ViewUtils.findFirstFocusableDescendant(mFocusArea2);
+ assertThat(firstFocusable).isEqualTo(mView2);
+ });
+ }
+
+ @Test
+ public void testFindFirstFocusableDescendant_skipInvisibleAndGoneView() {
+ mRoot.post(() -> {
+ mFocusArea2.setVisibility(INVISIBLE);
+ mFocusArea3.setVisibility(GONE);
+ View firstFocusable = ViewUtils.findFirstFocusableDescendant(mRoot);
+ assertThat(firstFocusable).isEqualTo(mView4);
+ });
+ }
+
+ @Test
+ public void testFindFirstFocusableDescendant_skipInvisibleAncestor() {
+ mRoot.post(() -> {
+ mRoot.setVisibility(INVISIBLE);
+ View firstFocusable = ViewUtils.findFirstFocusableDescendant(mFocusArea2);
+ assertThat(firstFocusable).isNull();
+ });
+ }
+
+ @Test
+ public void testIsImplicitDefaultFocusView_firstItem() {
+ mRoot.post(() -> {
+ View firstItem = mList5.getLayoutManager().findViewByPosition(0);
+ assertThat(ViewUtils.isImplicitDefaultFocusView(firstItem)).isTrue();
+ });
+ }
+
+ @Test
+ public void testIsImplicitDefaultFocusView_secondItem() {
+ mRoot.post(() -> {
+ View secondItem = mList5.getLayoutManager().findViewByPosition(1);
+ assertThat(ViewUtils.isImplicitDefaultFocusView(secondItem)).isFalse();
+ });
+ }
+
+ @Test
+ public void testIsImplicitDefaultFocusView_normalView() {
+ mRoot.post(() -> {
+ assertThat(ViewUtils.isImplicitDefaultFocusView(mView2)).isFalse();
+ });
+ }
+
+ @Test
+ public void testIsImplicitDefaultFocusView_skipInvisibleAncestor() {
+ mRoot.post(() -> {
+ mFocusArea5.setVisibility(INVISIBLE);
+ View firstItem = mList5.getLayoutManager().findViewByPosition(0);
+ assertThat(ViewUtils.isImplicitDefaultFocusView(firstItem)).isFalse();
+ });
+ }
+
+ @Test
+ public void testRequestFocus() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_nullView() {
+ mRoot.post(() -> {
+ assertRequestFocus(null, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_alreadyFocused() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ // mView2 is already focused before requesting focus.
+ assertRequestFocus(mView2, true);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_notFocusable() {
+ mRoot.post(() -> {
+ mView2.setFocusable(false);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_disabled() {
+ mRoot.post(() -> {
+ mView2.setEnabled(false);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_notVisible() {
+ mRoot.post(() -> {
+ mView2.setVisibility(View.INVISIBLE);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_skipInvisibleAncestor() {
+ mRoot.post(() -> {
+ mFocusArea2.setVisibility(View.INVISIBLE);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_zeroWidth() {
+ mRoot.post(() -> {
+ mView2.setRight(mView2.getLeft());
+ assertThat(mView2.getWidth()).isEqualTo(0);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_detachedFromWindow() {
+ mRoot.post(() -> {
+ mFocusArea2.removeView(mView2);
+ assertRequestFocus(mView2, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_FocusParkingView() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ assertRequestFocus(mFpv, false);
+ });
+ }
+
+ @Test
+ public void testRequestFocus_scrollableContainer() {
+ mRoot.post(() -> {
+ assertRequestFocus(mList5, false);
+ });
+ }
+
+ @Test
+ public void testAdjustFocus_inRoot() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ ViewUtils.adjustFocus(mRoot, null);
+ assertThat(mFocusedByDefault3.isFocused()).isTrue();
+ });
+ }
+
+ @Test
+ public void testAdjustFocus_inFocusAreaWithDefaultFocus() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ ViewUtils.adjustFocus(mFocusArea3, null);
+ assertThat(mFocusedByDefault3.isFocused()).isTrue();
+ });
+ }
+
+ @Test
+ public void testAdjustFocus_inFocusAreaWithoutDefaultFocus() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView4, true);
+ ViewUtils.adjustFocus(mFocusArea2, null);
+ assertThat(mView2.isFocused()).isTrue();
+ });
+ }
+
+ @Test
+ public void testAdjustFocus_inFocusAreaWithoutFocusableDescendant() {
+ mRoot.post(() -> {
+ assertRequestFocus(mView2, true);
+ boolean success = ViewUtils.adjustFocus(mFocusArea1, null);
+ assertThat(mFocusArea1.hasFocus()).isFalse();
+ assertThat(success).isFalse();
+ });
+ }
+
+ private static void assertRequestFocus(@Nullable View view, boolean focused) {
+ boolean result = ViewUtils.requestFocus(view);
+ assertThat(result).isEqualTo(focused);
+ if (view != null) {
+ assertThat(view.isFocused()).isEqualTo(focused);
+ }
+ }
+
+ private void setUpRecyclerView(@NonNull CarUiRecyclerView list) {
+ list.setLayoutManager(new LinearLayoutManager(mActivity));
+ list.setAdapter(new TestAdapter());
+ }
+
+ private static class TestAdapter extends RecyclerView.Adapter<TestViewHolder> {
+
+ private static final List<String> DATA = Arrays.asList("first", "second");
+
+ @NonNull
+ @Override
+ public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
+ View view = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.test_list_item, parent, false);
+ return new TestViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull TestViewHolder holder, int position) {
+ holder.bind(DATA.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return DATA.size();
+ }
+ }
+
+ private static class TestViewHolder extends RecyclerView.ViewHolder {
+ private final TextView mTextView;
+
+ TestViewHolder(View view) {
+ super(view);
+ mTextView = view.findViewById(R.id.text);
+ }
+
+ void bind(String text) {
+ mTextView.setText(text);
+ }
+ }
+}
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTestActivity.java b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTestActivity.java
new file mode 100644
index 0000000..87434d4
--- /dev/null
+++ b/car-ui-lib/car-ui-lib/src/androidTest/java/com/android/car/ui/utils/ViewUtilsTestActivity.java
@@ -0,0 +1,32 @@
+/*
+ * 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 android.app.Activity;
+import android.os.Bundle;
+
+import com.android.car.ui.test.R;
+
+/** An activity used for testing {@link ViewUtils}. */
+public class ViewUtilsTestActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.view_utils_test_activity);
+ }
+}
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/res/layout/test_list_item.xml b/car-ui-lib/car-ui-lib/src/androidTest/res/layout/test_list_item.xml
index e789145..1fab70e 100644
--- a/car-ui-lib/car-ui-lib/src/androidTest/res/layout/test_list_item.xml
+++ b/car-ui-lib/car-ui-lib/src/androidTest/res/layout/test_list_item.xml
@@ -18,7 +18,8 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
- android:orientation="horizontal">
+ android:orientation="horizontal"
+ android:focusable="true">
<TextView
android:layout_width="wrap_content"
diff --git a/car-ui-lib/car-ui-lib/src/androidTest/res/layout/view_utils_test_activity.xml b/car-ui-lib/car-ui-lib/src/androidTest/res/layout/view_utils_test_activity.xml
new file mode 100644
index 0000000..558dd9f
--- /dev/null
+++ b/car-ui-lib/car-ui-lib/src/androidTest/res/layout/view_utils_test_activity.xml
@@ -0,0 +1,96 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ 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.
+ -->
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <com.android.car.ui.FocusParkingView
+ android:id="@+id/fpv"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area1"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <View
+ android:layout_width="100dp"
+ android:layout_height="100dp"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area2"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <View
+ android:layout_width="100dp"
+ android:layout_height="100dp"/>
+ <View
+ android:id="@+id/view2"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area3"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <View
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"/>
+ <View
+ android:id="@+id/focused_by_default3"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"
+ android:focusedByDefault="true"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area4"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical"
+ app:defaultFocus="@+id/default_focus4">
+ <View
+ android:id="@+id/view4"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"/>
+ <View
+ android:id="@+id/default_focus4"
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"/>
+ </com.android.car.ui.FocusArea>
+ <com.android.car.ui.FocusArea
+ android:id="@+id/focus_area5"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="vertical">
+ <View
+ android:layout_width="100dp"
+ android:layout_height="100dp"
+ android:focusable="true"/>
+ <com.android.car.ui.recyclerview.CarUiRecyclerView
+ android:id="@+id/list5"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"/>
+ </com.android.car.ui.FocusArea>
+</LinearLayout>
diff --git a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusArea.java b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusArea.java
index b71b3ab..4f0fa94 100644
--- a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusArea.java
+++ b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusArea.java
@@ -25,11 +25,14 @@
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET;
import static com.android.car.ui.utils.RotaryConstants.NUDGE_DIRECTION;
+import static com.android.car.ui.utils.ViewUtils.NO_FOCUS;
+import static com.android.car.ui.utils.ViewUtils.REGULAR_FOCUS;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
+import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.SystemClock;
@@ -38,7 +41,6 @@
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
-import android.view.ViewParent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.LinearLayout;
@@ -289,7 +291,7 @@
mPreviousFocusArea = null;
return;
}
- mPreviousFocusArea = getAncestorFocusArea(oldFocus);
+ mPreviousFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
if (mPreviousFocusArea == null) {
Log.w(TAG, "No parent FocusArea for " + oldFocus);
}
@@ -306,7 +308,7 @@
if (!hasFocus || oldFocus == null) {
return;
}
- FocusArea oldFocusArea = getAncestorFocusArea(oldFocus);
+ FocusArea oldFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
if (oldFocusArea != this) {
return;
}
@@ -457,6 +459,28 @@
}
@Override
+ public void onWindowFocusChanged(boolean hasWindowFocus) {
+ // To ensure the focus is initialized properly in rotary mode when there is a window focus
+ // change, this FocusArea will grab the focus from the currently focused view if one of this
+ // FocusArea's descendants is a better focus candidate than the currently focused view.
+ if (hasWindowFocus && !isInTouchMode()) {
+ maybeAdjustFocus();
+ }
+ super.onWindowFocusChanged(hasWindowFocus);
+ }
+
+ /**
+ * Focuses on another view in this FocusArea if the view is a better focus candidate than the
+ * currently focused view.
+ */
+ private boolean maybeAdjustFocus() {
+ View root = getRootView();
+ View focus = root.findFocus();
+ return ViewUtils.adjustFocus(root, focus);
+ }
+
+
+ @Override
public boolean performAccessibilityAction(int action, Bundle arguments) {
switch (action) {
case ACTION_FOCUS:
@@ -481,12 +505,6 @@
}
private boolean focusOnDescendant() {
- if (focusOnFocusedByDefaultView()) {
- return true;
- }
- if (focusOnPrimaryFocusView()) {
- return true;
- }
if (mDefaultFocusOverridesHistory) {
// Check mDefaultFocus before last focused view.
if (focusDefaultFocusView() || focusOnLastFocusedView()) {
@@ -501,28 +519,26 @@
return focusOnFirstFocusableView();
}
- private boolean focusOnFocusedByDefaultView() {
- View focusedByDefaultView = ViewUtils.findFocusedByDefaultView(this);
- return requestFocus(focusedByDefaultView);
- }
-
- private boolean focusOnPrimaryFocusView() {
- View primaryFocus = ViewUtils.findPrimaryFocusView(this);
- return requestFocus(primaryFocus);
- }
-
private boolean focusDefaultFocusView() {
- return requestFocus(mDefaultFocusView);
+ return ViewUtils.adjustFocus(this, /* currentLevel= */ REGULAR_FOCUS);
+ }
+
+ /**
+ * Gets the {@code app:defaultFocus} view.
+ *
+ * @hidden
+ */
+ public View getDefaultFocusView() {
+ return mDefaultFocusView;
}
private boolean focusOnLastFocusedView() {
View lastFocusedView = mRotaryCache.getFocusedView(SystemClock.uptimeMillis());
- return requestFocus(lastFocusedView);
+ return ViewUtils.requestFocus(lastFocusedView);
}
private boolean focusOnFirstFocusableView() {
- View firstFocusableView = ViewUtils.findFocusableDescendant(this);
- return requestFocus(firstFocusableView);
+ return ViewUtils.adjustFocus(this, /* currentLevel= */ NO_FOCUS);
}
private boolean nudgeToShortcutView(Bundle arguments) {
@@ -541,7 +557,7 @@
// nudge to another FocusArea.
return false;
}
- return requestFocus(mNudgeShortcutView);
+ return ViewUtils.requestFocus(mNudgeShortcutView);
}
private boolean nudgeToAnotherFocusArea(Bundle arguments) {
@@ -597,17 +613,6 @@
+ "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
}
- private 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;
- }
-
@Nullable
private FocusArea getSpecifiedFocusArea(int direction) {
maybeInitializeSpecifiedFocusAreas();
@@ -661,6 +666,16 @@
bundle.putInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET, mBottomOffset);
}
+ @Override
+ protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
+ return maybeAdjustFocus();
+ }
+
+ @Override
+ public boolean restoreDefaultFocus() {
+ return maybeAdjustFocus();
+ }
+
private void maybeInitializeSpecifiedFocusAreas() {
if (mSpecifiedNudgeFocusAreaMap != null) {
return;
@@ -673,15 +688,6 @@
}
}
- private boolean requestFocus(@Nullable View view) {
- if (view == null || !view.isAttachedToWindow()) {
- return false;
- }
- // 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, null);
- }
-
/** Sets the padding (in pixels) of the FocusArea highlight. */
public void setHighlightPadding(int left, int top, int right, int bottom) {
if (mPaddingLeft == left && mPaddingTop == top && mPaddingRight == right
diff --git a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusParkingView.java b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusParkingView.java
index 8a42cea..c0893bb 100644
--- a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusParkingView.java
+++ b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/FocusParkingView.java
@@ -21,10 +21,11 @@
import static com.android.car.ui.utils.RotaryConstants.ACTION_RESTORE_DEFAULT_FOCUS;
import android.content.Context;
+import android.graphics.Rect;
import android.os.Bundle;
import android.util.AttributeSet;
-import android.util.Log;
import android.view.View;
+import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.Nullable;
@@ -33,10 +34,11 @@
/**
* A transparent {@link View} that can take focus. It's used by {@link
- * com.android.car.rotary.RotaryService} to support rotary controller navigation. Each {@link
- * android.view.Window} should have one FocusParkingView as the first focusable view in the view
- * tree, and outside of all {@link FocusArea}s. If multiple FocusParkingView are added in the
- * window, only the first one will be focusable.
+ * com.android.car.rotary.RotaryService} to support rotary controller navigation. It's also used to
+ * initialize the focus when in rotary mode.
+ * <p>
+ * To support the rotary controller, each {@link android.view.Window} must have a FocusParkingView
+ * as the first focusable view in the view tree, and outside of all {@link FocusArea}s.
* <p>
* Android doesn't clear focus automatically when focus is set in another window. If we try to clear
* focus in the previous window, Android will re-focus a view in that window, resulting in two
@@ -45,19 +47,33 @@
* matter whether it's focused or not. It can take focus so that RotaryService can "park" the focus
* on it to remove the focus highlight.
* <p>
- * If the focused view is scrolled off the screen, Android will refocus the first focusable view in
- * the window. The FocusParkingView should be the first view so that it gets focus. The
- * RotaryService detects this and moves focus to the scrolling container.
- * <p>
* If there is only one focus area in the current window, rotating the controller within the focus
* area will cause RotaryService to move the focus around from the view on the right to the view on
* the left or vice versa. Adding this view to each window can fix this issue. When RotaryService
* finds out the focus target is a FocusParkingView, it will know a wrap-around is going to happen.
* Then it will avoid the wrap-around by not moving focus.
+ * <p>
+ * To ensure the focus is initialized properly when there is a window change, the FocusParkingView
+ * will not get focused when the framework wants to focus on it. Instead, it will try to find a
+ * better focus target in the window and focus on the target. That said, the FocusParkingView can
+ * still be focused in order to clear focus highlight in the window, such as when RotaryService
+ * performs {@link android.view.accessibility.AccessibilityNodeInfo#ACTION_FOCUS} on the
+ * FocusParkingView, or the window has lost focus.
*/
public class FocusParkingView extends View {
private static final String TAG = "FocusParkingView";
+ /**
+ * The focused view in the window containing this FocusParkingView. It's null if no view is
+ * focused, or the focused view is a FocusParkingView.
+ */
+ @Nullable
+ private View mFocusedView;
+
+ /** The scrollable container that contains the {@link #mFocusedView}, if any. */
+ @Nullable
+ ViewGroup mScrollableContainer;
+
public FocusParkingView(Context context) {
super(context);
init();
@@ -94,6 +110,12 @@
// Prevent Android from drawing the default focus highlight for this view when it's focused.
setDefaultFocusHighlightEnabled(false);
+
+ // Keep track of the focused view so that we can recover focus when it's removed.
+ getViewTreeObserver().addOnGlobalFocusChangeListener((oldFocus, newFocus) -> {
+ mFocusedView = newFocus instanceof FocusParkingView ? null : newFocus;
+ mScrollableContainer = ViewUtils.getAncestorScrollableContainer(mFocusedView);
+ });
}
@Override
@@ -107,12 +129,16 @@
@Override
public void onWindowFocusChanged(boolean hasWindowFocus) {
if (!hasWindowFocus) {
- // We need to clear the focus (by parking the focus on the FocusParkingView) once the
- // current window goes to background. This can't be done by RotaryService because
- // RotaryService sees the window as removed, thus can't perform any action (such as
- // focus, clear focus) on the nodes in the window. So FocusParkingView has to grab the
- // focus proactively.
- requestFocus();
+ // We need to clear the focus highlight(by parking the focus on the FocusParkingView)
+ // once the current window goes to background. This can't be done by RotaryService
+ // because RotaryService sees the window as removed, thus can't perform any action
+ // (such as focus, clear focus) on the nodes in the window. So FocusParkingView has to
+ // grab the focus proactively.
+ super.requestFocus(FOCUS_DOWN, null);
+ } else if (isFocused()) {
+ // When FocusParkingView is focused and the window just gets focused, transfer the view
+ // focus to a non-FocusParkingView in the window.
+ restoreFocusInRoot();
}
super.onWindowFocusChanged(hasWindowFocus);
}
@@ -126,21 +152,7 @@
public boolean performAccessibilityAction(int action, Bundle arguments) {
switch (action) {
case ACTION_RESTORE_DEFAULT_FOCUS:
- View root = getRootView();
-
- // If there is a view focused by default and it can take focus, move focus to it.
- View defaultFocus = ViewUtils.findFocusedByDefaultView(root);
- if (defaultFocus != null) {
- return defaultFocus.requestFocus();
- }
-
- // If there is a primary focus view, move focus to it.
- View primaryFocus = ViewUtils.findPrimaryFocusView(root);
- if (primaryFocus != null) {
- return primaryFocus.requestFocus();
- }
-
- return false;
+ return restoreFocusInRoot();
case ACTION_HIDE_IME:
InputMethodManager inputMethodManager =
getContext().getSystemService(InputMethodManager.class);
@@ -149,33 +161,37 @@
case ACTION_FOCUS:
// Don't leave this to View to handle as it will exit touch mode.
if (!hasFocus()) {
- return requestFocus();
+ return super.requestFocus(FOCUS_DOWN, null);
}
- break;
+ return false;
}
return super.performAccessibilityAction(action, arguments);
}
@Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
+ public boolean requestFocus(int direction, Rect previouslyFocusedRect) {
+ // Find a better target to focus instead of focusing this FocusParkingView when the
+ // framework wants to focus it.
+ return restoreFocusInRoot();
+ }
- // If there is a FocusParkingView already, make the one after in the view tree
- // non-focusable.
- boolean []isBefore = new boolean[1];
- View anotherFpv = ViewUtils.depthFirstSearch(getRootView(), v -> {
- if (this == v) {
- isBefore[0] = true;
- }
- return v != this && v instanceof FocusParkingView && v.isFocusable();
- });
- if (anotherFpv != null) {
- Log.w(TAG, "There should be only one FocusParkingView in the window");
- if (isBefore[0]) {
- anotherFpv.setFocusable(false);
- } else {
- setFocusable(false);
- }
+ @Override
+ public boolean restoreDefaultFocus() {
+ // Find a better target to focus instead of focusing this FocusParkingView when the
+ // framework wants to focus it.
+ return restoreFocusInRoot();
+ }
+
+ private boolean restoreFocusInRoot() {
+ // The focused view was in a scrollable container and it was removed, e.g., it was scrolled
+ // off the screen. Let's focus on the scrollable container so that the rotary controller
+ // can scroll it.
+ if (mFocusedView != null && !mFocusedView.isAttachedToWindow()
+ && mScrollableContainer != null && mScrollableContainer.isAttachedToWindow()
+ && mScrollableContainer.isShown() && mScrollableContainer.requestFocus()) {
+ return true;
}
+ // Otherwise find the best target view to focus.
+ return ViewUtils.adjustFocus(getRootView(), /* currentFocus= */ null);
}
}
diff --git a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/RotaryConstants.java b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/RotaryConstants.java
index 99154df..1f1de0a 100644
--- a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/RotaryConstants.java
+++ b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/RotaryConstants.java
@@ -70,7 +70,7 @@
/** Action performed on a FocusArea to move focus to another FocusArea. */
public static final int ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA = 0x02000000;
- /** Action performed on a FocusParkingView to restore the default focus. */
+ /** Action performed on a FocusParkingView to restore the focus in the window. */
public static final int ACTION_RESTORE_DEFAULT_FOCUS = 0x04000000;
/** Action performed on a FocusParkingView to hide the IME. */
diff --git a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/ViewUtils.java b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/ViewUtils.java
index 6dd993b..7c95577 100644
--- a/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/ViewUtils.java
+++ b/car-ui-lib/car-ui-lib/src/main/java/com/android/car/ui/utils/ViewUtils.java
@@ -16,7 +16,7 @@
package com.android.car.ui.utils;
-import static android.view.View.VISIBLE;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
@@ -24,9 +24,18 @@
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
@@ -36,50 +45,310 @@
*/
public final class ViewUtils {
- /** This is a utility class */
+ /**
+ * 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() {
}
/**
- * Searches the {@code view} and its descendants in depth first order, and returns the first
- * view that is focused by default, can take focus, but has no invisible ancestors. Returns null
- * if not found.
+ * 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 View findFocusedByDefaultView(@NonNull View view) {
- return depthFirstSearch(view,
- /* targetPredicate= */ v -> v.isFocusedByDefault() && canTakeFocus(v),
- /* skipPredicate= */ v -> v.getVisibility() != VISIBLE);
+ 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;
+ }
+
+ @FocusLevel
+ private 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 scrollable container.
+ */
+ @VisibleForTesting
+ static boolean isImplicitDefaultFocusView(@NonNull View view) {
+ ViewGroup scrollableContainer = null;
+ ViewParent parent = view.getParent();
+ while (parent != null && parent instanceof ViewGroup) {
+ ViewGroup viewGroup = (ViewGroup) parent;
+ if (isScrollableContainer(viewGroup)) {
+ scrollableContainer = viewGroup;
+ break;
+ }
+ parent = parent.getParent();
+ }
+ if (scrollableContainer == null) {
+ return false;
+ }
+ return findFirstFocusableDescendant(scrollableContainer) == view;
+ }
+
+ 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
- * primary focus view, i.e., the first focusable item in a scrollable container. Returns null
- * if not found.
+ * {@code android:focusedByDefault} view that can take focus. Returns null if not found.
*/
- public static View findPrimaryFocusView(@NonNull View view) {
+ @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 scrollable
+ * container. Returns null if not found.
+ */
+ @VisibleForTesting
+ @Nullable
+ static View findImplicitDefaultFocusView(@NonNull View view) {
View scrollableContainer = findScrollableContainer(view);
- return scrollableContainer == null ? null : findFocusableDescendant(scrollableContainer);
+ return scrollableContainer == null
+ ? null
+ : findFirstFocusableDescendant(scrollableContainer);
}
/**
* Searches the {@code view}'s descendants in depth first order, and returns the first view
- * that can take focus but has no invisible ancestors, or null if not found.
+ * that can take focus, or null if not found.
*/
+ @VisibleForTesting
@Nullable
- public static View findFocusableDescendant(@NonNull View view) {
+ static View findFirstFocusableDescendant(@NonNull View view) {
return depthFirstSearch(view,
/* targetPredicate= */ v -> v != view && canTakeFocus(v),
- /* skipPredicate= */ v -> v.getVisibility() != VISIBLE);
+ /* skipPredicate= */ v -> !v.isShown());
}
/**
* Searches the {@code view} and its descendants in depth first order, and returns the first
- * view that meets the given condition. Returns null if not found.
+ * scrollable container shown on the screen. Returns null if not found.
*/
@Nullable
- public static View depthFirstSearch(@NonNull View view, @NonNull Predicate<View> predicate) {
- return depthFirstSearch(view, predicate, /* skipPredicate= */ null);
+ private static View findScrollableContainer(@NonNull View view) {
+ return depthFirstSearch(view,
+ /* targetPredicate= */ v -> isScrollableContainer(v),
+ /* skipPredicate= */ v -> !v.isShown());
}
/**
@@ -90,7 +359,7 @@
@Nullable
private static View depthFirstSearch(@NonNull View view,
@NonNull Predicate<View> targetPredicate,
- @NonNull Predicate<View> skipPredicate) {
+ @Nullable Predicate<View> skipPredicate) {
if (skipPredicate != null && skipPredicate.test(view)) {
return null;
}
@@ -110,35 +379,13 @@
return null;
}
- /**
- * 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
- */
- public interface Predicate<T> {
- /** Evaluates this predicate on the given argument. */
- boolean test(@NonNull T t);
- }
-
- /**
- * Searches the {@code view} and its descendants in depth first order, and returns the first
- * scrollable container that has no invisible ancestors. Returns null if not found.
- */
- @Nullable
- private static View findScrollableContainer(@NonNull View view) {
- return depthFirstSearch(view,
- /* targetPredicate= */ v -> {
- CharSequence contentDescription = v.getContentDescription();
- return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE)
- || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE);
- },
- /* skipPredicate= */ v -> v.getVisibility() != VISIBLE);
- }
-
/** Returns whether {@code view} can be focused. */
private static boolean canTakeFocus(@NonNull View view) {
- return view.isFocusable() && view.isEnabled() && view.getVisibility() == VISIBLE
- && view.getWidth() > 0 && view.getHeight() > 0;
+ 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);
}
}
diff --git a/car-ui-lib/car-ui-lib/src/main/res/values/attrs.xml b/car-ui-lib/car-ui-lib/src/main/res/values/attrs.xml
index e823bdf..ab238cc 100644
--- a/car-ui-lib/car-ui-lib/src/main/res/values/attrs.xml
+++ b/car-ui-lib/car-ui-lib/src/main/res/values/attrs.xml
@@ -147,9 +147,30 @@
<!-- Attributes for FocusArea. -->
<declare-styleable name="FocusArea">
- <!-- The ID of a focusable descendant view which should be focused when the user nudges to
- this FocusArea, if there was no view focused in the FocusArea or
- car_ui_focus_area_default_focus_overrides_history is true. -->
+ <!-- The ID of the default focus view. The view will be prioritized when searching for a
+ focus target.
+ (1) When the user nudges the rotary controller, it will search for a target FocusArea,
+ then search for a target view within the target FocusArea, and focus on the target
+ view. The target view is chosen in the following order:
+ 1. the "android:focusedByDefault" view, if any
+ 2. the "app:defaultFocus" view, if any
+ 3. the first focusable item in a scrollable container, if any
+ 4. previously focused view, if any and the cache is not stale
+ 5. the first focusable view, if any
+ Note that 4 will be prioritized over 1&2&3 when
+ car_ui_focus_area_default_focus_overrides_history is false.
+ (2) When it needs to initialize the focus (such as when a window is opened), it will
+ search for a view in the window and focus on it. The view is chosen in the
+ following order:
+ 1. the first "android:focusedByDefault" view, if any
+ 2. the first "app:defaultFocus" view, if any
+ 3. the first focusable item in a scrollable container, if any
+ 4. the first focusable view that is not a FocusParkingView, if any
+ If there is only one FocusArea that needs to set default focus, you can use either
+ "app:defaultFocus" or "android:focusedByDefault". If there are more than one, you
+ should use "android:focusedByDefault" in the primary FocusArea, and use
+ "app:defaultFocus" in other FocusAreas. -->
+
<attr name="defaultFocus" format="reference"/>
<!-- The paddings of FocusArea highlight. It does't impact the paddings on its child views,