Introduce a custom attribute for FocusArea
This CL introduces an attribute "app:defaultFocus". It's used to
specify the ID of a focusable descendant view which should be focused
when the user nudges to this focus area.
Bug: 158797952
Bug: 155698037
Test: atest com.android.car.ui.FocusAreaTest
Change-Id: Ic252c030c1507c2a3c30b68cad8b520251bc073c
diff --git a/car-ui-lib/res/values/attrs.xml b/car-ui-lib/res/values/attrs.xml
index 4b06a60..c42bdc7 100644
--- a/car-ui-lib/res/values/attrs.xml
+++ b/car-ui-lib/res/values/attrs.xml
@@ -144,4 +144,11 @@
<attr name="carUiRecyclerViewStyle" format="reference" />
<attr name="state_ux_restricted" format="boolean" />
+
+ <!-- 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. -->
+ <attr name="defaultFocus" format="reference"/>
+ </declare-styleable>
</resources>
diff --git a/car-ui-lib/src/com/android/car/ui/FocusArea.java b/car-ui-lib/src/com/android/car/ui/FocusArea.java
index f969380..ef28c38 100644
--- a/car-ui-lib/src/com/android/car/ui/FocusArea.java
+++ b/car-ui-lib/src/com/android/car/ui/FocusArea.java
@@ -16,16 +16,29 @@
package com.android.car.ui;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
+
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_ACTION_TYPE;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_DEFAULT;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_FIRST;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_INVALID;
+
import android.content.Context;
+import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
+import android.os.Bundle;
import android.util.AttributeSet;
+import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
+import com.android.car.ui.utils.CarUiUtils;
+import com.android.car.ui.utils.RotaryConstants;
+
/**
* A {@link LinearLayout} used as a navigation block for the rotary controller.
* <p>
@@ -48,6 +61,8 @@
*/
public class FocusArea extends LinearLayout {
+ private static final String TAG = "FocusArea";
+
/** Whether the FocusArea's descendant has focus (the FocusArea itself is not focusable). */
private boolean mHasFocus;
@@ -81,28 +96,34 @@
private int mPaddingTop;
private int mPaddingBottom;
+ /** The ID of the view specified in {@code app:defaultFocus}. */
+ private int mDefaultFocusId = View.NO_ID;
+ /** The view specified in {@code app:defaultFocus}. */
+ @Nullable
+ private View mDefaultFocusView;
+
public FocusArea(Context context) {
super(context);
- init();
+ init(context, null);
}
public FocusArea(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
- init();
+ init(context, attrs);
}
public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
- init();
+ init(context, attrs);
}
public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
- init();
+ init(context, attrs);
}
- private void init() {
+ private void init(Context context, @Nullable AttributeSet attrs) {
mEnableForegroundHighlight = getContext().getResources().getBoolean(
R.bool.car_ui_enable_focus_area_foreground_highlight);
mEnableBackgroundHighlight = getContext().getResources().getBoolean(
@@ -128,6 +149,59 @@
invalidate();
}
});
+
+ initAttrs(context, attrs);
+ }
+
+ private void initAttrs(Context context, @Nullable AttributeSet attrs) {
+ if (attrs == null) {
+ return;
+ }
+ TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FocusArea);
+ try {
+ mDefaultFocusId = a.getResourceId(R.styleable.FocusArea_defaultFocus, View.NO_ID);
+ } finally {
+ a.recycle();
+ }
+ }
+
+ @Override
+ protected void onFinishInflate() {
+ super.onFinishInflate();
+ if (mDefaultFocusId != View.NO_ID) {
+ mDefaultFocusView = CarUiUtils.requireViewByRefId(this, mDefaultFocusId);
+ }
+ }
+
+ @Override
+ public boolean performAccessibilityAction(int action, Bundle arguments) {
+ // FocusArea is not focusable, so we focus on its descendant when handling ACTION_FOCUS.
+ if (action == ACTION_FOCUS) {
+ if (arguments == null) {
+ Log.e(TAG, "Must specify action type when performing ACTION_FOCUS on FocusArea");
+ return false;
+ }
+ @RotaryConstants.FocusActionType
+ int type = arguments.getInt(FOCUS_ACTION_TYPE, FOCUS_INVALID);
+ switch (type) {
+ case FOCUS_DEFAULT:
+ // Move focus to the default focus (mDefaultFocusView), if any.
+ if (mDefaultFocusView != null) {
+ if (mDefaultFocusView.requestFocus()) {
+ return true;
+ }
+ Log.e(TAG, "The default focus of the FocusArea can't take focus");
+ }
+ return false;
+ case FOCUS_FIRST:
+ // Focus on the first focusable view in the FocusArea.
+ return requestFocus();
+ default:
+ Log.e(TAG, "Invalid action type " + type);
+ return false;
+ }
+ }
+ return super.performAccessibilityAction(action, arguments);
}
@Override
diff --git a/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java b/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
index cb418bb..e1c6fb8 100644
--- a/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
+++ b/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
@@ -16,6 +16,11 @@
package com.android.car.ui.utils;
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
/** Constants for the rotary controller. */
public final class RotaryConstants {
/**
@@ -23,14 +28,37 @@
* horizontally.
*/
public static final String ROTARY_HORIZONTALLY_SCROLLABLE =
- "android.rotary.HORIZONTALLY_SCROLLABLE";
+ "com.android.car.ui.utils.HORIZONTALLY_SCROLLABLE";
/**
* Content description indicating that the rotary controller should scroll this view
* vertically.
*/
public static final String ROTARY_VERTICALLY_SCROLLABLE =
- "android.rotary.VERTICALLY_SCROLLABLE";
+ "com.android.car.ui.utils.VERTICALLY_SCROLLABLE";
+
+ /** The key to store a FocusActionType in the Bundle. */
+ public static final String FOCUS_ACTION_TYPE = "com.android.car.ui.utils.FOCUS_ACTION_TYPE";
+
+ /** Value indicating that the ACTION_FOCUS hasn't specified what to focus. */
+ public static final int FOCUS_INVALID = 0;
+
+ /**
+ * Value indicating that the ACTION_FOCUS is meant to focus on the default focus in the
+ * FocusArea.
+ */
+ public static final int FOCUS_DEFAULT = 1;
+
+ /**
+ * Value indicating that the ACTION_FOCUS is meant to focus on the first focusable view in the
+ * FocusArea.
+ */
+ public static final int FOCUS_FIRST = 2;
+
+ @IntDef(flag = true, value = {FOCUS_DEFAULT, FOCUS_FIRST})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface FocusActionType {
+ }
/** Prevent instantiation. */
private RotaryConstants() {}
diff --git a/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml b/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
index 405bbfe..4482848 100644
--- a/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
+++ b/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
@@ -16,17 +16,28 @@
-->
<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">
+ <View
+ android:focusable="true"
+ android:layout_width="10dp"
+ android:layout_height="10dp"/>
<com.android.car.ui.TestFocusArea
android:id="@+id/focus_area"
android:layout_width="wrap_content"
- android:layout_height="wrap_content">
+ android:layout_height="wrap_content"
+ app:defaultFocus="@+id/default_focus">
<View
android:id="@+id/child"
android:focusable="true"
android:layout_width="10dp"
android:layout_height="10dp"/>
+ <View
+ android:id="@+id/default_focus"
+ android:focusable="true"
+ android:layout_width="10dp"
+ android:layout_height="10dp"/>
</com.android.car.ui.TestFocusArea>
<View
android:id="@+id/non_child"
diff --git a/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java b/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
index 8b2ddc1..fab32b7 100644
--- a/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
+++ b/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
@@ -16,8 +16,15 @@
package com.android.car.ui;
-import static org.junit.Assert.assertTrue;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_ACTION_TYPE;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_DEFAULT;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_FIRST;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
import android.view.View;
import androidx.test.rule.ActivityTestRule;
@@ -42,6 +49,7 @@
private FocusAreaTestActivity mActivity;
private TestFocusArea mFocusArea;
private View mChild;
+ private View mDefaultFocus;
private View mNonChild;
@Before
@@ -49,6 +57,7 @@
mActivity = mActivityRule.getActivity();
mFocusArea = mActivity.findViewById(R.id.focus_area);
mChild = mActivity.findViewById(R.id.child);
+ mDefaultFocus = mActivity.findViewById(R.id.default_focus);
mNonChild = mActivity.findViewById(R.id.non_child);
}
@@ -90,9 +99,39 @@
assertDrawMethodsCalled(latch);
}
+ @Test
+ public void testFocusOnDefaultFocus() throws Exception {
+ assertThat(mDefaultFocus.isFocused()).isFalse();
+
+ Bundle bundle = new Bundle();
+ bundle.putInt(FOCUS_ACTION_TYPE, FOCUS_DEFAULT);
+ CountDownLatch latch = new CountDownLatch(1);
+ mFocusArea.post(() -> {
+ mFocusArea.performAccessibilityAction(ACTION_FOCUS, bundle);
+ latch.countDown();
+ });
+ latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
+ assertThat(mDefaultFocus.isFocused()).isTrue();
+ }
+
+ @Test
+ public void testFocusOnFirstFocusable() throws Exception {
+ assertThat(mChild.isFocused()).isFalse();
+
+ Bundle bundle = new Bundle();
+ bundle.putInt(FOCUS_ACTION_TYPE, FOCUS_FIRST);
+ CountDownLatch latch = new CountDownLatch(1);
+ mFocusArea.post(() -> {
+ mFocusArea.performAccessibilityAction(ACTION_FOCUS, bundle);
+ latch.countDown();
+ });
+ latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
+ assertThat(mChild.isFocused()).isTrue();
+ }
+
private void assertDrawMethodsCalled(CountDownLatch latch) throws Exception {
latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
- assertTrue(mFocusArea.onDrawCalled());
- assertTrue(mFocusArea.drawCalled());
+ assertThat(mFocusArea.onDrawCalled()).isTrue();
+ assertThat(mFocusArea.drawCalled()).isTrue();
}
}