blob: 8a42ceab0108953fd846329e1f38dd8d3d07a31f [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;
import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
import static com.android.car.ui.utils.RotaryConstants.ACTION_HIDE_IME;
import static com.android.car.ui.utils.RotaryConstants.ACTION_RESTORE_DEFAULT_FOCUS;
import android.content.Context;
import android.os.Bundle;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.Nullable;
import com.android.car.ui.utils.ViewUtils;
/**
* 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.
* <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
* windows being focused simultaneously. Adding this view to each window can fix this issue. This
* view is transparent and its default focus highlight is disabled, so it's invisible to the user no
* 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.
*/
public class FocusParkingView extends View {
private static final String TAG = "FocusParkingView";
public FocusParkingView(Context context) {
super(context);
init();
}
public FocusParkingView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
init();
}
private void init() {
// This view is focusable, visible and enabled so it can take focus.
setFocusable(View.FOCUSABLE);
setVisibility(VISIBLE);
setEnabled(true);
// This view is not clickable so it won't affect the app's behavior when the user clicks on
// it by accident.
setClickable(false);
// This view is always transparent.
setAlpha(0f);
// Prevent Android from drawing the default focus highlight for this view when it's focused.
setDefaultFocusHighlightEnabled(false);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// This size of the view is always 1 x 1 pixel, no matter what value is set in the layout
// file (match_parent, wrap_content, 100dp, 0dp, etc). Small size is to ensure it has little
// impact on the layout, non-zero size is to ensure it can take focus.
setMeasuredDimension(1, 1);
}
@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();
}
super.onWindowFocusChanged(hasWindowFocus);
}
@Override
public CharSequence getAccessibilityClassName() {
return FocusParkingView.class.getName();
}
@Override
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;
case ACTION_HIDE_IME:
InputMethodManager inputMethodManager =
getContext().getSystemService(InputMethodManager.class);
return inputMethodManager.hideSoftInputFromWindow(getWindowToken(),
/* flags= */ 0);
case ACTION_FOCUS:
// Don't leave this to View to handle as it will exit touch mode.
if (!hasFocus()) {
return requestFocus();
}
break;
}
return super.performAccessibilityAction(action, arguments);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
// 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);
}
}
}
}