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