• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2020 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.ui;
18 
19 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
20 
21 import static com.android.car.ui.utils.RotaryConstants.ACTION_NUDGE_SHORTCUT;
22 import static com.android.car.ui.utils.RotaryConstants.ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA;
23 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET;
24 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET;
25 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
26 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET;
27 import static com.android.car.ui.utils.RotaryConstants.NUDGE_DIRECTION;
28 
29 import android.content.Context;
30 import android.content.res.Resources;
31 import android.content.res.TypedArray;
32 import android.graphics.Canvas;
33 import android.graphics.Rect;
34 import android.graphics.drawable.Drawable;
35 import android.os.Bundle;
36 import android.os.SystemClock;
37 import android.util.AttributeSet;
38 import android.util.Log;
39 import android.util.SparseArray;
40 import android.util.SparseIntArray;
41 import android.view.FocusFinder;
42 import android.view.KeyEvent;
43 import android.view.View;
44 import android.view.ViewGroup;
45 import android.view.ViewTreeObserver.OnGlobalFocusChangeListener;
46 import android.view.accessibility.AccessibilityNodeInfo;
47 import android.widget.LinearLayout;
48 
49 import androidx.annotation.NonNull;
50 import androidx.annotation.Nullable;
51 import androidx.annotation.VisibleForTesting;
52 
53 import com.android.car.ui.utils.ViewUtils;
54 
55 import java.util.Arrays;
56 import java.util.Collections;
57 import java.util.List;
58 
59 /**
60  * A {@link LinearLayout} used as a navigation block for the rotary controller.
61  * <p>
62  * The {@link com.android.car.rotary.RotaryService} looks for instances of {@link FocusArea} in the
63  * view hierarchy when handling rotate and nudge actions. When receiving a rotation event ({@link
64  * android.car.input.RotaryEvent}), RotaryService will move the focus to another {@link View} that
65  * can take focus within the same FocusArea. When receiving a nudge event ({@link
66  * KeyEvent#KEYCODE_SYSTEM_NAVIGATION_UP}, {@link KeyEvent#KEYCODE_SYSTEM_NAVIGATION_DOWN}, {@link
67  * KeyEvent#KEYCODE_SYSTEM_NAVIGATION_LEFT}, or {@link KeyEvent#KEYCODE_SYSTEM_NAVIGATION_RIGHT}),
68  * RotaryService will move the focus to another view that can take focus in another (typically
69  * adjacent) FocusArea.
70  * <p>
71  * If enabled, FocusArea can draw highlights when one of its descendants has focus and it's not in
72  * touch mode.
73  * <p>
74  * When creating a navigation block in the layout file, if you intend to use a LinearLayout as a
75  * container for that block, just use a FocusArea instead; otherwise wrap the block in a FocusArea.
76  * <p>
77  * DO NOT nest a FocusArea inside another FocusArea because it will result in undefined navigation
78  * behavior.
79  */
80 public class FocusArea extends LinearLayout {
81 
82     private static final String TAG = "FocusArea";
83 
84     private static final int INVALID_DIMEN = -1;
85 
86     private static final int INVALID_DIRECTION = -1;
87 
88     private static final List<Integer> NUDGE_DIRECTIONS =
89             Collections.unmodifiableList(Arrays.asList(
90                     FOCUS_LEFT, FOCUS_RIGHT, FOCUS_UP, FOCUS_DOWN));
91 
92     /** Whether the FocusArea's descendant has focus (the FocusArea itself is not focusable). */
93     private boolean mHasFocus;
94 
95     /**
96      * Whether to draw {@link #mForegroundHighlight} when one of the FocusArea's descendants has
97      * focus and it's not in touch mode.
98      */
99     private boolean mEnableForegroundHighlight;
100 
101     /**
102      * Whether to draw {@link #mBackgroundHighlight} when one of the FocusArea's descendants has
103      * focus and it's not in touch mode.
104      */
105     private boolean mEnableBackgroundHighlight;
106 
107     /**
108      * Highlight (typically outline of the FocusArea) drawn on top of the FocusArea and its
109      * descendants.
110      */
111     private Drawable mForegroundHighlight;
112 
113     /**
114      * Highlight (typically a solid or gradient shape) drawn on top of the FocusArea but behind its
115      * descendants.
116      */
117     private Drawable mBackgroundHighlight;
118 
119     /** The padding (in pixels) of the FocusArea highlight. */
120     private int mPaddingLeft;
121     private int mPaddingRight;
122     private int mPaddingTop;
123     private int mPaddingBottom;
124 
125     /** The offset (in pixels) of the FocusArea's bounds. */
126     private int mLeftOffset;
127     private int mRightOffset;
128     private int mTopOffset;
129     private int mBottomOffset;
130 
131     /** Whether the layout direction is {@link View#LAYOUT_DIRECTION_RTL}. */
132     private boolean mRtl;
133 
134     /** The ID of the view specified in {@code app:defaultFocus}. */
135     private int mDefaultFocusId;
136     /** The view specified in {@code app:defaultFocus}. */
137     @Nullable
138     private View mDefaultFocusView;
139 
140     /**
141      * Whether to focus on the default focus view when nudging to the FocusArea, even if there was
142      * another view in the FocusArea focused before.
143      */
144     private boolean mDefaultFocusOverridesHistory;
145 
146     /**
147      * Map from direction to nudge shortcut IDs specified in {@code app:nudgeLeftShortcut},
148      * {@code app:nudgRightShortcut}, {@code app:nudgeUpShortcut}, and {@code app
149      * :nudgeDownShortcut}.
150      */
151     private final SparseIntArray mSpecifiedNudgeShortcutIdMap = new SparseIntArray();
152 
153     /** Map from direction to specified nudge shortcut views. */
154     private SparseArray<View> mSpecifiedNudgeShortcutMap;
155 
156     /**
157      * Map from direction to nudge target FocusArea IDs specified in {@code app:nudgeLeft},
158      * {@code app:nudgRight}, {@code app:nudgeUp}, or {@code app:nudgeDown}.
159      */
160     private final SparseIntArray mSpecifiedNudgeIdMap = new SparseIntArray();
161 
162     /** Map from direction to specified nudge target FocusAreas. */
163     private SparseArray<FocusArea> mSpecifiedNudgeFocusAreaMap;
164 
165     /** Whether wrap-around is enabled. */
166     private boolean mWrapAround;
167 
168     /**
169      * Cache of focus history and nudge history of the rotary controller.
170      * <p>
171      * For focus history, the previously focused view and a timestamp will be saved when the
172      * focused view has changed.
173      * <p>
174      * For nudge history, the target FocusArea, direction, and a timestamp will be saved when the
175      * focus has moved from another FocusArea to this FocusArea. There are 2 cases:
176      * <ul>
177      *     <li>The focus is moved to another FocusArea because this FocusArea has called {@link
178      *         #nudgeToAnotherFocusArea}. In this case, the target FocusArea and direction are
179      *         trivial to this FocusArea.
180      *     <li>The focus is moved to this FocusArea because RotaryService has performed {@link
181      *         AccessibilityNodeInfo#ACTION_FOCUS} on this FocusArea. In this case, this FocusArea
182      *         can get the source FocusArea through the {@link
183      *         android.view.ViewTreeObserver.OnGlobalFocusChangeListener} registered, and can get
184      *         the direction when handling the action. Since the listener is triggered before
185      *         {@link #requestFocus} returns (which is called when handling the action), the
186      *         source FocusArea is revealed earlier than the direction, so the nudge history should
187      *         be saved when the direction is revealed.
188      * </ul>
189      */
190     private RotaryCache mRotaryCache;
191 
192     /** Whether to clear focus area history when the user rotates the rotary controller. */
193     private boolean mClearFocusAreaHistoryWhenRotating;
194 
195     /** The FocusArea that had focus before this FocusArea, if any. */
196     private FocusArea mPreviousFocusArea;
197 
198     /** The focused view in this FocusArea, if any. */
199     private View mFocusedView;
200 
201     private final OnGlobalFocusChangeListener mFocusChangeListener =
202             (oldFocus, newFocus) -> {
203                 boolean hasFocus = hasFocus();
204                 saveFocusHistory(hasFocus);
205                 maybeUpdatePreviousFocusArea(hasFocus, oldFocus);
206                 maybeClearFocusAreaHistory(hasFocus, oldFocus);
207                 maybeUpdateFocusAreaHighlight(hasFocus);
208                 mHasFocus = hasFocus;
209             };
210 
FocusArea(Context context)211     public FocusArea(Context context) {
212         super(context);
213         init(context, null);
214     }
215 
FocusArea(Context context, @Nullable AttributeSet attrs)216     public FocusArea(Context context, @Nullable AttributeSet attrs) {
217         super(context, attrs);
218         init(context, attrs);
219     }
220 
FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr)221     public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
222         super(context, attrs, defStyleAttr);
223         init(context, attrs);
224     }
225 
FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)226     public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
227             int defStyleRes) {
228         super(context, attrs, defStyleAttr, defStyleRes);
229         init(context, attrs);
230     }
231 
init(Context context, @Nullable AttributeSet attrs)232     private void init(Context context, @Nullable AttributeSet attrs) {
233         Resources resources = getContext().getResources();
234         mEnableForegroundHighlight = resources.getBoolean(
235                 R.bool.car_ui_enable_focus_area_foreground_highlight);
236         mEnableBackgroundHighlight = resources.getBoolean(
237                 R.bool.car_ui_enable_focus_area_background_highlight);
238         mForegroundHighlight = resources.getDrawable(
239                 R.drawable.car_ui_focus_area_foreground_highlight, getContext().getTheme());
240         mBackgroundHighlight = resources.getDrawable(
241                 R.drawable.car_ui_focus_area_background_highlight, getContext().getTheme());
242 
243         mClearFocusAreaHistoryWhenRotating = resources.getBoolean(
244                 R.bool.car_ui_clear_focus_area_history_when_rotating);
245 
246         @RotaryCache.CacheType
247         int focusHistoryCacheType = resources.getInteger(R.integer.car_ui_focus_history_cache_type);
248         int focusHistoryExpirationPeriodMs =
249                 resources.getInteger(R.integer.car_ui_focus_history_expiration_period_ms);
250         @RotaryCache.CacheType
251         int focusAreaHistoryCacheType = resources.getInteger(
252                 R.integer.car_ui_focus_area_history_cache_type);
253         int focusAreaHistoryExpirationPeriodMs =
254                 resources.getInteger(R.integer.car_ui_focus_area_history_expiration_period_ms);
255         mRotaryCache = new RotaryCache(focusHistoryCacheType, focusHistoryExpirationPeriodMs,
256                 focusAreaHistoryCacheType, focusAreaHistoryExpirationPeriodMs);
257 
258         // Ensure that an AccessibilityNodeInfo is created for this view.
259         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
260 
261         // By default all ViewGroup subclasses do not call their draw() and onDraw() methods. We
262         // should enable it since we override these methods.
263         setWillNotDraw(false);
264 
265         initAttrs(context, attrs);
266     }
267 
saveFocusHistory(boolean hasFocus)268     private void saveFocusHistory(boolean hasFocus) {
269         // Save focus history and clear mFocusedView if focus is leaving this FocusArea.
270         if (!hasFocus) {
271             if (mHasFocus) {
272                 mRotaryCache.saveFocusedView(mFocusedView, SystemClock.uptimeMillis());
273                 mFocusedView = null;
274             }
275             return;
276         }
277 
278         // Update mFocusedView if a view inside this FocusArea is focused.
279         View v = getFocusedChild();
280         while (v != null) {
281             if (v.isFocused()) {
282                 break;
283             }
284             v = v instanceof ViewGroup ? ((ViewGroup) v).getFocusedChild() : null;
285         }
286         mFocusedView = v;
287     }
288 
289     /**
290      * Updates {@link #mPreviousFocusArea} when the focus has moved from another FocusArea to this
291      * FocusArea, and sets it to {@code null} in any other cases.
292      */
maybeUpdatePreviousFocusArea(boolean hasFocus, View oldFocus)293     private void maybeUpdatePreviousFocusArea(boolean hasFocus, View oldFocus) {
294         if (mHasFocus || !hasFocus || oldFocus == null || oldFocus instanceof FocusParkingView) {
295             mPreviousFocusArea = null;
296             return;
297         }
298         mPreviousFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
299         if (mPreviousFocusArea == null) {
300             Log.w(TAG, "No parent FocusArea for " + oldFocus);
301         }
302     }
303 
304     /**
305      * Clears FocusArea nudge history when the user rotates the controller to move focus within this
306      * FocusArea.
307      */
maybeClearFocusAreaHistory(boolean hasFocus, View oldFocus)308     private void maybeClearFocusAreaHistory(boolean hasFocus, View oldFocus) {
309         if (!mClearFocusAreaHistoryWhenRotating) {
310             return;
311         }
312         if (!hasFocus || oldFocus == null) {
313             return;
314         }
315         FocusArea oldFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
316         if (oldFocusArea != this) {
317             return;
318         }
319         mRotaryCache.clearFocusAreaHistory();
320     }
321 
322     /** Updates highlight of the FocusArea if this FocusArea has gained or lost focus. */
maybeUpdateFocusAreaHighlight(boolean hasFocus)323     private void maybeUpdateFocusAreaHighlight(boolean hasFocus) {
324         if (!mEnableBackgroundHighlight && !mEnableForegroundHighlight) {
325             return;
326         }
327         if (mHasFocus != hasFocus) {
328             invalidate();
329         }
330     }
331 
initAttrs(Context context, @Nullable AttributeSet attrs)332     private void initAttrs(Context context, @Nullable AttributeSet attrs) {
333         if (attrs == null) {
334             return;
335         }
336         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FocusArea);
337         try {
338             mDefaultFocusId = a.getResourceId(R.styleable.FocusArea_defaultFocus, View.NO_ID);
339 
340             // Initialize the highlight padding. The padding, for example, left padding, is set in
341             // the following order:
342             // 1. if highlightPaddingStart (or highlightPaddingEnd in RTL layout) specified, use it
343             // 2. otherwise, if highlightPaddingHorizontal is specified, use it
344             // 3. otherwise use 0
345 
346             int paddingStart = a.getDimensionPixelSize(
347                     R.styleable.FocusArea_highlightPaddingStart, INVALID_DIMEN);
348             if (paddingStart == INVALID_DIMEN) {
349                 paddingStart = a.getDimensionPixelSize(
350                         R.styleable.FocusArea_highlightPaddingHorizontal, 0);
351             }
352 
353             int paddingEnd = a.getDimensionPixelSize(
354                     R.styleable.FocusArea_highlightPaddingEnd, INVALID_DIMEN);
355             if (paddingEnd == INVALID_DIMEN) {
356                 paddingEnd = a.getDimensionPixelSize(
357                         R.styleable.FocusArea_highlightPaddingHorizontal, 0);
358             }
359 
360             mRtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
361             mPaddingLeft = mRtl ? paddingEnd : paddingStart;
362             mPaddingRight = mRtl ? paddingStart : paddingEnd;
363 
364             mPaddingTop = a.getDimensionPixelSize(
365                     R.styleable.FocusArea_highlightPaddingTop, INVALID_DIMEN);
366             if (mPaddingTop == INVALID_DIMEN) {
367                 mPaddingTop = a.getDimensionPixelSize(
368                         R.styleable.FocusArea_highlightPaddingVertical, 0);
369             }
370 
371             mPaddingBottom = a.getDimensionPixelSize(
372                     R.styleable.FocusArea_highlightPaddingBottom, INVALID_DIMEN);
373             if (mPaddingBottom == INVALID_DIMEN) {
374                 mPaddingBottom = a.getDimensionPixelSize(
375                         R.styleable.FocusArea_highlightPaddingVertical, 0);
376             }
377 
378             // Initialize the offset of the FocusArea's bounds. The offset, for example, left
379             // offset, is set in the following order:
380             // 1. if startBoundOffset (or endBoundOffset in RTL layout) specified, use it
381             // 2. otherwise, if horizontalBoundOffset is specified, use it
382             // 3. otherwise use mPaddingLeft
383 
384             int startOffset = a.getDimensionPixelSize(
385                     R.styleable.FocusArea_startBoundOffset, INVALID_DIMEN);
386             if (startOffset == INVALID_DIMEN) {
387                 startOffset = a.getDimensionPixelSize(
388                         R.styleable.FocusArea_horizontalBoundOffset, paddingStart);
389             }
390 
391             int endOffset = a.getDimensionPixelSize(
392                     R.styleable.FocusArea_endBoundOffset, INVALID_DIMEN);
393             if (endOffset == INVALID_DIMEN) {
394                 endOffset = a.getDimensionPixelSize(
395                         R.styleable.FocusArea_horizontalBoundOffset, paddingEnd);
396             }
397 
398             mLeftOffset = mRtl ? endOffset : startOffset;
399             mRightOffset = mRtl ? startOffset : endOffset;
400 
401             mTopOffset = a.getDimensionPixelSize(
402                     R.styleable.FocusArea_topBoundOffset, INVALID_DIMEN);
403             if (mTopOffset == INVALID_DIMEN) {
404                 mTopOffset = a.getDimensionPixelSize(
405                         R.styleable.FocusArea_verticalBoundOffset, mPaddingTop);
406             }
407 
408             mBottomOffset = a.getDimensionPixelSize(
409                     R.styleable.FocusArea_bottomBoundOffset, INVALID_DIMEN);
410             if (mBottomOffset == INVALID_DIMEN) {
411                 mBottomOffset = a.getDimensionPixelSize(
412                         R.styleable.FocusArea_verticalBoundOffset, mPaddingBottom);
413             }
414 
415             // Handle new nudge shortcut attributes.
416             if (a.hasValue(R.styleable.FocusArea_nudgeLeftShortcut)) {
417                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_LEFT,
418                         a.getResourceId(R.styleable.FocusArea_nudgeLeftShortcut, View.NO_ID));
419             }
420             if (a.hasValue(R.styleable.FocusArea_nudgeRightShortcut)) {
421                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_RIGHT,
422                         a.getResourceId(R.styleable.FocusArea_nudgeRightShortcut, View.NO_ID));
423             }
424             if (a.hasValue(R.styleable.FocusArea_nudgeUpShortcut)) {
425                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_UP,
426                         a.getResourceId(R.styleable.FocusArea_nudgeUpShortcut, View.NO_ID));
427             }
428             if (a.hasValue(R.styleable.FocusArea_nudgeDownShortcut)) {
429                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_DOWN,
430                         a.getResourceId(R.styleable.FocusArea_nudgeDownShortcut, View.NO_ID));
431             }
432 
433             // Handle legacy nudge shortcut attributes.
434             int nudgeShortcutId = a.getResourceId(R.styleable.FocusArea_nudgeShortcut, View.NO_ID);
435             int nudgeShortcutDirection = a.getInt(
436                     R.styleable.FocusArea_nudgeShortcutDirection, INVALID_DIRECTION);
437             if ((nudgeShortcutId == View.NO_ID) ^ (nudgeShortcutDirection == INVALID_DIRECTION)) {
438                 throw new IllegalStateException("nudgeShortcut and nudgeShortcutDirection must "
439                         + "be specified together");
440             }
441             if (nudgeShortcutId != View.NO_ID) {
442                 if (mSpecifiedNudgeShortcutIdMap.size() > 0) {
443                     throw new IllegalStateException(
444                             "Don't use nudgeShortcut/nudgeShortcutDirection and nudge*Shortcut in "
445                                     + "the same FocusArea. Use nudge*Shortcut only.");
446                 }
447                 mSpecifiedNudgeShortcutIdMap.put(nudgeShortcutDirection, nudgeShortcutId);
448             }
449 
450             // Handle nudge targets.
451             if (a.hasValue(R.styleable.FocusArea_nudgeLeft)) {
452                 mSpecifiedNudgeIdMap.put(FOCUS_LEFT,
453                         a.getResourceId(R.styleable.FocusArea_nudgeLeft, View.NO_ID));
454             }
455             if (a.hasValue(R.styleable.FocusArea_nudgeRight)) {
456                 mSpecifiedNudgeIdMap.put(FOCUS_RIGHT,
457                         a.getResourceId(R.styleable.FocusArea_nudgeRight, View.NO_ID));
458             }
459             if (a.hasValue(R.styleable.FocusArea_nudgeUp)) {
460                 mSpecifiedNudgeIdMap.put(FOCUS_UP,
461                         a.getResourceId(R.styleable.FocusArea_nudgeUp, View.NO_ID));
462             }
463             if (a.hasValue(R.styleable.FocusArea_nudgeDown)) {
464                 mSpecifiedNudgeIdMap.put(FOCUS_DOWN,
465                         a.getResourceId(R.styleable.FocusArea_nudgeDown, View.NO_ID));
466             }
467 
468             mDefaultFocusOverridesHistory = a.getBoolean(
469                     R.styleable.FocusArea_defaultFocusOverridesHistory, false);
470 
471             mWrapAround = a.getBoolean(R.styleable.FocusArea_wrapAround, false);
472         } finally {
473             a.recycle();
474         }
475     }
476 
477     @Override
onFinishInflate()478     protected void onFinishInflate() {
479         super.onFinishInflate();
480         if (mDefaultFocusId != View.NO_ID) {
481             mDefaultFocusView = requireViewById(mDefaultFocusId);
482         }
483     }
484 
485     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)486     protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
487         super.onLayout(changed, left, top, right, bottom);
488         boolean rtl = getLayoutDirection() == LAYOUT_DIRECTION_RTL;
489         if (mRtl != rtl) {
490             mRtl = rtl;
491 
492             int temp = mPaddingLeft;
493             mPaddingLeft = mPaddingRight;
494             mPaddingRight = temp;
495 
496             temp = mLeftOffset;
497             mLeftOffset = mRightOffset;
498             mRightOffset = temp;
499         }
500     }
501 
502     @Override
onAttachedToWindow()503     protected void onAttachedToWindow() {
504         super.onAttachedToWindow();
505         getViewTreeObserver().addOnGlobalFocusChangeListener(mFocusChangeListener);
506     }
507 
508     @Override
onDetachedFromWindow()509     protected void onDetachedFromWindow() {
510         getViewTreeObserver().removeOnGlobalFocusChangeListener(mFocusChangeListener);
511         super.onDetachedFromWindow();
512     }
513 
514     @Override
onWindowFocusChanged(boolean hasWindowFocus)515     public void onWindowFocusChanged(boolean hasWindowFocus) {
516         // To ensure the focus is initialized properly in rotary mode when there is a window focus
517         // change, this FocusArea will grab the focus if nothing is focused or the currently
518         // focused view's FocusLevel is lower than REGULAR_FOCUS.
519         if (hasWindowFocus && !isInTouchMode()) {
520             maybeInitFocus();
521         }
522         super.onWindowFocusChanged(hasWindowFocus);
523     }
524 
525     /**
526      * Focuses on another view in this FocusArea if nothing is focused or the currently focused
527      * view's FocusLevel is lower than REGULAR_FOCUS.
528      */
maybeInitFocus()529     private boolean maybeInitFocus() {
530         View root = getRootView();
531         View focus = root.findFocus();
532         return ViewUtils.initFocus(root, focus);
533     }
534 
535     /**
536      * Focuses on a view in this FocusArea if the view is a better focus candidate than the
537      * currently focused view.
538      */
maybeAdjustFocus()539     private boolean maybeAdjustFocus() {
540         View root = getRootView();
541         View focus = root.findFocus();
542         return ViewUtils.adjustFocus(root, focus);
543     }
544 
545 
546     @Override
performAccessibilityAction(int action, Bundle arguments)547     public boolean performAccessibilityAction(int action, Bundle arguments) {
548         switch (action) {
549             case ACTION_FOCUS:
550                 // Repurpose ACTION_FOCUS to focus on its descendant. We can do this because
551                 // FocusArea is not focusable and it didn't consume ACTION_FOCUS previously.
552                 boolean success = focusOnDescendant();
553                 if (success && mPreviousFocusArea != null) {
554                     int direction = getNudgeDirection(arguments);
555                     if (direction != INVALID_DIRECTION) {
556                         saveFocusAreaHistory(direction, mPreviousFocusArea, this,
557                                 SystemClock.uptimeMillis());
558                     }
559                 }
560                 return success;
561             case ACTION_NUDGE_SHORTCUT:
562                 return nudgeToShortcutView(arguments);
563             case ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA:
564                 return nudgeToAnotherFocusArea(arguments);
565             default:
566                 return super.performAccessibilityAction(action, arguments);
567         }
568     }
569 
focusOnDescendant()570     private boolean focusOnDescendant() {
571         View lastFocusedView = mRotaryCache.getFocusedView(SystemClock.uptimeMillis());
572         return ViewUtils.adjustFocus(this, lastFocusedView, mDefaultFocusOverridesHistory);
573     }
574 
575     /**
576      * Gets the {@code app:defaultFocus} view.
577      *
578      * @hidden
579      */
580     @Nullable
getDefaultFocusView()581     public View getDefaultFocusView() {
582         return mDefaultFocusView;
583     }
584 
nudgeToShortcutView(Bundle arguments)585     private boolean nudgeToShortcutView(Bundle arguments) {
586         int direction = getNudgeDirection(arguments);
587         View targetView = getSpecifiedShortcut(direction);
588         if (targetView == null) {
589             // No nudge shortcut configured for the given direction.
590             return false;
591         }
592         if (targetView.isFocused()) {
593             // The nudge shortcut view is already focused; return false so that the user can
594             // nudge to another FocusArea.
595             return false;
596         }
597         return ViewUtils.requestFocus(targetView);
598     }
599 
nudgeToAnotherFocusArea(Bundle arguments)600     private boolean nudgeToAnotherFocusArea(Bundle arguments) {
601         int direction = getNudgeDirection(arguments);
602         long elapsedRealtime = SystemClock.uptimeMillis();
603 
604         // Try to nudge to specified FocusArea, if any.
605         FocusArea targetFocusArea = getSpecifiedFocusArea(direction);
606         boolean success = targetFocusArea != null && targetFocusArea.focusOnDescendant();
607 
608         // If failed, try to nudge to cached FocusArea, if any.
609         if (!success) {
610             targetFocusArea = mRotaryCache.getCachedFocusArea(direction, elapsedRealtime);
611             success = targetFocusArea != null && targetFocusArea.focusOnDescendant();
612         }
613 
614         return success;
615     }
616 
getNudgeDirection(Bundle arguments)617     private static int getNudgeDirection(Bundle arguments) {
618         return arguments == null
619                 ? INVALID_DIRECTION
620                 : arguments.getInt(NUDGE_DIRECTION, INVALID_DIRECTION);
621     }
622 
saveFocusAreaHistory(int direction, @NonNull FocusArea sourceFocusArea, @NonNull FocusArea targetFocusArea, long elapsedRealtime)623     private void saveFocusAreaHistory(int direction, @NonNull FocusArea sourceFocusArea,
624             @NonNull FocusArea targetFocusArea, long elapsedRealtime) {
625         // Save one-way rather than two-way nudge history to avoid infinite nudge loop.
626         if (sourceFocusArea.mRotaryCache.getCachedFocusArea(direction, elapsedRealtime) == null) {
627             // Save reversed nudge history so that the users can nudge back to where they were.
628             int oppositeDirection = getOppositeDirection(direction);
629             targetFocusArea.mRotaryCache.saveFocusArea(oppositeDirection, sourceFocusArea,
630                     elapsedRealtime);
631         }
632     }
633 
634     /** Returns the direction opposite the given {@code direction} */
getOppositeDirection(int direction)635     private static int getOppositeDirection(int direction) {
636         switch (direction) {
637             case View.FOCUS_LEFT:
638                 return View.FOCUS_RIGHT;
639             case View.FOCUS_RIGHT:
640                 return View.FOCUS_LEFT;
641             case View.FOCUS_UP:
642                 return View.FOCUS_DOWN;
643             case View.FOCUS_DOWN:
644                 return View.FOCUS_UP;
645             default: // fall out
646         }
647         throw new IllegalArgumentException("direction must be "
648                 + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
649     }
650 
651     @Nullable
getSpecifiedFocusArea(int direction)652     private FocusArea getSpecifiedFocusArea(int direction) {
653         maybeInitializeSpecifiedFocusAreas();
654         return mSpecifiedNudgeFocusAreaMap.get(direction);
655     }
656 
657     @Nullable
getSpecifiedShortcut(int direction)658     private View getSpecifiedShortcut(int direction) {
659         maybeInitializeSpecifiedShortcuts();
660         return mSpecifiedNudgeShortcutMap.get(direction);
661     }
662 
663     @Override
onDraw(Canvas canvas)664     public void onDraw(Canvas canvas) {
665         super.onDraw(canvas);
666 
667         // Draw highlight on top of this FocusArea (including its background and content) but
668         // behind its children.
669         if (mEnableBackgroundHighlight && mHasFocus && !isInTouchMode()) {
670             mBackgroundHighlight.setBounds(
671                     mPaddingLeft + getScrollX(),
672                     mPaddingTop + getScrollY(),
673                     getScrollX() + getWidth() - mPaddingRight,
674                     getScrollY() + getHeight() - mPaddingBottom);
675             mBackgroundHighlight.draw(canvas);
676         }
677     }
678 
679     @Override
draw(Canvas canvas)680     public void draw(Canvas canvas) {
681         super.draw(canvas);
682 
683         // Draw highlight on top of this FocusArea (including its background and content) and its
684         // children (including background, content, focus highlight, etc).
685         if (mEnableForegroundHighlight && mHasFocus && !isInTouchMode()) {
686             mForegroundHighlight.setBounds(
687                     mPaddingLeft + getScrollX(),
688                     mPaddingTop + getScrollY(),
689                     getScrollX() + getWidth() - mPaddingRight,
690                     getScrollY() + getHeight() - mPaddingBottom);
691             mForegroundHighlight.draw(canvas);
692         }
693     }
694 
695     @Override
getAccessibilityClassName()696     public CharSequence getAccessibilityClassName() {
697         return FocusArea.class.getName();
698     }
699 
700     @Override
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)701     public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
702         super.onInitializeAccessibilityNodeInfo(info);
703         Bundle bundle = info.getExtras();
704         bundle.putInt(FOCUS_AREA_LEFT_BOUND_OFFSET, mLeftOffset);
705         bundle.putInt(FOCUS_AREA_RIGHT_BOUND_OFFSET, mRightOffset);
706         bundle.putInt(FOCUS_AREA_TOP_BOUND_OFFSET, mTopOffset);
707         bundle.putInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET, mBottomOffset);
708     }
709 
710     @Override
onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect)711     protected boolean onRequestFocusInDescendants(int direction, Rect previouslyFocusedRect) {
712         if (isInTouchMode()) {
713             return super.onRequestFocusInDescendants(direction, previouslyFocusedRect);
714         }
715         return maybeAdjustFocus();
716     }
717 
718     @Override
restoreDefaultFocus()719     public boolean restoreDefaultFocus() {
720         return maybeAdjustFocus();
721     }
722 
maybeInitializeSpecifiedFocusAreas()723     private void maybeInitializeSpecifiedFocusAreas() {
724         if (mSpecifiedNudgeFocusAreaMap != null) {
725             return;
726         }
727         View root = getRootView();
728         mSpecifiedNudgeFocusAreaMap = new SparseArray<>();
729         for (int direction : NUDGE_DIRECTIONS) {
730             int id = mSpecifiedNudgeIdMap.get(direction, View.NO_ID);
731             mSpecifiedNudgeFocusAreaMap.put(direction, root.findViewById(id));
732         }
733     }
734 
maybeInitializeSpecifiedShortcuts()735     private void maybeInitializeSpecifiedShortcuts() {
736         if (mSpecifiedNudgeShortcutMap != null) {
737             return;
738         }
739         View root = getRootView();
740         mSpecifiedNudgeShortcutMap = new SparseArray<>();
741         for (int direction : NUDGE_DIRECTIONS) {
742             int id = mSpecifiedNudgeShortcutIdMap.get(direction, View.NO_ID);
743             mSpecifiedNudgeShortcutMap.put(direction, root.findViewById(id));
744         }
745     }
746 
747     /**
748      * Sets the padding (in pixels) of the FocusArea highlight.
749      * <p>
750      * It doesn't affect other values, such as the paddings on its child views.
751      */
setHighlightPadding(int left, int top, int right, int bottom)752     public void setHighlightPadding(int left, int top, int right, int bottom) {
753         if (mPaddingLeft == left && mPaddingTop == top && mPaddingRight == right
754                 && mPaddingBottom == bottom) {
755             return;
756         }
757         mPaddingLeft = left;
758         mPaddingTop = top;
759         mPaddingRight = right;
760         mPaddingBottom = bottom;
761         invalidate();
762     }
763 
764     /**
765      * Sets the offset (in pixels) of the FocusArea's bounds.
766      * <p>
767      * It only affects the perceived bounds for the purposes of finding the nudge target. It doesn't
768      * affect the FocusArea's view bounds or highlight bounds. The offset should only be used when
769      * FocusAreas are overlapping and nudge interaction is ambiguous.
770      */
setBoundsOffset(int left, int top, int right, int bottom)771     public void setBoundsOffset(int left, int top, int right, int bottom) {
772         mLeftOffset = left;
773         mTopOffset = top;
774         mRightOffset = right;
775         mBottomOffset = bottom;
776     }
777 
778     /** Sets whether wrap-around is enabled for this FocusArea. */
setWrapAround(boolean wrapAround)779     public void setWrapAround(boolean wrapAround) {
780         mWrapAround = wrapAround;
781     }
782 
783     /** Sets the default focus view in this FocusArea. */
setDefaultFocus(@onNull View defaultFocus)784     public void setDefaultFocus(@NonNull View defaultFocus) {
785         mDefaultFocusView = defaultFocus;
786     }
787 
788     /**
789      * Sets the nudge shortcut for the given {@code direction}. Removes the nudge shortcut if {@code
790      * view} is {@code null}.
791      */
setNudgeShortcut(int direction, @Nullable View view)792     public void setNudgeShortcut(int direction, @Nullable View view) {
793         if (!NUDGE_DIRECTIONS.contains(direction)) {
794             throw new IllegalArgumentException("direction must be "
795                     + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
796         }
797         maybeInitializeSpecifiedShortcuts();
798         if (view == null) {
799             mSpecifiedNudgeShortcutMap.remove(direction);
800         } else {
801             mSpecifiedNudgeShortcutMap.put(direction, view);
802         }
803     }
804 
805     /**
806      * @inheritDoc
807      * <p>
808      * When {@link #mWrapAround} is true, the search is restricted to descendants of this
809      * {@link FocusArea}.
810      */
811     @Override
focusSearch(View focused, int direction)812     public View focusSearch(View focused, int direction) {
813         if (mWrapAround) {
814             return FocusFinder.getInstance().findNextFocus(/* root= */ this, focused, direction);
815         }
816         return super.focusSearch(focused, direction);
817     }
818 
819     @VisibleForTesting
enableForegroundHighlight()820     void enableForegroundHighlight() {
821         mEnableForegroundHighlight = true;
822     }
823 
824     @VisibleForTesting
setDefaultFocusOverridesHistory(boolean override)825     void setDefaultFocusOverridesHistory(boolean override) {
826         mDefaultFocusOverridesHistory = override;
827     }
828 
829     @VisibleForTesting
setRotaryCache(@onNull RotaryCache rotaryCache)830     void setRotaryCache(@NonNull RotaryCache rotaryCache) {
831         mRotaryCache = rotaryCache;
832     }
833 
834     @VisibleForTesting
setClearFocusAreaHistoryWhenRotating(boolean clear)835     void setClearFocusAreaHistoryWhenRotating(boolean clear) {
836         mClearFocusAreaHistoryWhenRotating = clear;
837     }
838 }
839