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