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.utils; 18 19 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS; 20 21 import static com.android.car.ui.utils.RotaryConstants.ROTARY_CONTAINER; 22 import static com.android.car.ui.utils.RotaryConstants.ROTARY_FOCUS_DELEGATING_CONTAINER; 23 import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE; 24 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE; 25 26 import android.app.Activity; 27 import android.content.Context; 28 import android.content.ContextWrapper; 29 import android.text.TextUtils; 30 import android.util.Log; 31 import android.view.View; 32 import android.view.ViewGroup; 33 import android.view.ViewParent; 34 import android.view.ViewTreeObserver; 35 36 import androidx.annotation.IntDef; 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 import androidx.annotation.VisibleForTesting; 40 41 import com.android.car.ui.FocusParkingView; 42 import com.android.car.ui.IFocusArea; 43 44 import java.lang.annotation.Retention; 45 import java.lang.annotation.RetentionPolicy; 46 import java.util.function.Predicate; 47 48 /** Utility class for helpful methods related to {@link View} objects. */ 49 @SuppressWarnings("AndroidJdkLibsChecker") 50 public final class ViewUtils { 51 52 private static final String TAG = "ViewUtils"; 53 54 /** 55 * How many milliseconds to wait before trying to restore the focus inside the LazyLayoutView 56 * the second time. 57 */ 58 @VisibleForTesting 59 static final int RESTORE_FOCUS_RETRY_DELAY_MS = 3000; 60 61 /** 62 * No view is focused, the focused view is not shown, or the focused view is a FocusParkingView. 63 */ 64 @VisibleForTesting 65 static final int NO_FOCUS = 1; 66 67 /** A scrollable container is focused. */ 68 @VisibleForTesting 69 static final int SCROLLABLE_CONTAINER_FOCUS = 2; 70 71 /** 72 * A regular view is focused. A regular View is a View that is neither a FocusParkingView nor a 73 * scrollable container. 74 */ 75 @VisibleForTesting 76 static final int REGULAR_FOCUS = 3; 77 78 /** The selected view is focused. */ 79 @VisibleForTesting 80 static final int SELECTED_FOCUS = 4; 81 82 /** 83 * An implicit default focus view (i.e., the selected item or the first focusable item in a 84 * scrollable container) is focused. 85 */ 86 @VisibleForTesting 87 static final int IMPLICIT_DEFAULT_FOCUS = 5; 88 89 /** The {@code app:defaultFocus} view is focused. */ 90 @VisibleForTesting 91 static final int DEFAULT_FOCUS = 6; 92 93 /** The {@code android:focusedByDefault} view is focused. */ 94 @VisibleForTesting 95 static final int FOCUSED_BY_DEFAULT = 7; 96 97 /** 98 * Focus level of a view. When adjusting the focus, the view with the highest focus level will 99 * be focused. 100 */ 101 @IntDef(flag = true, value = {NO_FOCUS, SCROLLABLE_CONTAINER_FOCUS, REGULAR_FOCUS, 102 SELECTED_FOCUS, IMPLICIT_DEFAULT_FOCUS, DEFAULT_FOCUS, FOCUSED_BY_DEFAULT}) 103 @Retention(RetentionPolicy.SOURCE) 104 private @interface FocusLevel { 105 } 106 107 /** This is a utility class. */ ViewUtils()108 private ViewUtils() { 109 } 110 111 /** 112 * An interface used to restore focus inside a view when its layout is completed. 113 * <p> 114 * The view that needs to restore focus lazily should implement this interface. 115 */ 116 public interface LazyLayoutView { 117 118 /** 119 * Returns whether the view's layout is completed and ready to restore focus inside it. 120 */ isLayoutCompleted()121 boolean isLayoutCompleted(); 122 123 /** 124 * Adds a listener to be called when the view's layout is completed. 125 */ addOnLayoutCompleteListener(@ullable Runnable runnable)126 void addOnLayoutCompleteListener(@Nullable Runnable runnable); 127 128 /** 129 * Removes a listener to be called when the view's layout is completed. 130 */ removeOnLayoutCompleteListener(@ullable Runnable runnable)131 void removeOnLayoutCompleteListener(@Nullable Runnable runnable); 132 } 133 134 /** Returns whether the {@code view} is in multi-window mode. */ isInMultiWindowMode(@onNull View view)135 public static boolean isInMultiWindowMode(@NonNull View view) { 136 Context context = view.getContext(); 137 // Find the Activity context in case the view was inflated with Hilt dependency injector. 138 Activity activity = findActivity(context); 139 return activity != null && activity.isInMultiWindowMode(); 140 } 141 142 /** Returns the Activity of the given {@code context}. */ 143 @Nullable findActivity(@ullable Context context)144 public static Activity findActivity(@Nullable Context context) { 145 while (context instanceof ContextWrapper 146 && !(context instanceof Activity)) { 147 context = ((ContextWrapper) context).getBaseContext(); 148 } 149 if (context instanceof Activity) { 150 return (Activity) context; 151 } 152 return null; 153 } 154 155 /** Returns whether the {@code descendant} view is a descendant of the {@code view}. */ isDescendant(@ullable View descendant, @Nullable View view)156 public static boolean isDescendant(@Nullable View descendant, @Nullable View view) { 157 if (descendant == null || view == null) { 158 return false; 159 } 160 ViewParent parent = descendant.getParent(); 161 while (parent != null) { 162 if (parent == view) { 163 return true; 164 } 165 parent = parent.getParent(); 166 } 167 return false; 168 } 169 170 /** 171 * Hides the focus by searching the view tree for the {@link FocusParkingView} 172 * and focusing on it. 173 * 174 * @param root the root view to search from 175 * @return true if the FocusParkingView was successfully found and focused 176 * or if it was already focused 177 */ hideFocus(@onNull View root)178 public static boolean hideFocus(@NonNull View root) { 179 FocusParkingView fpv = findFocusParkingView(root); 180 if (fpv == null) { 181 return false; 182 } 183 if (fpv.isFocused()) { 184 return true; 185 } 186 return fpv.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null); 187 } 188 189 /** 190 * Returns the first {@link FocusParkingView} of the view tree, if any. Returns null if not 191 * found. 192 */ 193 @VisibleForTesting findFocusParkingView(@onNull View root)194 public static FocusParkingView findFocusParkingView(@NonNull View root) { 195 return (FocusParkingView) depthFirstSearch(root, 196 /* targetPredicate= */ v -> v instanceof FocusParkingView, 197 /* skipPredicate= */ null); 198 } 199 200 /** Gets the ancestor IFocusArea of the {@code view}, if any. Returns null if not found. */ 201 @Nullable getAncestorFocusArea(@onNull View view)202 public static IFocusArea getAncestorFocusArea(@NonNull View view) { 203 ViewParent parent = view.getParent(); 204 while (parent != null) { 205 if (parent instanceof IFocusArea) { 206 return (IFocusArea) parent; 207 } 208 parent = parent.getParent(); 209 } 210 return null; 211 } 212 213 /** 214 * Gets the ancestor scrollable container of the {@code view}, if any. Returns null if not 215 * found. 216 */ 217 @Nullable getAncestorScrollableContainer(@ullable View view)218 public static ViewGroup getAncestorScrollableContainer(@Nullable View view) { 219 if (view == null) { 220 return null; 221 } 222 ViewParent parent = view.getParent(); 223 // A scrollable container can't contain an IFocusArea, so let's return earlier if we found 224 // an IFocusArea. 225 while (parent != null && parent instanceof ViewGroup && !(parent instanceof IFocusArea)) { 226 ViewGroup viewGroup = (ViewGroup) parent; 227 if (isScrollableContainer(viewGroup)) { 228 return viewGroup; 229 } 230 parent = parent.getParent(); 231 } 232 return null; 233 } 234 235 /** 236 * Focuses on the {@code view} if it can be focused. 237 * 238 * @return whether it was successfully focused or already focused 239 */ requestFocus(@ullable View view)240 public static boolean requestFocus(@Nullable View view) { 241 if (view == null || !canTakeFocus(view)) { 242 return false; 243 } 244 if (view.isFocused()) { 245 return true; 246 } 247 // Exit touch mode and focus the view. The view may not be focusable in touch mode, so we 248 // need to exit touch mode before focusing it. 249 return view.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null); 250 } 251 252 /** 253 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 254 * the view's FocusLevel is higher than the {@code currentFocus}'s FocusLevel, focuses on the 255 * view. If it tried to focus on a LazyLayoutView but failed, requests to adjust the focus 256 * inside the LazyLayoutView later. 257 * 258 * @return whether the view is focused 259 */ adjustFocus(@onNull View root, @Nullable View currentFocus)260 public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) { 261 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 262 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 263 /* defaultFocusOverridesHistory= */ false); 264 } 265 266 /** 267 * Similar to {@link #adjustFocus(View, View)} but without requesting to adjust the focus 268 * inside the LazyLayoutView later. 269 */ adjustFocusImmediately(@onNull View root, @Nullable View currentFocus)270 public static boolean adjustFocusImmediately(@NonNull View root, @Nullable View currentFocus) { 271 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 272 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 273 /* defaultFocusOverridesHistory= */ false, /* delayed= */ false); 274 } 275 276 /** 277 * If the {@code currentFocus}'s FocusLevel is lower than REGULAR_FOCUS, adjusts focus within 278 * {@code root}. See {@link #adjustFocus(View, int)}. Otherwise no-op. 279 * 280 * @return whether the focus has changed 281 */ initFocus(@onNull View root, @Nullable View currentFocus)282 public static boolean initFocus(@NonNull View root, @Nullable View currentFocus) { 283 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 284 if (currentLevel >= REGULAR_FOCUS) { 285 return false; 286 } 287 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 288 /* defaultFocusOverridesHistory= */ false); 289 } 290 291 /** 292 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 293 * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view. 294 * 295 * @return whether the view is focused 296 */ 297 @VisibleForTesting adjustFocus(@onNull View root, @FocusLevel int currentLevel)298 static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) { 299 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 300 /* defaultFocusOverridesHistory= */ false); 301 } 302 303 /** 304 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel} and 305 * focuses on it or the {@code cachedFocusedView}. 306 * 307 * @return whether the view is focused 308 */ adjustFocus(@onNull View root, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)309 public static boolean adjustFocus(@NonNull View root, 310 @Nullable View cachedFocusedView, 311 boolean defaultFocusOverridesHistory) { 312 return adjustFocus(root, NO_FOCUS, cachedFocusedView, defaultFocusOverridesHistory); 313 } 314 adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)315 private static boolean adjustFocus(@NonNull View root, 316 @FocusLevel int currentLevel, 317 @Nullable View cachedFocusedView, 318 boolean defaultFocusOverridesHistory) { 319 return adjustFocus(root, currentLevel, cachedFocusedView, defaultFocusOverridesHistory, 320 /* delayed= */ true); 321 } 322 323 /** 324 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 325 * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view or {@code 326 * cachedFocusedView}. 327 * 328 * @return whether the view is focused 329 */ adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory, boolean delayed)330 private static boolean adjustFocus(@NonNull View root, 331 @FocusLevel int currentLevel, 332 @Nullable View cachedFocusedView, 333 boolean defaultFocusOverridesHistory, 334 boolean delayed) { 335 // If the previously focused view has higher priority than the default focus, try to focus 336 // on the previously focused view. 337 if (!defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) { 338 return true; 339 } 340 341 // Try to focus on the default focus view. 342 if (currentLevel < FOCUSED_BY_DEFAULT && focusOnFocusedByDefaultView(root)) { 343 return true; 344 } 345 if (currentLevel < DEFAULT_FOCUS && focusOnDefaultFocusView(root)) { 346 return true; 347 } 348 if (currentLevel < IMPLICIT_DEFAULT_FOCUS && focusOnImplicitDefaultFocusView(root)) { 349 return true; 350 } 351 if (currentLevel < SELECTED_FOCUS && focusOnSelectedView(root)) { 352 return true; 353 } 354 355 // When delayed is true, if there is a LazyLayoutView but it failed to adjust focus 356 // inside it because it hasn't loaded yet or it's loaded but has no descendants, request to 357 // restore focus inside it later, and return false for now. 358 if (delayed && currentLevel < IMPLICIT_DEFAULT_FOCUS) { 359 LazyLayoutView lazyLayoutView = findLazyLayoutView(root); 360 if (lazyLayoutView != null && !lazyLayoutView.isLayoutCompleted()) { 361 initFocusDelayed(lazyLayoutView); 362 return false; 363 } 364 } 365 366 // If the previously focused view has lower priority than the default focus, try to focus 367 // on the previously focused view. 368 if (defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) { 369 return true; 370 } 371 372 // Try to focus on other views with low focus levels. 373 if (currentLevel < REGULAR_FOCUS && focusOnFirstRegularView(root)) { 374 return true; 375 } 376 if (currentLevel < SCROLLABLE_CONTAINER_FOCUS) { 377 return focusOnScrollableContainer(root); 378 } 379 return false; 380 } 381 382 /** 383 * If the {code lazyLayoutView} has a focusable descendant and no visible view is focused, 384 * focuses on the descendant. Otherwise tries again when the {code lazyLayoutView} completes 385 * layout, shows up on the screen, or after a timeout, whichever comes first. 386 */ initFocus(@onNull LazyLayoutView lazyLayoutView)387 public static void initFocus(@NonNull LazyLayoutView lazyLayoutView) { 388 if (initFocusImmediately(lazyLayoutView)) { 389 return; 390 } 391 initFocusDelayed(lazyLayoutView); 392 } 393 initFocusDelayed(@onNull LazyLayoutView lazyLayoutView)394 private static void initFocusDelayed(@NonNull LazyLayoutView lazyLayoutView) { 395 if (!(lazyLayoutView instanceof View)) { 396 return; 397 } 398 View lazyView = (View) lazyLayoutView; 399 Runnable[] onLayoutCompleteListener = new Runnable[1]; 400 Runnable[] delayedTask = new Runnable[1]; 401 ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener = 402 new ViewTreeObserver.OnGlobalLayoutListener[1]; 403 404 // If the lazyLayoutView has not completed layout yet, try to restore focus inside it once 405 // it's completed. 406 if (!lazyLayoutView.isLayoutCompleted()) { 407 Log.v(TAG, "The lazyLayoutView has not completed layout: " + lazyLayoutView); 408 onLayoutCompleteListener[0] = () -> { 409 Log.v(TAG, "The lazyLayoutView completed layout: " 410 + lazyLayoutView); 411 if (initFocusImmediately(lazyLayoutView)) { 412 Log.v(TAG, "Focus restored after lazyLayoutView completed layout"); 413 // Remove the other tasks only when onLayoutCompleteListener has initialized the 414 // focus successfully, because the other tasks need to kick in when it fails, 415 // such as when it has completed layout but has no descendants to take focus, 416 // or it's not shown (e.g., its ancestor is invisible). In the former case, 417 // the delayedTask needs to run after a timeout, while in the latter case the 418 // onGlobalLayoutListener needs to run when it shows up on the screen. 419 removeCallbacks(lazyLayoutView, onGlobalLayoutListener, 420 onLayoutCompleteListener, delayedTask); 421 } 422 }; 423 lazyLayoutView.addOnLayoutCompleteListener(onLayoutCompleteListener[0]); 424 } 425 426 // If the lazyLayoutView is not shown yet, try to restore focus inside it once it's shown. 427 if (!lazyView.isShown()) { 428 Log.d(TAG, "The lazyLayoutView is not shown: " + lazyLayoutView); 429 onGlobalLayoutListener[0] = () -> { 430 Log.d(TAG, "onGlobalLayoutListener is called"); 431 if (lazyView.isShown()) { 432 Log.d(TAG, "The lazyLayoutView is shown"); 433 if (initFocusImmediately(lazyLayoutView)) { 434 Log.v(TAG, "Focus restored after showing lazyLayoutView"); 435 removeCallbacks(lazyLayoutView, onGlobalLayoutListener, 436 onLayoutCompleteListener, delayedTask); 437 } 438 } 439 }; 440 lazyView.getViewTreeObserver() 441 .addOnGlobalLayoutListener(onGlobalLayoutListener[0]); 442 } 443 444 // Run a delayed task as fallback. 445 delayedTask[0] = () -> { 446 Log.d(TAG, "Starting delayedTask"); 447 removeCallbacks(lazyLayoutView, onGlobalLayoutListener, 448 onLayoutCompleteListener, delayedTask); 449 if (!hasVisibleFocusInRoot(lazyView)) { 450 // Make one last attempt to restore focus inside the lazyLayoutView. For example, 451 // in ViewUtilsTest.testInitFocus_inLazyLayoutView5(), when lazyLayoutView's parent 452 // becomes visible, onGlobalLayoutListener won't be triggered, so it won't try to 453 // restore focus there. 454 if (lazyLayoutView.isLayoutCompleted() && lazyView.isShown()) { 455 Log.d(TAG, "Last attempt to restore focus inside the lazyLayoutView"); 456 if (initFocusImmediately(lazyLayoutView)) { 457 Log.d(TAG, "Restored focus inside the lazyLayoutView"); 458 return; 459 } 460 } 461 // Search the view tree and find the view to focus when it failed to restore focus 462 // inside the lazyLayoutView. 463 adjustFocus(lazyView.getRootView(), NO_FOCUS, /* cachedFocusedView= */ null, 464 /* defaultFocusOverridesHistory= */ false, /* delayed= */ false); 465 } 466 }; 467 lazyView.postDelayed(delayedTask[0], RESTORE_FOCUS_RETRY_DELAY_MS); 468 } 469 removeCallbacks(@onNull LazyLayoutView lazyLayoutView, ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener, Runnable[] onLayoutCompleteListener, Runnable[] delayedTask)470 private static void removeCallbacks(@NonNull LazyLayoutView lazyLayoutView, 471 ViewTreeObserver.OnGlobalLayoutListener[] onGlobalLayoutListener, 472 Runnable[] onLayoutCompleteListener, 473 Runnable[] delayedTask) { 474 lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]); 475 if (!(lazyLayoutView instanceof View)) { 476 return; 477 } 478 View lazyView = (View) lazyLayoutView; 479 lazyView.removeCallbacks(delayedTask[0]); 480 lazyView.getViewTreeObserver() 481 .removeOnGlobalLayoutListener(onGlobalLayoutListener[0]); 482 } 483 initFocusImmediately(@onNull LazyLayoutView lazyLayoutView)484 private static boolean initFocusImmediately(@NonNull LazyLayoutView lazyLayoutView) { 485 if (!(lazyLayoutView instanceof View)) { 486 return false; 487 } 488 View lazyView = (View) lazyLayoutView; 489 // If there is a visible view focused in the view tree, just return true. 490 if (hasVisibleFocusInRoot(lazyView)) { 491 return true; 492 } 493 return ViewUtils.adjustFocusImmediately(lazyView, /* currentFocus= */ null); 494 } 495 hasVisibleFocusInRoot(@onNull View view)496 private static boolean hasVisibleFocusInRoot(@NonNull View view) { 497 View focus = view.getRootView().findFocus(); 498 return focus != null && !(focus instanceof FocusParkingView); 499 } 500 501 @VisibleForTesting 502 @FocusLevel getFocusLevel(@ullable View view)503 static int getFocusLevel(@Nullable View view) { 504 if (view == null || view instanceof FocusParkingView || !view.isShown()) { 505 return NO_FOCUS; 506 } 507 if (view.isFocusedByDefault()) { 508 return FOCUSED_BY_DEFAULT; 509 } 510 if (isDefaultFocus(view)) { 511 return DEFAULT_FOCUS; 512 } 513 if (isImplicitDefaultFocusView(view)) { 514 return IMPLICIT_DEFAULT_FOCUS; 515 } 516 if (view.isSelected()) { 517 return SELECTED_FOCUS; 518 } 519 if (isScrollableContainer(view)) { 520 return SCROLLABLE_CONTAINER_FOCUS; 521 } 522 return REGULAR_FOCUS; 523 } 524 525 /** Returns whether the {@code view} is a {@code app:defaultFocus} view. */ isDefaultFocus(@onNull View view)526 private static boolean isDefaultFocus(@NonNull View view) { 527 IFocusArea parent = getAncestorFocusArea(view); 528 return parent != null && view == parent.getDefaultFocusView(); 529 } 530 531 /** 532 * Returns whether the {@code view} is an implicit default focus view, i.e., the selected 533 * item or the first focusable item in a rotary container. 534 */ 535 @VisibleForTesting isImplicitDefaultFocusView(@onNull View view)536 static boolean isImplicitDefaultFocusView(@NonNull View view) { 537 ViewGroup rotaryContainer = null; 538 ViewParent parent = view.getParent(); 539 while (parent != null && parent instanceof ViewGroup) { 540 ViewGroup viewGroup = (ViewGroup) parent; 541 if (isRotaryContainer(viewGroup)) { 542 rotaryContainer = viewGroup; 543 break; 544 } 545 parent = parent.getParent(); 546 } 547 if (rotaryContainer == null) { 548 return false; 549 } 550 return findFirstSelectedFocusableDescendant(rotaryContainer) == view 551 || findFirstFocusableDescendant(rotaryContainer) == view; 552 } 553 isRotaryContainer(@onNull View view)554 private static boolean isRotaryContainer(@NonNull View view) { 555 CharSequence contentDescription = view.getContentDescription(); 556 return TextUtils.equals(contentDescription, ROTARY_CONTAINER) 557 || TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE) 558 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE); 559 } 560 isScrollableContainer(@onNull View view)561 private static boolean isScrollableContainer(@NonNull View view) { 562 CharSequence contentDescription = view.getContentDescription(); 563 return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE) 564 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE); 565 } 566 isFocusDelegatingContainer(@onNull View view)567 private static boolean isFocusDelegatingContainer(@NonNull View view) { 568 CharSequence contentDescription = view.getContentDescription(); 569 return TextUtils.equals(contentDescription, ROTARY_FOCUS_DELEGATING_CONTAINER); 570 } 571 572 /** 573 * Searches the {@code root}'s descendants for the first {@code app:defaultFocus} view and 574 * focuses on it, if any. 575 * 576 * @param root the root view to search from 577 * @return whether succeeded 578 */ focusOnDefaultFocusView(@onNull View root)579 private static boolean focusOnDefaultFocusView(@NonNull View root) { 580 View defaultFocus = findDefaultFocusView(root); 581 return requestFocus(defaultFocus); 582 } 583 584 /** 585 * Searches the {@code root}'s descendants for the first {@code android:focusedByDefault} view 586 * and focuses on it if any. 587 * 588 * @param root the root view to search from 589 * @return whether succeeded 590 */ focusOnFocusedByDefaultView(@onNull View root)591 private static boolean focusOnFocusedByDefaultView(@NonNull View root) { 592 View focusedByDefault = findFocusedByDefaultView(root); 593 return requestFocus(focusedByDefault); 594 } 595 596 /** 597 * Searches the {@code root}'s descendants for the first implicit default focus view and focuses 598 * on it, if any. 599 * 600 * @param root the root view to search from 601 * @return whether succeeded 602 */ focusOnImplicitDefaultFocusView(@onNull View root)603 private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) { 604 View implicitDefaultFocus = findImplicitDefaultFocusView(root); 605 return requestFocus(implicitDefaultFocus); 606 } 607 608 /** 609 * Searches the {@code root}'s descendants for the first selected view and focuses on it, if 610 * any. 611 * 612 * @param root the root view to search from 613 * @return whether succeeded 614 */ focusOnSelectedView(@onNull View root)615 private static boolean focusOnSelectedView(@NonNull View root) { 616 View selectedView = findFirstSelectedFocusableDescendant(root); 617 return requestFocus(selectedView); 618 } 619 620 /** 621 * Searches the {@code root}'s descendants for the focusable view in depth first order 622 * (excluding the FocusParkingView and scrollable containers), and tries to focus on it. 623 * If focusing on the first such view fails, keeps trying other views in depth first order 624 * until succeeds or there are no more such views. 625 * 626 * @param root the root view to search from 627 * @return whether succeeded 628 */ focusOnFirstRegularView(@onNull View root)629 public static boolean focusOnFirstRegularView(@NonNull View root) { 630 View focusedView = ViewUtils.depthFirstSearch(root, 631 /* targetPredicate= */ 632 v -> v != root && !isScrollableContainer(v) && canTakeFocus(v) && requestFocus(v), 633 /* skipPredicate= */ v -> !v.isShown()); 634 return focusedView != null; 635 } 636 637 /** 638 * Focuses on the first scrollable container in the view tree, if any. 639 *<p> 640 * Unlike other similar methods, don't skip the {@code root} because some callers may pass 641 * a scrollable container as parameter. 642 * 643 * @param root the root of the view tree 644 * @return whether succeeded 645 */ focusOnScrollableContainer(@onNull View root)646 private static boolean focusOnScrollableContainer(@NonNull View root) { 647 View focusedView = ViewUtils.depthFirstSearch(root, 648 /* targetPredicate= */ v -> isScrollableContainer(v) && canTakeFocus(v), 649 /* skipPredicate= */ v -> !v.isShown()); 650 return requestFocus(focusedView); 651 } 652 653 /** 654 * Searches the {@code root}'s descendants in depth first order, and returns the first 655 * {@code app:defaultFocus} view that can take focus. Returns null if not found. 656 */ 657 @Nullable findDefaultFocusView(@onNull View view)658 private static View findDefaultFocusView(@NonNull View view) { 659 if (!view.isShown()) { 660 return null; 661 } 662 if (view instanceof IFocusArea) { 663 IFocusArea focusArea = (IFocusArea) view; 664 View defaultFocus = focusArea.getDefaultFocusView(); 665 if (defaultFocus != null && canTakeFocus(defaultFocus)) { 666 return defaultFocus; 667 } 668 } else if (view instanceof ViewGroup) { 669 ViewGroup parent = (ViewGroup) view; 670 for (int i = 0; i < parent.getChildCount(); i++) { 671 View child = parent.getChildAt(i); 672 View defaultFocus = findDefaultFocusView(child); 673 if (defaultFocus != null) { 674 return defaultFocus; 675 } 676 } 677 } 678 return null; 679 } 680 681 /** 682 * Searches the {@code view}'s descendants in depth first order, and returns the first 683 * {@code android:focusedByDefault} view that can take focus. Returns null if not found. 684 */ 685 @VisibleForTesting 686 @Nullable findFocusedByDefaultView(@onNull View view)687 static View findFocusedByDefaultView(@NonNull View view) { 688 return depthFirstSearch(view, 689 /* targetPredicate= */ v -> v != view && v.isFocusedByDefault() && canTakeFocus(v), 690 /* skipPredicate= */ v -> !v.isShown()); 691 } 692 693 /** 694 * Searches the {@code view}'s descendants in depth first order, and returns the first 695 * implicit default focus view, i.e., the selected item or the first focusable item in the 696 * first rotary container. Returns null if not found. 697 */ 698 @VisibleForTesting 699 @Nullable findImplicitDefaultFocusView(@onNull View view)700 static View findImplicitDefaultFocusView(@NonNull View view) { 701 View rotaryContainer = findRotaryContainer(view); 702 if (rotaryContainer == null) { 703 return null; 704 } 705 706 View selectedItem = findFirstSelectedFocusableDescendant(rotaryContainer); 707 708 return selectedItem != null 709 ? selectedItem 710 : findFirstFocusableDescendant(rotaryContainer); 711 } 712 713 /** 714 * Searches the {@code view}'s descendants in depth first order, and returns the first view 715 * that can take focus, or null if not found. 716 */ 717 @VisibleForTesting 718 @Nullable findFirstFocusableDescendant(@onNull View view)719 static View findFirstFocusableDescendant(@NonNull View view) { 720 return depthFirstSearch(view, 721 /* targetPredicate= */ v -> v != view && canTakeFocus(v), 722 /* skipPredicate= */ v -> !v.isShown()); 723 } 724 725 /** 726 * Searches the {@code view}'s descendants in depth first order, and returns the first view 727 * that is selected and can take focus, or null if not found. 728 */ 729 @VisibleForTesting 730 @Nullable findFirstSelectedFocusableDescendant(@onNull View view)731 static View findFirstSelectedFocusableDescendant(@NonNull View view) { 732 return depthFirstSearch(view, 733 /* targetPredicate= */ v -> v != view && v.isSelected() && canTakeFocus(v), 734 /* skipPredicate= */ v -> !v.isShown()); 735 } 736 737 /** 738 * Searches the {@code view} and its descendants in depth first order, and returns the first 739 * rotary container shown on the screen. If the rotary containers are LazyLayoutViews, returns 740 * the first layout completed one. Returns null if not found. 741 * <p> 742 * Unlike other similar methods, don't skip the {@code root} because some callers may pass 743 * a rotary container as parameter. 744 */ 745 @Nullable findRotaryContainer(@onNull View view)746 private static View findRotaryContainer(@NonNull View view) { 747 return depthFirstSearch(view, 748 /* targetPredicate= */ ViewUtils::isRotaryContainer, 749 /* skipPredicate= */ v -> { 750 if (!v.isShown()) { 751 return true; 752 } 753 if (v instanceof LazyLayoutView) { 754 LazyLayoutView lazyLayoutView = (LazyLayoutView) v; 755 return !lazyLayoutView.isLayoutCompleted(); 756 } 757 return false; 758 }); 759 } 760 761 /** 762 * Searches the {@code view} and its descendants in depth first order, and returns the first 763 * LazyLayoutView shown on the screen. Returns null if not found. 764 * <p> 765 * Unlike other similar methods, don't skip the {@code root} because some callers may pass 766 * a LazyLayoutView as parameter. 767 */ 768 @Nullable 769 private static LazyLayoutView findLazyLayoutView(@NonNull View view) { 770 return (LazyLayoutView) depthFirstSearch(view, 771 /* targetPredicate= */ v -> v instanceof LazyLayoutView, 772 /* skipPredicate= */ v -> !v.isShown()); 773 } 774 775 /** 776 * Searches the {@code view} and its descendants in depth first order, skips the views that 777 * match {@code skipPredicate} and their descendants, and returns the first view that matches 778 * {@code targetPredicate}. Returns null if not found. 779 */ 780 @Nullable 781 private static View depthFirstSearch(@NonNull View view, 782 @NonNull Predicate<View> targetPredicate, 783 @Nullable Predicate<View> skipPredicate) { 784 if (skipPredicate != null && skipPredicate.test(view)) { 785 return null; 786 } 787 if (targetPredicate.test(view)) { 788 return view; 789 } 790 if (view instanceof ViewGroup) { 791 ViewGroup parent = (ViewGroup) view; 792 for (int i = 0; i < parent.getChildCount(); i++) { 793 View child = parent.getChildAt(i); 794 View target = depthFirstSearch(child, targetPredicate, skipPredicate); 795 if (target != null) { 796 return target; 797 } 798 } 799 } 800 return null; 801 } 802 803 /** Returns whether {@code view} can be focused. */ 804 private static boolean canTakeFocus(@NonNull View view) { 805 boolean focusable = view.isFocusable() || isFocusDelegatingContainer(view); 806 return focusable && view.isEnabled() && view.isShown() 807 && view.getWidth() > 0 && view.getHeight() > 0 && view.isAttachedToWindow() 808 && !(view instanceof FocusParkingView) 809 // If it's a scrollable container, it can be focused only when it has no focusable 810 // descendants. We focus on it so that the rotary controller can scroll it. 811 && (!isScrollableContainer(view) || findFirstFocusableDescendant(view) == null); 812 } 813 814 /** 815 * Enables rotary scrolling for {@code view}, either vertically (if {@code isVertical} is true) 816 * or horizontally (if {@code isVertical} is false). With rotary scrolling enabled, rotating the 817 * rotary controller will scroll rather than moving the focus when moving the focus would cause 818 * a lot of scrolling. Rotary scrolling should be enabled for scrolling views which contain 819 * content which the user may want to see but can't interact with, either alone or along with 820 * interactive (focusable) content. 821 */ 822 public static void setRotaryScrollEnabled(@NonNull View view, boolean isVertical) { 823 view.setContentDescription( 824 isVertical ? ROTARY_VERTICALLY_SCROLLABLE : ROTARY_HORIZONTALLY_SCROLLABLE); 825 } 826 } 827