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.text.TextUtils; 27 import android.view.View; 28 import android.view.ViewGroup; 29 import android.view.ViewParent; 30 31 import androidx.annotation.IntDef; 32 import androidx.annotation.NonNull; 33 import androidx.annotation.Nullable; 34 import androidx.annotation.VisibleForTesting; 35 36 import com.android.car.ui.FocusArea; 37 import com.android.car.ui.FocusParkingView; 38 39 import java.lang.annotation.Retention; 40 import java.lang.annotation.RetentionPolicy; 41 42 /** 43 * Utility class used by {@link com.android.car.ui.FocusArea} and {@link 44 * com.android.car.ui.FocusParkingView}. 45 * 46 * @hide 47 */ 48 public final class ViewUtils { 49 50 /** 51 * How many milliseconds to wait before trying to restore the focus inside the LazyLayoutView 52 * the second time. 53 */ 54 private static final int RESTORE_FOCUS_RETRY_DELAY_MS = 3000; 55 56 /** 57 * No view is focused, the focused view is not shown, or the focused view is a FocusParkingView. 58 */ 59 @VisibleForTesting 60 static final int NO_FOCUS = 1; 61 62 /** A scrollable container is focused. */ 63 @VisibleForTesting 64 static final int SCROLLABLE_CONTAINER_FOCUS = 2; 65 66 /** 67 * A regular view is focused. A regular View is a View that is neither a FocusParkingView nor a 68 * scrollable container. 69 */ 70 @VisibleForTesting 71 static final int REGULAR_FOCUS = 3; 72 73 /** 74 * An implicit default focus view (i.e., the selected item or the first focusable item in a 75 * scrollable container) is focused. 76 */ 77 @VisibleForTesting 78 static final int IMPLICIT_DEFAULT_FOCUS = 4; 79 80 /** The {@code app:defaultFocus} view is focused. */ 81 @VisibleForTesting 82 static final int DEFAULT_FOCUS = 5; 83 84 /** The {@code android:focusedByDefault} view is focused. */ 85 @VisibleForTesting 86 static final int FOCUSED_BY_DEFAULT = 6; 87 88 /** 89 * Focus level of a view. When adjusting the focus, the view with the highest focus level will 90 * be focused. 91 */ 92 @IntDef(flag = true, value = {NO_FOCUS, SCROLLABLE_CONTAINER_FOCUS, REGULAR_FOCUS, 93 IMPLICIT_DEFAULT_FOCUS, DEFAULT_FOCUS, FOCUSED_BY_DEFAULT}) 94 @Retention(RetentionPolicy.SOURCE) 95 private @interface FocusLevel { 96 } 97 98 /** This is a utility class. */ ViewUtils()99 private ViewUtils() { 100 } 101 102 /** 103 * This is a functional interface and can therefore be used as the assignment target for a 104 * lambda expression or method reference. 105 * 106 * @param <T> the type of the input to the predicate 107 */ 108 private interface Predicate<T> { 109 /** Evaluates this predicate on the given argument. */ test(@onNull T t)110 boolean test(@NonNull T t); 111 } 112 113 /** 114 * An interface used to restore focus inside a view when its layout is completed. 115 * <p> 116 * The view that needs to restore focus lazily should implement this interface. 117 */ 118 public interface LazyLayoutView { 119 120 /** 121 * Returnes whether the view's layout is completed and ready to restore focus inside it. 122 */ isLayoutCompleted()123 boolean isLayoutCompleted(); 124 125 /** 126 * Adds a listener to be called when the view's layout is completed. 127 */ addOnLayoutCompleteListener(@ullable Runnable runnable)128 void addOnLayoutCompleteListener(@Nullable Runnable runnable); 129 130 /** 131 * Removes a listener to be called when the view's layout is completed. 132 */ removeOnLayoutCompleteListener(@ullable Runnable runnable)133 void removeOnLayoutCompleteListener(@Nullable Runnable runnable); 134 } 135 136 /** 137 * Hides the focus by searching the view tree for the {@link FocusParkingView} 138 * and focusing on it. 139 * 140 * @param root the root view to search from 141 * @return true if the FocusParkingView was successfully found and focused 142 * or if it was already focused 143 */ hideFocus(@onNull View root)144 public static boolean hideFocus(@NonNull View root) { 145 FocusParkingView fpv = (FocusParkingView) depthFirstSearch(root, 146 /* targetPredicate= */ v -> v instanceof FocusParkingView, 147 /* skipPredicate= */ null); 148 if (fpv == null) { 149 return false; 150 } 151 if (fpv.isFocused()) { 152 return true; 153 } 154 return fpv.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null); 155 } 156 157 /** Gets the ancestor FocusArea of the {@code view}, if any. Returns null if not found. */ 158 @Nullable getAncestorFocusArea(@onNull View view)159 public static FocusArea getAncestorFocusArea(@NonNull View view) { 160 ViewParent parent = view.getParent(); 161 while (parent != null) { 162 if (parent instanceof FocusArea) { 163 return (FocusArea) parent; 164 } 165 parent = parent.getParent(); 166 } 167 return null; 168 } 169 170 /** 171 * Gets the ancestor scrollable container of the {@code view}, if any. Returns null if not 172 * found. 173 */ 174 @Nullable getAncestorScrollableContainer(@ullable View view)175 public static ViewGroup getAncestorScrollableContainer(@Nullable View view) { 176 if (view == null) { 177 return null; 178 } 179 ViewParent parent = view.getParent(); 180 // A scrollable container can't contain a FocusArea, so let's return earlier if we found 181 // a FocusArea. 182 while (parent != null && parent instanceof ViewGroup && !(parent instanceof FocusArea)) { 183 ViewGroup viewGroup = (ViewGroup) parent; 184 if (isScrollableContainer(viewGroup)) { 185 return viewGroup; 186 } 187 parent = parent.getParent(); 188 } 189 return null; 190 } 191 192 /** 193 * Focuses on the {@code view} if it can be focused. 194 * 195 * @return whether it was successfully focused or already focused 196 */ requestFocus(@ullable View view)197 public static boolean requestFocus(@Nullable View view) { 198 if (view == null || !canTakeFocus(view)) { 199 return false; 200 } 201 if (view.isFocused()) { 202 return true; 203 } 204 // Exit touch mode and focus the view. The view may not be focusable in touch mode, so we 205 // need to exit touch mode before focusing it. 206 return view.performAccessibilityAction(ACTION_FOCUS, /* arguments= */ null); 207 } 208 209 /** 210 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 211 * the view's FocusLevel is higher than the {@code currentFocus}'s FocusLevel, focuses on the 212 * view. If it tried to focus on a LazyLayoutView but failed, requests to adjust the focus 213 * inside the LazyLayoutView later. 214 * 215 * @return whether the view is focused 216 */ adjustFocus(@onNull View root, @Nullable View currentFocus)217 public static boolean adjustFocus(@NonNull View root, @Nullable View currentFocus) { 218 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 219 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 220 /* defaultFocusOverridesHistory= */ false); 221 } 222 223 /** 224 * Similar to {@link #adjustFocus(View, View)} but without requesting to adjust the focus 225 * inside the LazyLayoutView later. 226 */ adjustFocusImmediately(@onNull View root, @Nullable View currentFocus)227 public static boolean adjustFocusImmediately(@NonNull View root, @Nullable View currentFocus) { 228 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 229 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 230 /* defaultFocusOverridesHistory= */ false, /* delayed= */ false); 231 } 232 233 /** 234 * If the {@code currentFocus}'s FocusLevel is lower than REGULAR_FOCUS, adjusts focus within 235 * {@code root}. See {@link #adjustFocus(View, int)}. Otherwise no-op. 236 * 237 * @return whether the focus has changed 238 */ initFocus(@onNull View root, @Nullable View currentFocus)239 public static boolean initFocus(@NonNull View root, @Nullable View currentFocus) { 240 @FocusLevel int currentLevel = getFocusLevel(currentFocus); 241 if (currentLevel >= REGULAR_FOCUS) { 242 return false; 243 } 244 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 245 /* defaultFocusOverridesHistory= */ false); 246 } 247 248 /** 249 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 250 * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view. 251 * 252 * @return whether the view is focused 253 */ 254 @VisibleForTesting adjustFocus(@onNull View root, @FocusLevel int currentLevel)255 static boolean adjustFocus(@NonNull View root, @FocusLevel int currentLevel) { 256 return adjustFocus(root, currentLevel, /* cachedFocusedView= */ null, 257 /* defaultFocusOverridesHistory= */ false); 258 } 259 260 /** 261 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel} and 262 * focuses on it or the {@code cachedFocusedView}. 263 * 264 * @return whether the view is focused 265 */ adjustFocus(@onNull View root, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)266 public static boolean adjustFocus(@NonNull View root, 267 @Nullable View cachedFocusedView, 268 boolean defaultFocusOverridesHistory) { 269 return adjustFocus(root, NO_FOCUS, cachedFocusedView, defaultFocusOverridesHistory); 270 } 271 adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory)272 private static boolean adjustFocus(@NonNull View root, 273 @FocusLevel int currentLevel, 274 @Nullable View cachedFocusedView, 275 boolean defaultFocusOverridesHistory) { 276 return adjustFocus(root, currentLevel, cachedFocusedView, defaultFocusOverridesHistory, 277 /* delayed= */true); 278 } 279 280 /** 281 * Searches the {@code root}'s descendants for a view with the highest {@link FocusLevel}. If 282 * the view's FocusLevel is higher than {@code currentLevel}, focuses on the view or {@code 283 * cachedFocusedView}. 284 * 285 * @return whether the view is focused 286 */ adjustFocus(@onNull View root, @FocusLevel int currentLevel, @Nullable View cachedFocusedView, boolean defaultFocusOverridesHistory, boolean delayed)287 private static boolean adjustFocus(@NonNull View root, 288 @FocusLevel int currentLevel, 289 @Nullable View cachedFocusedView, 290 boolean defaultFocusOverridesHistory, 291 boolean delayed) { 292 // If the previously focused view has higher priority than the default focus, try to focus 293 // on the previously focused view. 294 if (!defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) { 295 return true; 296 } 297 298 // Try to focus on the default focus view. 299 if (currentLevel < FOCUSED_BY_DEFAULT && focusOnFocusedByDefaultView(root)) { 300 return true; 301 } 302 if (currentLevel < DEFAULT_FOCUS && focusOnDefaultFocusView(root)) { 303 return true; 304 } 305 if (currentLevel < IMPLICIT_DEFAULT_FOCUS && focusOnImplicitDefaultFocusView(root)) { 306 return true; 307 } 308 309 // When delayed is true, if there is a LazyLayoutView but it failed to adjust focus 310 // inside it because it is not loaded yet or it's loaded but has no descendnats, request to 311 // restore focus inside it later, and return false for now. 312 if (delayed && currentLevel < IMPLICIT_DEFAULT_FOCUS) { 313 LazyLayoutView lazyLayoutView = findLazyLayoutView(root); 314 if (lazyLayoutView != null && !lazyLayoutView.isLayoutCompleted()) { 315 initFocusDelayed(lazyLayoutView); 316 return false; 317 } 318 } 319 320 // If the previously focused view has lower priority than the default focus, try to focus 321 // on the previously focused view. 322 if (defaultFocusOverridesHistory && requestFocus(cachedFocusedView)) { 323 return true; 324 } 325 326 // Try to focus on other views with low focus levels. 327 if (currentLevel < REGULAR_FOCUS && focusOnFirstRegularView(root)) { 328 return true; 329 } 330 if (currentLevel < SCROLLABLE_CONTAINER_FOCUS) { 331 return focusOnScrollableContainer(root); 332 } 333 return false; 334 } 335 336 /** 337 * If the {code lazyLayoutView} has a focusable descendant and no visible view is focused, 338 * focuses on the descendant. Otherwise tries again when the {code lazyLayoutView} completes 339 * layout or after a timeout, whichever comes first. 340 */ initFocus(@onNull LazyLayoutView lazyLayoutView)341 public static void initFocus(@NonNull LazyLayoutView lazyLayoutView) { 342 if (initFocusImmediately(lazyLayoutView)) { 343 return; 344 } 345 initFocusDelayed(lazyLayoutView); 346 } 347 initFocusDelayed(@onNull LazyLayoutView lazyLayoutView)348 private static void initFocusDelayed(@NonNull LazyLayoutView lazyLayoutView) { 349 if (!(lazyLayoutView instanceof View)) { 350 return; 351 } 352 View lazyView = (View) lazyLayoutView; 353 Runnable[] onLayoutCompleteListener = new Runnable[1]; 354 Runnable delayedTask = () -> { 355 lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]); 356 initFocusImmediately(lazyLayoutView); 357 }; 358 onLayoutCompleteListener[0] = () -> { 359 if (initFocusImmediately(lazyLayoutView)) { 360 // Remove the delayedTask only when onLayoutCompleteListener has initialized the 361 // focus succefully, because the delayedTask needs to kick in when it fails, such 362 // as the lazyLayoutView is still loading after a timeout, or it's loaded but has 363 // no descendants to take focus. 364 lazyView.removeCallbacks(delayedTask); 365 lazyLayoutView.removeOnLayoutCompleteListener(onLayoutCompleteListener[0]); 366 } 367 }; 368 lazyLayoutView.addOnLayoutCompleteListener(onLayoutCompleteListener[0]); 369 lazyView.postDelayed(delayedTask, RESTORE_FOCUS_RETRY_DELAY_MS); 370 } 371 initFocusImmediately(@onNull LazyLayoutView lazyLayoutView)372 private static boolean initFocusImmediately(@NonNull LazyLayoutView lazyLayoutView) { 373 if (!(lazyLayoutView instanceof View)) { 374 return false; 375 } 376 View lazyView = (View) lazyLayoutView; 377 View focusedView = lazyView.getRootView().findFocus(); 378 // If the currently focused view won't draw, it's not a valid focus. 379 View visibleFocusedView = 380 focusedView != null && !focusedView.willNotDraw() ? focusedView : null; 381 // If there is a visible view focused, just return true. 382 if (visibleFocusedView != null && !(visibleFocusedView instanceof FocusParkingView)) { 383 return true; 384 } 385 386 return ViewUtils.adjustFocusImmediately(lazyView, visibleFocusedView); 387 } 388 389 @VisibleForTesting 390 @FocusLevel getFocusLevel(@ullable View view)391 static int getFocusLevel(@Nullable View view) { 392 if (view == null || view instanceof FocusParkingView || !view.isShown()) { 393 return NO_FOCUS; 394 } 395 if (view.isFocusedByDefault()) { 396 return FOCUSED_BY_DEFAULT; 397 } 398 if (isDefaultFocus(view)) { 399 return DEFAULT_FOCUS; 400 } 401 if (isImplicitDefaultFocusView(view)) { 402 return IMPLICIT_DEFAULT_FOCUS; 403 } 404 if (isScrollableContainer(view)) { 405 return SCROLLABLE_CONTAINER_FOCUS; 406 } 407 return REGULAR_FOCUS; 408 } 409 410 /** Returns whether the {@code view} is a {@code app:defaultFocus} view. */ isDefaultFocus(@onNull View view)411 private static boolean isDefaultFocus(@NonNull View view) { 412 FocusArea parent = getAncestorFocusArea(view); 413 return parent != null && view == parent.getDefaultFocusView(); 414 } 415 416 /** 417 * Returns whether the {@code view} is an implicit default focus view, i.e., the selected 418 * item or the first focusable item in a rotary container. 419 */ 420 @VisibleForTesting isImplicitDefaultFocusView(@onNull View view)421 static boolean isImplicitDefaultFocusView(@NonNull View view) { 422 ViewGroup rotaryContainer = null; 423 ViewParent parent = view.getParent(); 424 while (parent != null && parent instanceof ViewGroup) { 425 ViewGroup viewGroup = (ViewGroup) parent; 426 if (isRotaryContainer(viewGroup)) { 427 rotaryContainer = viewGroup; 428 break; 429 } 430 parent = parent.getParent(); 431 } 432 if (rotaryContainer == null) { 433 return false; 434 } 435 return findFirstSelectedFocusableDescendant(rotaryContainer) == view 436 || findFirstFocusableDescendant(rotaryContainer) == view; 437 } 438 isRotaryContainer(@onNull View view)439 private static boolean isRotaryContainer(@NonNull View view) { 440 CharSequence contentDescription = view.getContentDescription(); 441 return TextUtils.equals(contentDescription, ROTARY_CONTAINER) 442 || TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE) 443 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE); 444 } 445 isScrollableContainer(@onNull View view)446 private static boolean isScrollableContainer(@NonNull View view) { 447 CharSequence contentDescription = view.getContentDescription(); 448 return TextUtils.equals(contentDescription, ROTARY_VERTICALLY_SCROLLABLE) 449 || TextUtils.equals(contentDescription, ROTARY_HORIZONTALLY_SCROLLABLE); 450 } 451 isFocusDelegatingContainer(@onNull View view)452 private static boolean isFocusDelegatingContainer(@NonNull View view) { 453 CharSequence contentDescription = view.getContentDescription(); 454 return TextUtils.equals(contentDescription, ROTARY_FOCUS_DELEGATING_CONTAINER); 455 } 456 457 /** 458 * Focuses on the first {@code app:defaultFocus} view in the view tree, if any. 459 * 460 * @param root the root of the view tree 461 * @return whether succeeded 462 */ focusOnDefaultFocusView(@onNull View root)463 private static boolean focusOnDefaultFocusView(@NonNull View root) { 464 View defaultFocus = findDefaultFocusView(root); 465 return requestFocus(defaultFocus); 466 } 467 468 /** 469 * Focuses on the first {@code android:focusedByDefault} view in the view tree, if any. 470 * 471 * @param root the root of the view tree 472 * @return whether succeeded 473 */ focusOnFocusedByDefaultView(@onNull View root)474 private static boolean focusOnFocusedByDefaultView(@NonNull View root) { 475 View focusedByDefault = findFocusedByDefaultView(root); 476 return requestFocus(focusedByDefault); 477 } 478 479 /** 480 * Focuses on the first implicit default focus view in the view tree, if any. 481 * 482 * @param root the root of the view tree 483 * @return whether succeeded 484 */ focusOnImplicitDefaultFocusView(@onNull View root)485 private static boolean focusOnImplicitDefaultFocusView(@NonNull View root) { 486 View implicitDefaultFocus = findImplicitDefaultFocusView(root); 487 return requestFocus(implicitDefaultFocus); 488 } 489 490 /** 491 * Tries to focus on the first focusable view in the view tree in depth first order, excluding 492 * the FocusParkingView and scrollable containers. If focusing on the first such view fails, 493 * keeps trying other views in depth first order until succeeds or there are no more such views. 494 * 495 * @param root the root of the view tree 496 * @return whether succeeded 497 */ focusOnFirstRegularView(@onNull View root)498 private static boolean focusOnFirstRegularView(@NonNull View root) { 499 View focusedView = ViewUtils.depthFirstSearch(root, 500 /* targetPredicate= */ 501 v -> !isScrollableContainer(v) && canTakeFocus(v) && requestFocus(v), 502 /* skipPredicate= */ v -> !v.isShown()); 503 return focusedView != null; 504 } 505 506 /** 507 * Focuses on the first scrollable container in the view tree, if any. 508 * 509 * @param root the root of the view tree 510 * @return whether succeeded 511 */ focusOnScrollableContainer(@onNull View root)512 private static boolean focusOnScrollableContainer(@NonNull View root) { 513 View focusedView = ViewUtils.depthFirstSearch(root, 514 /* targetPredicate= */ v -> isScrollableContainer(v) && canTakeFocus(v), 515 /* skipPredicate= */ v -> !v.isShown()); 516 return requestFocus(focusedView); 517 } 518 519 /** 520 * Searches the {@code root}'s descendants in depth first order, and returns the first 521 * {@code app:defaultFocus} view that can take focus. Returns null if not found. 522 */ 523 @Nullable findDefaultFocusView(@onNull View view)524 private static View findDefaultFocusView(@NonNull View view) { 525 if (!view.isShown()) { 526 return null; 527 } 528 if (view instanceof FocusArea) { 529 FocusArea focusArea = (FocusArea) view; 530 View defaultFocus = focusArea.getDefaultFocusView(); 531 if (defaultFocus != null && canTakeFocus(defaultFocus)) { 532 return defaultFocus; 533 } 534 } else if (view instanceof ViewGroup) { 535 ViewGroup parent = (ViewGroup) view; 536 for (int i = 0; i < parent.getChildCount(); i++) { 537 View child = parent.getChildAt(i); 538 View defaultFocus = findDefaultFocusView(child); 539 if (defaultFocus != null) { 540 return defaultFocus; 541 } 542 } 543 } 544 return null; 545 } 546 547 /** 548 * Searches the {@code view} and its descendants in depth first order, and returns the first 549 * {@code android:focusedByDefault} view that can take focus. Returns null if not found. 550 */ 551 @VisibleForTesting 552 @Nullable findFocusedByDefaultView(@onNull View view)553 static View findFocusedByDefaultView(@NonNull View view) { 554 return depthFirstSearch(view, 555 /* targetPredicate= */ v -> v.isFocusedByDefault() && canTakeFocus(v), 556 /* skipPredicate= */ v -> !v.isShown()); 557 } 558 559 /** 560 * Searches the {@code view} and its descendants in depth first order, and returns the first 561 * implicit default focus view, i.e., the selected item or the first focusable item in the 562 * first rotary container. Returns null if not found. 563 */ 564 @VisibleForTesting 565 @Nullable findImplicitDefaultFocusView(@onNull View view)566 static View findImplicitDefaultFocusView(@NonNull View view) { 567 View rotaryContainer = findRotaryContainer(view); 568 if (rotaryContainer == null) { 569 return null; 570 } 571 572 View selectedItem = findFirstSelectedFocusableDescendant(rotaryContainer); 573 574 return selectedItem != null 575 ? selectedItem 576 : findFirstFocusableDescendant(rotaryContainer); 577 } 578 579 /** 580 * Searches the {@code view}'s descendants in depth first order, and returns the first view 581 * that can take focus, or null if not found. 582 */ 583 @VisibleForTesting 584 @Nullable findFirstFocusableDescendant(@onNull View view)585 static View findFirstFocusableDescendant(@NonNull View view) { 586 return depthFirstSearch(view, 587 /* targetPredicate= */ v -> v != view && canTakeFocus(v), 588 /* skipPredicate= */ v -> !v.isShown()); 589 } 590 591 /** 592 * Searches the {@code view}'s descendants in depth first order, and returns the first view 593 * that is selected and can take focus, or null if not found. 594 */ 595 @VisibleForTesting 596 @Nullable findFirstSelectedFocusableDescendant(@onNull View view)597 static View findFirstSelectedFocusableDescendant(@NonNull View view) { 598 return depthFirstSearch(view, 599 /* targetPredicate= */ v -> v != view && v.isSelected() && canTakeFocus(v), 600 /* skipPredicate= */ v -> !v.isShown()); 601 } 602 603 /** 604 * Searches the {@code view} and its descendants in depth first order, and returns the first 605 * rotary container shown on the screen. Returns null if not found. 606 */ 607 @Nullable findRotaryContainer(@onNull View view)608 private static View findRotaryContainer(@NonNull View view) { 609 return depthFirstSearch(view, 610 /* targetPredicate= */ v -> isRotaryContainer(v), 611 /* skipPredicate= */ v -> !v.isShown()); 612 } 613 614 /** 615 * Searches the {@code view} and its descendants in depth first order, and returns the first 616 * LazyLayoutView shown on the screen. Returns null if not found. 617 */ 618 @Nullable findLazyLayoutView(@onNull View view)619 private static LazyLayoutView findLazyLayoutView(@NonNull View view) { 620 return (LazyLayoutView) depthFirstSearch(view, 621 /* targetPredicate= */ v -> v instanceof LazyLayoutView, 622 /* skipPredicate= */ v -> !v.isShown()); 623 } 624 625 /** 626 * Searches the {@code view} and its descendants in depth first order, skips the views that 627 * match {@code skipPredicate} and their descendants, and returns the first view that matches 628 * {@code targetPredicate}. Returns null if not found. 629 */ 630 @Nullable depthFirstSearch(@onNull View view, @NonNull Predicate<View> targetPredicate, @Nullable Predicate<View> skipPredicate)631 private static View depthFirstSearch(@NonNull View view, 632 @NonNull Predicate<View> targetPredicate, 633 @Nullable Predicate<View> skipPredicate) { 634 if (skipPredicate != null && skipPredicate.test(view)) { 635 return null; 636 } 637 if (targetPredicate.test(view)) { 638 return view; 639 } 640 if (view instanceof ViewGroup) { 641 ViewGroup parent = (ViewGroup) view; 642 for (int i = 0; i < parent.getChildCount(); i++) { 643 View child = parent.getChildAt(i); 644 View target = depthFirstSearch(child, targetPredicate, skipPredicate); 645 if (target != null) { 646 return target; 647 } 648 } 649 } 650 return null; 651 } 652 653 /** Returns whether {@code view} can be focused. */ canTakeFocus(@onNull View view)654 private static boolean canTakeFocus(@NonNull View view) { 655 boolean focusable = view.isFocusable() || isFocusDelegatingContainer(view); 656 return focusable && view.isEnabled() && view.isShown() 657 && view.getWidth() > 0 && view.getHeight() > 0 && view.isAttachedToWindow() 658 && !(view instanceof FocusParkingView) 659 // If it's a scrollable container, it can be focused only when it has no focusable 660 // descendants. We focus on it so that the rotary controller can scroll it. 661 && (!isScrollableContainer(view) || findFirstFocusableDescendant(view) == null); 662 } 663 664 /** 665 * Enables rotary scrolling for {@code view}, either vertically (if {@code isVertical} is true) 666 * or horizontally (if {@code isVertical} is false). With rotary scrolling enabled, rotating the 667 * rotary controller will scroll rather than moving the focus when moving the focus would cause 668 * a lot of scrolling. Rotary scrolling should be enabled for scrolling views which contain 669 * content which the user may want to see but can't interact with, either alone or along with 670 * interactive (focusable) content. 671 */ setRotaryScrollEnabled(@onNull View view, boolean isVertical)672 public static void setRotaryScrollEnabled(@NonNull View view, boolean isVertical) { 673 view.setContentDescription( 674 isVertical ? ROTARY_VERTICALLY_SCROLLABLE : ROTARY_HORIZONTALLY_SCROLLABLE); 675 } 676 } 677