| /* |
| * 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.AccessibilityEvent.TYPE_VIEW_FOCUSED; |
| import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS; |
| |
| import static com.android.car.ui.utils.RotaryConstants.ACTION_DISMISS_POPUP_WINDOW; |
| 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.content.res.Resources; |
| import android.content.res.TypedArray; |
| import android.graphics.Rect; |
| import android.os.Bundle; |
| import android.os.SystemClock; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewTreeObserver.OnGlobalFocusChangeListener; |
| import android.view.ViewTreeObserver.OnGlobalLayoutListener; |
| import android.view.inputmethod.InputMethodManager; |
| |
| import androidx.annotation.Nullable; |
| import androidx.recyclerview.widget.RecyclerView; |
| |
| 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. 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 |
| * 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 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 Android frameworks want 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"; |
| private static final boolean DEBUG = false; |
| |
| /** Interface used to dismiss a popup window. */ |
| public interface OnDismissPopupWindow { |
| void onDismiss(); |
| } |
| |
| @Nullable |
| private OnDismissPopupWindow mOnDismissPopupWindow; |
| |
| /** |
| * 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 cache to save the previously focused view. */ |
| private RotaryCache.FocusCache mFocusCache; |
| |
| /** The scrollable container that contains the {@link #mFocusedView}, if any. */ |
| @Nullable |
| ViewGroup mScrollableContainer; |
| |
| /** |
| * Whether to restore focus when Android frameworks want to focus this view. When false, this |
| * view allows itself to be focused instead. This should be false if this view is in a |
| * {@link com.android.wm.shell.TaskView}. The default value is true. |
| */ |
| private boolean mShouldRestoreFocus = true; |
| |
| /** |
| * Whether to focus on the default focus view when nudging to the explicit focus area containing |
| * this FocusParkingView, even if there was another view in the explicit focus area focused |
| * before. |
| */ |
| private boolean mDefaultFocusOverridesHistory; |
| |
| private final OnGlobalFocusChangeListener mFocusChangeListener = |
| (oldFocus, newFocus) -> { |
| if (DEBUG) { |
| Log.d(TAG, "oldFocus:" + oldFocus + ", newFocus:" + newFocus); |
| for (StackTraceElement ste : Thread.currentThread().getStackTrace()) { |
| Log.d(TAG, ste.toString()); |
| } |
| } |
| // Keep track of the focused view so that we can recover focus when it's removed. |
| View focusedView = newFocus instanceof FocusParkingView ? null : newFocus; |
| updateFocusedView(focusedView); |
| }; |
| |
| public FocusParkingView(Context context) { |
| super(context); |
| init(context, /* attrs= */ null); |
| } |
| |
| public FocusParkingView(Context context, @Nullable AttributeSet attrs) { |
| super(context, attrs); |
| init(context, attrs); |
| } |
| |
| public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { |
| super(context, attrs, defStyleAttr); |
| init(context, attrs); |
| } |
| |
| public FocusParkingView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, |
| int defStyleRes) { |
| super(context, attrs, defStyleAttr, defStyleRes); |
| init(context, attrs); |
| } |
| |
| private void init(Context context, @Nullable AttributeSet attrs) { |
| if (attrs != null) { |
| TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FocusParkingView); |
| mShouldRestoreFocus = a.getBoolean(R.styleable.FocusParkingView_shouldRestoreFocus, |
| /* defValue= */ true); |
| } |
| |
| // 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); |
| |
| Resources resources = getResources(); |
| @RotaryCache.CacheType |
| int focusHistoryCacheType = resources.getInteger(R.integer.car_ui_focus_history_cache_type); |
| int focusHistoryExpirationPeriodMs = |
| resources.getInteger(R.integer.car_ui_focus_history_expiration_period_ms); |
| mFocusCache = |
| new RotaryCache.FocusCache(focusHistoryCacheType, focusHistoryExpirationPeriodMs); |
| |
| mDefaultFocusOverridesHistory = resources.getBoolean( |
| R.bool.car_ui_focus_area_default_focus_overrides_history); |
| } |
| |
| private void updateFocusedView(@Nullable View focusedView) { |
| if (mFocusedView != null) { |
| mFocusCache.setFocusedView(mFocusedView, SystemClock.uptimeMillis()); |
| } |
| mFocusedView = focusedView; |
| mScrollableContainer = ViewUtils.getAncestorScrollableContainer(focusedView); |
| } |
| |
| @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 |
| protected void onAttachedToWindow() { |
| super.onAttachedToWindow(); |
| getViewTreeObserver().addOnGlobalFocusChangeListener(mFocusChangeListener); |
| // Disable restore focus behavior if this view is in a TaskView. |
| if (mShouldRestoreFocus && ViewUtils.isInMultiWindowMode(this)) { |
| mShouldRestoreFocus = false; |
| } |
| } |
| |
| @Override |
| protected void onDetachedFromWindow() { |
| getViewTreeObserver().removeOnGlobalFocusChangeListener(mFocusChangeListener); |
| super.onDetachedFromWindow(); |
| } |
| |
| @Override |
| public void onWindowFocusChanged(boolean hasWindowFocus) { |
| // TODO(b/201700195): sometimes onWindowFocusChanged() won't be called when window focus |
| // has changed, so add the log for debugging. |
| Log.d(TAG, "The window of Activity [" + ViewUtils.findActivity(getContext()) |
| + (hasWindowFocus ? "] gained" : "] lost") + " focus"); |
| |
| if (!hasWindowFocus) { |
| // 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); |
| |
| // OnGlobalFocusChangeListener won't be triggered when the window lost focus, so reset |
| // the focused view here. |
| updateFocusedView(null); |
| } else if (isFocused() && mShouldRestoreFocus) { |
| // When FocusParkingView is focused and the window just gets focused, transfer the view |
| // focus to a non-FocusParkingView in the window. |
| restoreFocusInRoot(/* checkForTouchMode= */ true); |
| } |
| 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: |
| return restoreFocusInRoot(/* checkForTouchMode= */ 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 super.requestFocus(FOCUS_DOWN, null); |
| } |
| return false; |
| case ACTION_DISMISS_POPUP_WINDOW: |
| if (mOnDismissPopupWindow != null) { |
| mOnDismissPopupWindow.onDismiss(); |
| return true; |
| } |
| return false; |
| } |
| return super.performAccessibilityAction(action, arguments); |
| } |
| |
| @Override |
| public boolean requestFocus(int direction, Rect previouslyFocusedRect) { |
| if (!mShouldRestoreFocus) { |
| return super.requestFocus(direction, previouslyFocusedRect); |
| } |
| // Find a better target to focus instead of focusing this FocusParkingView when |
| // Android frameworks want to focus it. |
| return restoreFocusInRoot(/* checkForTouchMode= */ true); |
| } |
| |
| @Override |
| public boolean restoreDefaultFocus() { |
| if (!mShouldRestoreFocus) { |
| return super.restoreDefaultFocus(); |
| } |
| // Find a better target to focus instead of focusing this FocusParkingView when the |
| // framework wants to focus it. |
| return restoreFocusInRoot(/* checkForTouchMode= */ true); |
| } |
| |
| /** |
| * Sets whether this view should restore focus when Android frameworks want to focus this view. |
| * When set to false, this view allows itself to be focused instead. This should be set to false |
| * for the {@code FocusParkingView} in a {@code TaskView}. The default value is true. |
| */ |
| public void setShouldRestoreFocus(boolean shouldRestoreFocus) { |
| mShouldRestoreFocus = shouldRestoreFocus; |
| } |
| |
| /** |
| * Sets an {@link OnDismissPopupWindow} callback. Removes the existing callback if |
| * {@code onDismissPopupWindow} is null. |
| */ |
| public void setOnPopupWindowDismiss(@Nullable OnDismissPopupWindow onDismissPopupWindow) { |
| mOnDismissPopupWindow = onDismissPopupWindow; |
| } |
| |
| private boolean restoreFocusInRoot(boolean checkForTouchMode) { |
| // Don't do anything in touch mode if checkForTouchMode is true. |
| if (checkForTouchMode && isInTouchMode()) { |
| return false; |
| } |
| |
| // When initializing the focus, if there is a focused view already, do nothing rather than |
| // moving focus to the view with highest priority. For example, when an item in |
| // RecyclerView is focused, and the RecyclerView does a full layout. See b/175630971. |
| if (mFocusedView != null && mFocusedView.isFocused() && mFocusedView.isAttachedToWindow()) { |
| return true; |
| } |
| |
| // The focused view was in a scrollable container and the Framework unfocused it because it |
| // was scrolled off the screen. In this case focus on the scrollable container so that the |
| // rotary controller can scroll the scrollable container. |
| if (maybeFocusOnScrollableContainer()) { |
| return true; |
| } |
| |
| // Otherwise try to find the best target view to focus. |
| View cachedFocusedView = mFocusCache.getFocusedView(SystemClock.uptimeMillis()); |
| if (ViewUtils.adjustFocus( |
| getRootView(), cachedFocusedView, mDefaultFocusOverridesHistory)) { |
| return true; |
| } |
| |
| // It failed to find a target view (e.g., all the views are not shown), so focus on this |
| // FocusParkingView as fallback. |
| return super.requestFocus(FOCUS_DOWN, /* previouslyFocusedRect= */ null); |
| } |
| |
| private boolean maybeFocusOnScrollableContainer() { |
| // If the focused view was in a scrollable container and it was scrolled off the screen, |
| // focus on the scrollable container. When a view is scrolled off the screen, it is no |
| // longer attached to window and its parent is not null. When a view is removed, its parent |
| // is null. There is no need to focus on the scrollable container when its focused element |
| // is removed. |
| if (mFocusedView != null && !mFocusedView.isAttachedToWindow() |
| && mFocusedView.getParent() != null && mScrollableContainer != null |
| && mScrollableContainer.isAttachedToWindow() && mScrollableContainer.isShown()) { |
| RecyclerView recyclerView = mScrollableContainer instanceof RecyclerView |
| ? (RecyclerView) mScrollableContainer |
| : null; |
| if (mScrollableContainer.requestFocus()) { |
| if (recyclerView != null && recyclerView.isComputingLayout()) { |
| // When a RecyclerView gains focus, it won't dispatch AccessibilityEvent if its |
| // layout is not ready. So wait until its layout is ready then dispatch the |
| // event. |
| getViewTreeObserver().addOnGlobalLayoutListener( |
| new OnGlobalLayoutListener() { |
| @Override |
| public void onGlobalLayout() { |
| // At this point the layout is complete and the dimensions of |
| // recyclerView and any child views are known. |
| recyclerView.sendAccessibilityEvent(TYPE_VIEW_FOCUSED); |
| getViewTreeObserver().removeOnGlobalLayoutListener(this); |
| } |
| }); |
| } |
| return true; |
| } |
| } |
| return false; |
| } |
| } |