| /* |
| * 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_NUDGE_SHORTCUT; |
| import static com.android.car.ui.utils.RotaryConstants.ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA; |
| import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET; |
| import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET; |
| 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 android.content.Context; |
| import android.content.res.Resources; |
| import android.content.res.TypedArray; |
| import android.graphics.Canvas; |
| import android.graphics.drawable.Drawable; |
| import android.os.Bundle; |
| import android.os.SystemClock; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| 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; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| |
| import com.android.car.ui.utils.CarUiUtils; |
| import com.android.car.ui.utils.ViewUtils; |
| |
| import java.util.Arrays; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| /** |
| * A {@link LinearLayout} used as a navigation block for the rotary controller. |
| * <p> |
| * The {@link com.android.car.rotary.RotaryService} looks for instances of {@link FocusArea} in the |
| * view hierarchy when handling rotate and nudge actions. When receiving a rotation event ({@link |
| * android.car.input.RotaryEvent}), RotaryService will move the focus to another {@link View} that |
| * can take focus within the same FocusArea. When receiving a nudge event ({@link |
| * KeyEvent#KEYCODE_SYSTEM_NAVIGATION_UP}, {@link KeyEvent#KEYCODE_SYSTEM_NAVIGATION_DOWN}, {@link |
| * KeyEvent#KEYCODE_SYSTEM_NAVIGATION_LEFT}, or {@link KeyEvent#KEYCODE_SYSTEM_NAVIGATION_RIGHT}), |
| * RotaryService will move the focus to another view that can take focus in another (typically |
| * adjacent) FocusArea. |
| * <p> |
| * If enabled, FocusArea can draw highlights when one of its descendants has focus and it's not in |
| * touch mode. |
| * <p> |
| * When creating a navigation block in the layout file, if you intend to use a LinearLayout as a |
| * container for that block, just use a FocusArea instead; otherwise wrap the block in a FocusArea. |
| * <p> |
| * DO NOT nest a FocusArea inside another FocusArea because it will result in undefined navigation |
| * behavior. |
| */ |
| public class FocusArea extends LinearLayout { |
| |
| private static final String TAG = "FocusArea"; |
| |
| private static final int INVALID_DIMEN = -1; |
| |
| private static final int INVALID_DIRECTION = -1; |
| |
| private static final List<Integer> NUDGE_DIRECTIONS = |
| Arrays.asList(FOCUS_LEFT, FOCUS_RIGHT, FOCUS_UP, FOCUS_DOWN); |
| |
| /** Whether the FocusArea's descendant has focus (the FocusArea itself is not focusable). */ |
| private boolean mHasFocus; |
| |
| /** |
| * Whether to draw {@link #mForegroundHighlight} when one of the FocusArea's descendants has |
| * focus and it's not in touch mode. |
| */ |
| private boolean mEnableForegroundHighlight; |
| |
| /** |
| * Whether to draw {@link #mBackgroundHighlight} when one of the FocusArea's descendants has |
| * focus and it's not in touch mode. |
| */ |
| private boolean mEnableBackgroundHighlight; |
| |
| /** |
| * Highlight (typically outline of the FocusArea) drawn on top of the FocusArea and its |
| * descendants. |
| */ |
| private Drawable mForegroundHighlight; |
| |
| /** |
| * Highlight (typically a solid or gradient shape) drawn on top of the FocusArea but behind its |
| * descendants. |
| */ |
| private Drawable mBackgroundHighlight; |
| |
| /** The padding (in pixels) of the FocusArea highlight. */ |
| private int mPaddingLeft; |
| private int mPaddingRight; |
| private int mPaddingTop; |
| private int mPaddingBottom; |
| |
| /** The offset (in pixels) of the FocusArea's bounds. */ |
| private int mLeftOffset; |
| private int mRightOffset; |
| private int mTopOffset; |
| private int mBottomOffset; |
| |
| /** Whether the layout direction is {@link View#LAYOUT_DIRECTION_RTL}. */ |
| private boolean mRtl; |
| |
| /** The ID of the view specified in {@code app:defaultFocus}. */ |
| private int mDefaultFocusId; |
| /** The view specified in {@code app:defaultFocus}. */ |
| @Nullable |
| private View mDefaultFocusView; |
| |
| /** |
| * Whether to focus on the {@code app:defaultFocus} view when nudging to the FocusArea, even if |
| * there was another view in the FocusArea focused before. |
| */ |
| private boolean mDefaultFocusOverridesHistory; |
| |
| /** The ID of the view specified in {@code app:nudgeShortcut}. */ |
| private int mNudgeShortcutId; |
| /** The view specified in {@code app:nudgeShortcut}. */ |
| @Nullable |
| private View mNudgeShortcutView; |
| |
| /** The direction specified in {@code app:nudgeShortcutDirection}. */ |
| private int mNudgeShortcutDirection; |
| |
| /** |
| * Map of nudge target FocusArea IDs specified in {@code app:nudgeLeft}, {@code app:nudgRight}, |
| * {@code app:nudgeUp}, or {@code app:nudgeDown}. |
| */ |
| private Map<Integer, Integer> mSpecifiedNudgeIdMap; |
| |
| /** Map of specified nudge target FocusAreas. */ |
| private Map<Integer, FocusArea> mSpecifiedNudgeFocusAreaMap; |
| |
| /** |
| * Cache of focus history and nudge history of the rotary controller. |
| * <p> |
| * For focus history, the previously focused view and a timestamp will be saved when the |
| * focused view has changed. |
| * <p> |
| * For nudge history, the target FocusArea, direction, and a timestamp will be saved when the |
| * focus has moved from another FocusArea to this FocusArea. There are 2 cases: |
| * <ul> |
| * <li>The focus is moved to another FocusArea because this FocusArea has called {@link |
| * #nudgeToAnotherFocusArea}. In this case, the target FocusArea and direction are |
| * trivial to this FocusArea. |
| * <li>The focus is moved to this FocusArea because RotaryService has performed {@link |
| * AccessibilityNodeInfo#ACTION_FOCUS} on this FocusArea. In this case, this FocusArea |
| * can get the source FocusArea through the {@link |
| * android.view.ViewTreeObserver.OnGlobalFocusChangeListener} registered, and can get |
| * the direction when handling the action. Since the listener is triggered before |
| * {@link #requestFocus} returns (which is called when handling the action), the |
| * source FocusArea is revealed earlier than the direction, so the nudge history should |
| * be saved when the direction is revealed. |
| * </ul> |
| */ |
| private RotaryCache mRotaryCache; |
| |
| /** Whether to clear focus area history when the user rotates the rotary controller. */ |
| private boolean mClearFocusAreaHistoryWhenRotating; |
| |
| /** The FocusArea that had focus before this FocusArea, if any. */ |
| private FocusArea mPreviousFocusArea; |
| |
| /** The focused view in this FocusArea, if any. */ |
| private View mFocusedView; |
| |
| public FocusArea(Context context) { |
| super(context); |
| init(context, null); |
| } |
| |
| public FocusArea(Context context, @Nullable AttributeSet attrs) { |
| super(context, attrs); |
| init(context, attrs); |
| } |
| |
| public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { |
| super(context, attrs, defStyleAttr); |
| init(context, attrs); |
| } |
| |
| public FocusArea(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) { |
| Resources resources = getContext().getResources(); |
| mEnableForegroundHighlight = resources.getBoolean( |
| R.bool.car_ui_enable_focus_area_foreground_highlight); |
| mEnableBackgroundHighlight = resources.getBoolean( |
| R.bool.car_ui_enable_focus_area_background_highlight); |
| mForegroundHighlight = resources.getDrawable( |
| R.drawable.car_ui_focus_area_foreground_highlight, getContext().getTheme()); |
| mBackgroundHighlight = resources.getDrawable( |
| R.drawable.car_ui_focus_area_background_highlight, getContext().getTheme()); |
| |
| mDefaultFocusOverridesHistory = resources.getBoolean( |
| R.bool.car_ui_focus_area_default_focus_overrides_history); |
| mClearFocusAreaHistoryWhenRotating = resources.getBoolean( |
| R.bool.car_ui_clear_focus_area_history_when_rotating); |
| |
| @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); |
| @RotaryCache.CacheType |
| int focusAreaHistoryCacheType = resources.getInteger( |
| R.integer.car_ui_focus_area_history_cache_type); |
| int focusAreaHistoryExpirationPeriodMs = |
| resources.getInteger(R.integer.car_ui_focus_area_history_expiration_period_ms); |
| mRotaryCache = new RotaryCache(focusHistoryCacheType, focusHistoryExpirationPeriodMs, |
| focusAreaHistoryCacheType, focusAreaHistoryExpirationPeriodMs); |
| |
| // Ensure that an AccessibilityNodeInfo is created for this view. |
| setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); |
| |
| // By default all ViewGroup subclasses do not call their draw() and onDraw() methods. We |
| // should enable it since we override these methods. |
| setWillNotDraw(false); |
| |
| registerFocusChangeListener(); |
| |
| initAttrs(context, attrs); |
| } |
| |
| private void registerFocusChangeListener() { |
| getViewTreeObserver().addOnGlobalFocusChangeListener( |
| (oldFocus, newFocus) -> { |
| boolean hasFocus = hasFocus(); |
| saveFocusHistory(hasFocus); |
| maybeUpdatePreviousFocusArea(hasFocus, oldFocus); |
| maybeClearFocusAreaHistory(hasFocus, oldFocus); |
| maybeUpdateFocusAreaHighlight(hasFocus); |
| mHasFocus = hasFocus; |
| }); |
| } |
| |
| private void saveFocusHistory(boolean hasFocus) { |
| if (!hasFocus) { |
| mRotaryCache.saveFocusedView(mFocusedView, SystemClock.uptimeMillis()); |
| mFocusedView = null; |
| return; |
| } |
| View v = getFocusedChild(); |
| while (v != null) { |
| if (v.isFocused()) { |
| break; |
| } |
| v = v instanceof ViewGroup ? ((ViewGroup) v).getFocusedChild() : null; |
| } |
| mFocusedView = v; |
| } |
| |
| /** |
| * Updates {@link #mPreviousFocusArea} when the focus has moved from another FocusArea to this |
| * FocusArea. |
| */ |
| private void maybeUpdatePreviousFocusArea(boolean hasFocus, View oldFocus) { |
| if (mHasFocus || !hasFocus || oldFocus == null) { |
| return; |
| } |
| mPreviousFocusArea = getAncestorFocusArea(oldFocus); |
| if (mPreviousFocusArea == null) { |
| Log.w(TAG, "No parent FocusArea for " + oldFocus); |
| } |
| } |
| |
| /** |
| * Clears FocusArea nudge history when the user rotates the controller to move focus within this |
| * FocusArea. |
| */ |
| private void maybeClearFocusAreaHistory(boolean hasFocus, View oldFocus) { |
| if (!mClearFocusAreaHistoryWhenRotating) { |
| return; |
| } |
| if (!hasFocus || oldFocus == null) { |
| return; |
| } |
| FocusArea oldFocusArea = getAncestorFocusArea(oldFocus); |
| if (oldFocusArea != this) { |
| return; |
| } |
| mRotaryCache.clearFocusAreaHistory(); |
| } |
| |
| /** Updates highlight of the FocusArea if this FocusArea has gained or lost focus. */ |
| private void maybeUpdateFocusAreaHighlight(boolean hasFocus) { |
| if (!mEnableBackgroundHighlight && !mEnableForegroundHighlight) { |
| return; |
| } |
| if (mHasFocus != hasFocus) { |
| invalidate(); |
| } |
| } |
| |
| 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); |
| |
| // Initialize the highlight padding. The padding, for example, left padding, is set in |
| // the following order: |
| // 1. if highlightPaddingStart (or highlightPaddingEnd in RTL layout) specified, use it |
| // 2. otherwise, if highlightPaddingHorizontal is specified, use it |
| // 3. otherwise use 0 |
| |
| int paddingStart = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingStart, INVALID_DIMEN); |
| if (paddingStart == INVALID_DIMEN) { |
| paddingStart = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingHorizontal, 0); |
| } |
| |
| int paddingEnd = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingEnd, INVALID_DIMEN); |
| if (paddingEnd == INVALID_DIMEN) { |
| paddingEnd = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingHorizontal, 0); |
| } |
| |
| mRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL; |
| mPaddingLeft = mRtl ? paddingEnd : paddingStart; |
| mPaddingRight = mRtl ? paddingStart : paddingEnd; |
| |
| mPaddingTop = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingTop, INVALID_DIMEN); |
| if (mPaddingTop == INVALID_DIMEN) { |
| mPaddingTop = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingVertical, 0); |
| } |
| |
| mPaddingBottom = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingBottom, INVALID_DIMEN); |
| if (mPaddingBottom == INVALID_DIMEN) { |
| mPaddingBottom = a.getDimensionPixelSize( |
| R.styleable.FocusArea_highlightPaddingVertical, 0); |
| } |
| |
| // Initialize the offset of the FocusArea's bounds. The offset, for example, left |
| // offset, is set in the following order: |
| // 1. if startBoundOffset (or endBoundOffset in RTL layout) specified, use it |
| // 2. otherwise, if horizontalBoundOffset is specified, use it |
| // 3. otherwise use mPaddingLeft |
| |
| int startOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_startBoundOffset, INVALID_DIMEN); |
| if (startOffset == INVALID_DIMEN) { |
| startOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_horizontalBoundOffset, paddingStart); |
| } |
| |
| int endOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_endBoundOffset, INVALID_DIMEN); |
| if (endOffset == INVALID_DIMEN) { |
| endOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_horizontalBoundOffset, paddingEnd); |
| } |
| |
| mLeftOffset = mRtl ? endOffset : startOffset; |
| mRightOffset = mRtl ? startOffset : endOffset; |
| |
| mTopOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_topBoundOffset, INVALID_DIMEN); |
| if (mTopOffset == INVALID_DIMEN) { |
| mTopOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_verticalBoundOffset, mPaddingTop); |
| } |
| |
| mBottomOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_bottomBoundOffset, INVALID_DIMEN); |
| if (mBottomOffset == INVALID_DIMEN) { |
| mBottomOffset = a.getDimensionPixelSize( |
| R.styleable.FocusArea_verticalBoundOffset, mPaddingBottom); |
| } |
| |
| mNudgeShortcutId = a.getResourceId(R.styleable.FocusArea_nudgeShortcut, View.NO_ID); |
| mNudgeShortcutDirection = a.getInt( |
| R.styleable.FocusArea_nudgeShortcutDirection, INVALID_DIRECTION); |
| if ((mNudgeShortcutId == View.NO_ID) ^ (mNudgeShortcutDirection == INVALID_DIRECTION)) { |
| throw new IllegalStateException("nudgeShortcut and nudgeShortcutDirection must " |
| + "be specified together"); |
| } |
| |
| mSpecifiedNudgeIdMap = new HashMap<>(); |
| mSpecifiedNudgeIdMap.put(FOCUS_LEFT, |
| a.getResourceId(R.styleable.FocusArea_nudgeLeft, View.NO_ID)); |
| mSpecifiedNudgeIdMap.put(FOCUS_RIGHT, |
| a.getResourceId(R.styleable.FocusArea_nudgeRight, View.NO_ID)); |
| mSpecifiedNudgeIdMap.put(FOCUS_UP, |
| a.getResourceId(R.styleable.FocusArea_nudgeUp, View.NO_ID)); |
| mSpecifiedNudgeIdMap.put(FOCUS_DOWN, |
| a.getResourceId(R.styleable.FocusArea_nudgeDown, View.NO_ID)); |
| } finally { |
| a.recycle(); |
| } |
| } |
| |
| @Override |
| protected void onFinishInflate() { |
| super.onFinishInflate(); |
| if (mDefaultFocusId != View.NO_ID) { |
| mDefaultFocusView = CarUiUtils.requireViewByRefId(this, mDefaultFocusId); |
| } |
| if (mNudgeShortcutId != View.NO_ID) { |
| mNudgeShortcutView = CarUiUtils.requireViewByRefId(this, mNudgeShortcutId); |
| } |
| } |
| |
| @Override |
| protected void onLayout(boolean changed, int left, int top, int right, int bottom) { |
| super.onLayout(changed, left, top, right, bottom); |
| boolean rtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL; |
| if (mRtl != rtl) { |
| mRtl = rtl; |
| |
| int temp = mPaddingLeft; |
| mPaddingLeft = mPaddingRight; |
| mPaddingRight = temp; |
| |
| temp = mLeftOffset; |
| mLeftOffset = mRightOffset; |
| mRightOffset = temp; |
| } |
| } |
| |
| @Override |
| public boolean performAccessibilityAction(int action, Bundle arguments) { |
| switch (action) { |
| case ACTION_FOCUS: |
| // Repurpose ACTION_FOCUS to focus on its descendant. We can do this because |
| // FocusArea is not focusable and it didn't consume ACTION_FOCUS previously. |
| boolean success = focusOnDescendant(); |
| if (success && mPreviousFocusArea != null) { |
| int direction = getNudgeDirection(arguments); |
| if (direction != INVALID_DIRECTION) { |
| saveFocusAreaHistory(direction, mPreviousFocusArea, this, |
| SystemClock.uptimeMillis()); |
| } |
| } |
| return success; |
| case ACTION_NUDGE_SHORTCUT: |
| return nudgeToShortcutView(arguments); |
| case ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA: |
| return nudgeToAnotherFocusArea(arguments); |
| default: |
| return super.performAccessibilityAction(action, arguments); |
| } |
| } |
| |
| private boolean focusOnDescendant() { |
| if (focusOnFocusedByDefaultView()) { |
| return true; |
| } |
| if (focusOnPrimaryFocusView()) { |
| return true; |
| } |
| if (mDefaultFocusOverridesHistory) { |
| // Check mDefaultFocus before last focused view. |
| if (focusDefaultFocusView() || focusOnLastFocusedView()) { |
| return true; |
| } |
| } else { |
| // Check last focused view before mDefaultFocus. |
| if (focusOnLastFocusedView() || focusDefaultFocusView()) { |
| return true; |
| } |
| } |
| 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); |
| } |
| |
| private boolean focusOnLastFocusedView() { |
| View lastFocusedView = mRotaryCache.getFocusedView(SystemClock.uptimeMillis()); |
| return requestFocus(lastFocusedView); |
| } |
| |
| private boolean focusOnFirstFocusableView() { |
| View firstFocusableView = ViewUtils.findFocusableDescendant(this); |
| return requestFocus(firstFocusableView); |
| } |
| |
| private boolean nudgeToShortcutView(Bundle arguments) { |
| if (mNudgeShortcutDirection == INVALID_DIRECTION) { |
| // No nudge shortcut configured for this FocusArea. |
| return false; |
| } |
| if (arguments == null |
| || arguments.getInt(NUDGE_DIRECTION, INVALID_DIRECTION) |
| != mNudgeShortcutDirection) { |
| // The user is not nudging in the nudge shortcut direction. |
| return false; |
| } |
| if (mNudgeShortcutView.isFocused()) { |
| // The nudge shortcut view is already focused; return false so that the user can |
| // nudge to another FocusArea. |
| return false; |
| } |
| return requestFocus(mNudgeShortcutView); |
| } |
| |
| private boolean nudgeToAnotherFocusArea(Bundle arguments) { |
| int direction = getNudgeDirection(arguments); |
| long elapsedRealtime = SystemClock.uptimeMillis(); |
| |
| // Try to nudge to specified FocusArea, if any. |
| FocusArea targetFocusArea = getSpecifiedFocusArea(direction); |
| boolean success = targetFocusArea != null && targetFocusArea.focusOnDescendant(); |
| |
| // If failed, try to nudge to cached FocusArea, if any. |
| if (!success) { |
| targetFocusArea = mRotaryCache.getCachedFocusArea(direction, elapsedRealtime); |
| success = targetFocusArea != null && targetFocusArea.focusOnDescendant(); |
| } |
| |
| if (success) { |
| saveFocusAreaHistory(direction, this, targetFocusArea, elapsedRealtime); |
| } |
| return success; |
| } |
| |
| private static int getNudgeDirection(Bundle arguments) { |
| return arguments == null |
| ? INVALID_DIRECTION |
| : arguments.getInt(NUDGE_DIRECTION, INVALID_DIRECTION); |
| } |
| |
| /** Saves bidirectional FocusArea nudge history. */ |
| private void saveFocusAreaHistory(int direction, @NonNull FocusArea sourceFocusArea, |
| @NonNull FocusArea targetFocusArea, long elapsedRealtime) { |
| sourceFocusArea.mRotaryCache.saveFocusArea(direction, targetFocusArea, elapsedRealtime); |
| |
| int oppositeDirection = getOppositeDirection(direction); |
| targetFocusArea.mRotaryCache.saveFocusArea(oppositeDirection, sourceFocusArea, |
| elapsedRealtime); |
| } |
| |
| /** Returns the direction opposite the given {@code direction} */ |
| @VisibleForTesting |
| private static int getOppositeDirection(int direction) { |
| switch (direction) { |
| case View.FOCUS_LEFT: |
| return View.FOCUS_RIGHT; |
| case View.FOCUS_RIGHT: |
| return View.FOCUS_LEFT; |
| case View.FOCUS_UP: |
| return View.FOCUS_DOWN; |
| case View.FOCUS_DOWN: |
| return View.FOCUS_UP; |
| } |
| throw new IllegalArgumentException("direction must be " |
| + "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(); |
| return mSpecifiedNudgeFocusAreaMap.get(direction); |
| } |
| |
| @Override |
| public void onDraw(Canvas canvas) { |
| super.onDraw(canvas); |
| |
| // Draw highlight on top of this FocusArea (including its background and content) but |
| // behind its children. |
| if (mEnableBackgroundHighlight && mHasFocus && !isInTouchMode()) { |
| mBackgroundHighlight.setBounds( |
| mPaddingLeft + getScrollX(), |
| mPaddingTop + getScrollY(), |
| getScrollX() + getWidth() - mPaddingRight, |
| getScrollY() + getHeight() - mPaddingBottom); |
| mBackgroundHighlight.draw(canvas); |
| } |
| } |
| |
| @Override |
| public void draw(Canvas canvas) { |
| super.draw(canvas); |
| |
| // Draw highlight on top of this FocusArea (including its background and content) and its |
| // children (including background, content, focus highlight, etc). |
| if (mEnableForegroundHighlight && mHasFocus && !isInTouchMode()) { |
| mForegroundHighlight.setBounds( |
| mPaddingLeft + getScrollX(), |
| mPaddingTop + getScrollY(), |
| getScrollX() + getWidth() - mPaddingRight, |
| getScrollY() + getHeight() - mPaddingBottom); |
| mForegroundHighlight.draw(canvas); |
| } |
| } |
| |
| @Override |
| public CharSequence getAccessibilityClassName() { |
| return FocusArea.class.getName(); |
| } |
| |
| @Override |
| public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) { |
| super.onInitializeAccessibilityNodeInfo(info); |
| Bundle bundle = info.getExtras(); |
| bundle.putInt(FOCUS_AREA_LEFT_BOUND_OFFSET, mLeftOffset); |
| bundle.putInt(FOCUS_AREA_RIGHT_BOUND_OFFSET, mRightOffset); |
| bundle.putInt(FOCUS_AREA_TOP_BOUND_OFFSET, mTopOffset); |
| bundle.putInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET, mBottomOffset); |
| } |
| |
| private void maybeInitializeSpecifiedFocusAreas() { |
| if (mSpecifiedNudgeFocusAreaMap != null) { |
| return; |
| } |
| View root = getRootView(); |
| mSpecifiedNudgeFocusAreaMap = new HashMap<>(); |
| for (Integer direction : NUDGE_DIRECTIONS) { |
| int id = mSpecifiedNudgeIdMap.get(direction); |
| mSpecifiedNudgeFocusAreaMap.put(direction, root.findViewById(id)); |
| } |
| } |
| |
| 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 |
| && mPaddingBottom == bottom) { |
| return; |
| } |
| mPaddingLeft = left; |
| mPaddingTop = top; |
| mPaddingRight = right; |
| mPaddingBottom = bottom; |
| invalidate(); |
| } |
| |
| /** Sets the offset (in pixels) of the FocusArea's bounds. */ |
| public void setBoundsOffset(int left, int top, int right, int bottom) { |
| mLeftOffset = left; |
| mTopOffset = top; |
| mRightOffset = right; |
| mBottomOffset = bottom; |
| } |
| |
| @VisibleForTesting |
| void enableForegroundHighlight() { |
| mEnableForegroundHighlight = true; |
| } |
| } |