1 /* 2 * Copyright (C) 2023 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 package com.android.launcher3.taskbar; 17 18 import static androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID; 19 20 import android.animation.Animator; 21 import android.animation.AnimatorListenerAdapter; 22 import android.animation.AnimatorSet; 23 import android.animation.ObjectAnimator; 24 import android.content.Context; 25 import android.content.res.Resources; 26 import android.graphics.Outline; 27 import android.graphics.Rect; 28 import android.icu.text.MessageFormat; 29 import android.util.AttributeSet; 30 import android.view.KeyEvent; 31 import android.view.LayoutInflater; 32 import android.view.View; 33 import android.view.ViewGroup; 34 import android.view.ViewOutlineProvider; 35 import android.view.ViewTreeObserver; 36 import android.view.animation.Interpolator; 37 import android.widget.HorizontalScrollView; 38 import android.widget.ImageButton; 39 import android.widget.TextView; 40 import android.window.OnBackInvokedDispatcher; 41 import android.window.WindowOnBackInvokedDispatcher; 42 43 import androidx.annotation.LayoutRes; 44 import androidx.annotation.NonNull; 45 import androidx.annotation.Nullable; 46 import androidx.constraintlayout.widget.ConstraintLayout; 47 48 import com.android.app.animation.Interpolators; 49 import com.android.internal.jank.Cuj; 50 import com.android.launcher3.R; 51 import com.android.launcher3.Utilities; 52 import com.android.launcher3.anim.AnimatedFloat; 53 import com.android.launcher3.testing.TestLogging; 54 import com.android.launcher3.testing.shared.TestProtocol; 55 import com.android.quickstep.util.GroupTask; 56 import com.android.quickstep.util.SingleTask; 57 import com.android.quickstep.util.SplitTask; 58 import com.android.systemui.shared.recents.model.Task; 59 import com.android.systemui.shared.system.InteractionJankMonitorWrapper; 60 import com.android.wm.shell.shared.TypefaceUtils; 61 import com.android.wm.shell.shared.TypefaceUtils.FontFamily; 62 63 import java.util.HashMap; 64 import java.util.List; 65 import java.util.Locale; 66 67 /** 68 * View that allows quick switching between recent tasks. 69 * 70 * Can be access via: 71 * - keyboard alt-tab 72 * - alt-shift-tab 73 * - taskbar overflow button 74 */ 75 public class KeyboardQuickSwitchView extends ConstraintLayout { 76 77 private static final long OUTLINE_ANIMATION_DURATION_MS = 333; 78 private static final float OUTLINE_START_HEIGHT_FACTOR = 0.45f; 79 private static final float OUTLINE_START_RADIUS_FACTOR = 0.25f; 80 private static final Interpolator OPEN_OUTLINE_INTERPOLATOR = 81 Interpolators.EMPHASIZED_DECELERATE; 82 private static final Interpolator CLOSE_OUTLINE_INTERPOLATOR = 83 Interpolators.EMPHASIZED_ACCELERATE; 84 85 private static final long ALPHA_ANIMATION_DURATION_MS = 83; 86 private static final long ALPHA_ANIMATION_START_DELAY_MS = 67; 87 88 private static final long CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS = 500; 89 private static final long CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS = 333; 90 private static final float CONTENT_START_TRANSLATION_X_DP = 32; 91 private static final float CONTENT_START_TRANSLATION_Y_DP = 40; 92 private static final Interpolator OPEN_TRANSLATION_X_INTERPOLATOR = Interpolators.EMPHASIZED; 93 private static final Interpolator OPEN_TRANSLATION_Y_INTERPOLATOR = 94 Interpolators.EMPHASIZED_DECELERATE; 95 private static final Interpolator CLOSE_TRANSLATION_Y_INTERPOLATOR = 96 Interpolators.EMPHASIZED_ACCELERATE; 97 98 private static final long CONTENT_ALPHA_ANIMATION_DURATION_MS = 83; 99 private static final long CONTENT_ALPHA_ANIMATION_START_DELAY_MS = 83; 100 101 private final AnimatedFloat mOutlineAnimationProgress = new AnimatedFloat( 102 this::invalidateOutline); 103 104 private boolean mDisplayingRecentTasks; 105 private View mNoRecentItemsPane; 106 private HorizontalScrollView mScrollView; 107 private ConstraintLayout mContent; 108 109 private boolean mSupportsScrollArrows = false; 110 private ImageButton mStartScrollArrow; 111 private ImageButton mEndScrollArrow; 112 113 private int mTaskViewBorderWidth; 114 private int mTaskViewRadius; 115 private int mSpacing; 116 private int mSmallSpacing; 117 private int mOutlineRadius; 118 private boolean mIsRtl; 119 120 private int mOverviewTaskIndex = -1; 121 private int mDesktopTaskIndex = -1; 122 123 @Nullable 124 private AnimatorSet mOpenAnimation; 125 126 private boolean mIsBackCallbackRegistered = false; 127 128 @Nullable 129 private KeyboardQuickSwitchViewController.ViewCallbacks mViewCallbacks; 130 KeyboardQuickSwitchView(@onNull Context context)131 public KeyboardQuickSwitchView(@NonNull Context context) { 132 this(context, null); 133 } 134 KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs)135 public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs) { 136 this(context, attrs, 0); 137 } 138 KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)139 public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs, 140 int defStyleAttr) { 141 this(context, attrs, defStyleAttr, 0); 142 } 143 KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)144 public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs, 145 int defStyleAttr, 146 int defStyleRes) { 147 super(context, attrs, defStyleAttr, defStyleRes); 148 } 149 150 @Override onDetachedFromWindow()151 protected void onDetachedFromWindow() { 152 super.onDetachedFromWindow(); 153 154 if (mViewCallbacks != null) { 155 mViewCallbacks.onViewDetchedFromWindow(); 156 } 157 } 158 159 @Override onFinishInflate()160 protected void onFinishInflate() { 161 super.onFinishInflate(); 162 mNoRecentItemsPane = findViewById(R.id.no_recent_items_pane); 163 mScrollView = findViewById(R.id.scroll_view); 164 mContent = findViewById(R.id.content); 165 mStartScrollArrow = findViewById(R.id.scroll_button_start); 166 mEndScrollArrow = findViewById(R.id.scroll_button_end); 167 168 setDescendantFocusability(ViewGroup.FOCUS_BLOCK_DESCENDANTS); 169 170 Resources resources = getResources(); 171 mSpacing = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_spacing); 172 mSmallSpacing = resources.getDimensionPixelSize( 173 R.dimen.keyboard_quick_switch_view_small_spacing); 174 mOutlineRadius = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_radius); 175 mTaskViewBorderWidth = resources.getDimensionPixelSize( 176 R.dimen.keyboard_quick_switch_border_width); 177 mTaskViewRadius = resources.getDimensionPixelSize( 178 R.dimen.keyboard_quick_switch_task_view_radius); 179 180 mIsRtl = Utilities.isRtl(resources); 181 182 TypefaceUtils.setTypeface( 183 mNoRecentItemsPane.findViewById(R.id.no_recent_items_text), 184 FontFamily.GSF_LABEL_LARGE); 185 } 186 registerOnBackInvokedCallback()187 private void registerOnBackInvokedCallback() { 188 OnBackInvokedDispatcher dispatcher = findOnBackInvokedDispatcher(); 189 190 if (isOnBackInvokedCallbackEnabled(dispatcher) 191 && !mIsBackCallbackRegistered) { 192 dispatcher.registerOnBackInvokedCallback( 193 OnBackInvokedDispatcher.PRIORITY_OVERLAY, mViewCallbacks.onBackInvokedCallback); 194 mIsBackCallbackRegistered = true; 195 } 196 } 197 unregisterOnBackInvokedCallback()198 private void unregisterOnBackInvokedCallback() { 199 OnBackInvokedDispatcher dispatcher = findOnBackInvokedDispatcher(); 200 201 if (isOnBackInvokedCallbackEnabled(dispatcher) 202 && mIsBackCallbackRegistered) { 203 dispatcher.unregisterOnBackInvokedCallback( 204 mViewCallbacks.onBackInvokedCallback); 205 mIsBackCallbackRegistered = false; 206 } 207 } 208 isOnBackInvokedCallbackEnabled(OnBackInvokedDispatcher dispatcher)209 private boolean isOnBackInvokedCallbackEnabled(OnBackInvokedDispatcher dispatcher) { 210 return dispatcher instanceof WindowOnBackInvokedDispatcher 211 && ((WindowOnBackInvokedDispatcher) dispatcher).isOnBackInvokedCallbackEnabled() 212 && mViewCallbacks != null; 213 } 214 createAndAddTaskView( int index, boolean isFinalView, boolean useSmallStartSpacing, @LayoutRes int resId, @NonNull LayoutInflater layoutInflater, @Nullable View previousView)215 private KeyboardQuickSwitchTaskView createAndAddTaskView( 216 int index, 217 boolean isFinalView, 218 boolean useSmallStartSpacing, 219 @LayoutRes int resId, 220 @NonNull LayoutInflater layoutInflater, 221 @Nullable View previousView) { 222 KeyboardQuickSwitchTaskView taskView = (KeyboardQuickSwitchTaskView) layoutInflater.inflate( 223 resId, mContent, false); 224 taskView.setId(View.generateViewId()); 225 taskView.setOnClickListener(v -> mViewCallbacks.launchTaskAt(index)); 226 227 LayoutParams lp = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 228 // Create a left-to-right ordering of views (or right-to-left in RTL locales) 229 if (previousView != null) { 230 lp.startToEnd = previousView.getId(); 231 } else { 232 lp.startToStart = PARENT_ID; 233 } 234 lp.topToTop = PARENT_ID; 235 lp.bottomToBottom = PARENT_ID; 236 // Add spacing between views 237 lp.setMarginStart(useSmallStartSpacing ? mSmallSpacing : mSpacing); 238 if (isFinalView) { 239 // Add spacing to the end of the final view so that scrolling ends with some padding. 240 lp.endToEnd = PARENT_ID; 241 lp.setMarginEnd(mSpacing); 242 lp.horizontalBias = 1f; 243 } 244 245 mContent.addView(taskView, lp); 246 247 return taskView; 248 } 249 applyLoadPlan( @onNull Context context, @NonNull List<GroupTask> groupTasks, int numHiddenTasks, boolean updateTasks, int currentFocusIndexOverride, @NonNull KeyboardQuickSwitchViewController.ViewCallbacks viewCallbacks, boolean useDesktopTaskView)250 protected void applyLoadPlan( 251 @NonNull Context context, 252 @NonNull List<GroupTask> groupTasks, 253 int numHiddenTasks, 254 boolean updateTasks, 255 int currentFocusIndexOverride, 256 @NonNull KeyboardQuickSwitchViewController.ViewCallbacks viewCallbacks, 257 boolean useDesktopTaskView) { 258 mContent.removeAllViews(); 259 260 mViewCallbacks = viewCallbacks; 261 Resources resources = context.getResources(); 262 Resources.Theme theme = context.getTheme(); 263 264 View previousTaskView = null; 265 LayoutInflater layoutInflater = LayoutInflater.from(context); 266 int tasksToDisplay = groupTasks.size(); 267 for (int i = 0; i < tasksToDisplay; i++) { 268 GroupTask groupTask = groupTasks.get(i); 269 KeyboardQuickSwitchTaskView currentTaskView = createAndAddTaskView( 270 i, 271 /* isFinalView= */ i == tasksToDisplay - 1 272 && numHiddenTasks == 0 && !useDesktopTaskView, 273 /* useSmallStartSpacing= */ false, 274 mViewCallbacks.isAspectRatioSquare() 275 ? R.layout.keyboard_quick_switch_taskview_square 276 : R.layout.keyboard_quick_switch_taskview, 277 layoutInflater, 278 previousTaskView); 279 280 Task task1; 281 Task task2; 282 if (groupTask instanceof SplitTask splitTask) { 283 task1 = splitTask.getTopLeftTask(); 284 task2 = splitTask.getBottomRightTask(); 285 } else if (groupTask instanceof SingleTask singleTask) { 286 task1 = singleTask.getTask(); 287 task2 = null; 288 } else { 289 continue; 290 } 291 292 currentTaskView.setPositionInformation(i, tasksToDisplay); 293 currentTaskView.setThumbnailsForSplitTasks( 294 task1, 295 task2, 296 updateTasks ? mViewCallbacks::updateThumbnailInBackground : null, 297 updateTasks ? mViewCallbacks::updateIconInBackground : null, 298 groupTask instanceof SplitTask splitTask ? splitTask.getSplitBounds() : null); 299 300 previousTaskView = currentTaskView; 301 } 302 if (numHiddenTasks > 0) { 303 HashMap<String, Integer> args = new HashMap<>(); 304 args.put("count", numHiddenTasks); 305 306 mOverviewTaskIndex = getTaskCount(); 307 View overviewButton = createAndAddTaskView( 308 mOverviewTaskIndex, 309 /* isFinalView= */ !useDesktopTaskView, 310 /* useSmallStartSpacing= */ false, 311 R.layout.keyboard_quick_switch_overview_taskview, 312 layoutInflater, 313 previousTaskView); 314 315 overviewButton.<TextView>findViewById(R.id.large_text).setText( 316 String.format(Locale.getDefault(), "%d", numHiddenTasks)); 317 overviewButton.<TextView>findViewById(R.id.small_text).setText(new MessageFormat( 318 resources.getString(R.string.quick_switch_overflow), 319 Locale.getDefault()).format(args)); 320 321 previousTaskView = overviewButton; 322 } 323 if (useDesktopTaskView) { 324 mDesktopTaskIndex = getTaskCount(); 325 View desktopButton = createAndAddTaskView( 326 mDesktopTaskIndex, 327 /* isFinalView= */ true, 328 /* useSmallStartSpacing= */ numHiddenTasks > 0, 329 R.layout.keyboard_quick_switch_desktop_taskview, 330 layoutInflater, 331 previousTaskView); 332 333 desktopButton.<TextView>findViewById(R.id.small_text).setText( 334 resources.getString(R.string.quick_switch_desktop)); 335 } 336 mDisplayingRecentTasks = !groupTasks.isEmpty() || useDesktopTaskView; 337 338 getViewTreeObserver().addOnGlobalLayoutListener( 339 new ViewTreeObserver.OnGlobalLayoutListener() { 340 @Override 341 public void onGlobalLayout() { 342 registerOnBackInvokedCallback(); 343 animateOpen(currentFocusIndexOverride); 344 345 getViewTreeObserver().removeOnGlobalLayoutListener(this); 346 } 347 }); 348 } 349 350 enableScrollArrowSupport()351 void enableScrollArrowSupport() { 352 if (mSupportsScrollArrows) { 353 return; 354 } 355 mSupportsScrollArrows = true; 356 357 if (mIsRtl) { 358 mStartScrollArrow.setContentDescription( 359 getResources().getString(R.string.quick_switch_scroll_arrow_right)); 360 mEndScrollArrow.setContentDescription( 361 getResources().getString(R.string.quick_switch_scroll_arrow_left)); 362 } 363 364 365 mStartScrollArrow.setOnClickListener(new OnClickListener() { 366 @Override 367 public void onClick(View v) { 368 if (mIsRtl) { 369 runScrollCommand(false, () -> { 370 mScrollView.smoothScrollBy(mScrollView.getWidth(), 0); 371 }); 372 } else { 373 runScrollCommand(false, () -> { 374 mScrollView.smoothScrollBy(-mScrollView.getWidth(), 0); 375 }); 376 } 377 } 378 }); 379 380 mEndScrollArrow.setOnClickListener(new OnClickListener() { 381 @Override 382 public void onClick(View v) { 383 if (mIsRtl) { 384 runScrollCommand(false, () -> { 385 mScrollView.smoothScrollBy(-mScrollView.getWidth(), 0); 386 }); 387 } else { 388 runScrollCommand(false, () -> { 389 mScrollView.smoothScrollBy(mScrollView.getWidth(), 0); 390 }); 391 } 392 } 393 }); 394 395 // Add listeners to disable arrow buttons when the scroll view cannot be further scrolled in 396 // the associated direction. 397 mScrollView.setOnScrollChangeListener(new OnScrollChangeListener() { 398 @Override 399 public void onScrollChange(View v, int scrollX, int scrollY, int oldScrollX, 400 int oldScrollY) { 401 updateArrowButtonsEnabledState(); 402 } 403 }); 404 405 // Update scroll view outline to clip its contents with rounded corners. 406 mScrollView.setClipToOutline(true); 407 mScrollView.setOutlineProvider(new ViewOutlineProvider() { 408 @Override 409 public void getOutline(View view, Outline outline) { 410 int spacingWithoutBorder = mSpacing - mTaskViewBorderWidth; 411 outline.setRoundRect(spacingWithoutBorder, 412 spacingWithoutBorder, view.getWidth() - spacingWithoutBorder, 413 view.getHeight() - spacingWithoutBorder, 414 mTaskViewRadius); 415 } 416 }); 417 } 418 updateArrowButtonsEnabledState()419 private void updateArrowButtonsEnabledState() { 420 if (!mDisplayingRecentTasks) { 421 return; 422 } 423 424 int scrollX = mScrollView.getScrollX(); 425 if (mIsRtl) { 426 mEndScrollArrow.setEnabled(scrollX > 0); 427 mStartScrollArrow.setEnabled(scrollX < mContent.getWidth() - mScrollView.getWidth()); 428 } else { 429 mStartScrollArrow.setEnabled(scrollX > 0); 430 mEndScrollArrow.setEnabled(scrollX < mContent.getWidth() - mScrollView.getWidth()); 431 } 432 } 433 434 int getOverviewTaskIndex() { 435 return mOverviewTaskIndex; 436 } 437 438 int getDesktopTaskIndex() { 439 return mDesktopTaskIndex; 440 } 441 442 void resetViewCallbacks() { 443 // Unregister the back invoked callback after the view is closed and before the 444 // mViewCallbacks is reset. 445 unregisterOnBackInvokedCallback(); 446 mViewCallbacks = null; 447 } 448 449 private void animateDisplayedContentForClose(View view, AnimatorSet animator) { 450 Animator translationYAnimation = ObjectAnimator.ofFloat( 451 view, 452 TRANSLATION_Y, 453 0, -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP)); 454 translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS); 455 translationYAnimation.setInterpolator(CLOSE_TRANSLATION_Y_INTERPOLATOR); 456 animator.play(translationYAnimation); 457 458 Animator contentAlphaAnimation = ObjectAnimator.ofFloat(view, ALPHA, 1f, 0f); 459 contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS); 460 animator.play(contentAlphaAnimation); 461 462 } 463 464 protected Animator getCloseAnimation() { 465 AnimatorSet closeAnimation = new AnimatorSet(); 466 467 Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(0f); 468 outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS); 469 outlineAnimation.setInterpolator(CLOSE_OUTLINE_INTERPOLATOR); 470 closeAnimation.play(outlineAnimation); 471 472 Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 1f, 0f); 473 alphaAnimation.setStartDelay(ALPHA_ANIMATION_START_DELAY_MS); 474 alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS); 475 closeAnimation.play(alphaAnimation); 476 477 View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane; 478 animateDisplayedContentForClose(displayedContent, closeAnimation); 479 if (mSupportsScrollArrows) { 480 animateDisplayedContentForClose(mStartScrollArrow, closeAnimation); 481 animateDisplayedContentForClose(mEndScrollArrow, closeAnimation); 482 } 483 484 closeAnimation.addListener(new AnimatorListenerAdapter() { 485 @Override 486 public void onAnimationStart(Animator animation) { 487 super.onAnimationStart(animation); 488 if (mOpenAnimation != null) { 489 mOpenAnimation.cancel(); 490 } 491 } 492 }); 493 494 return closeAnimation; 495 } 496 497 private void animateDisplayedContentForOpen(View view, AnimatorSet animator) { 498 Animator translationXAnimation = ObjectAnimator.ofFloat( 499 view, 500 TRANSLATION_X, 501 -Utilities.dpToPx(CONTENT_START_TRANSLATION_X_DP), 0); 502 translationXAnimation.setDuration(CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS); 503 translationXAnimation.setInterpolator(OPEN_TRANSLATION_X_INTERPOLATOR); 504 animator.play(translationXAnimation); 505 506 Animator translationYAnimation = ObjectAnimator.ofFloat( 507 view, 508 TRANSLATION_Y, 509 -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP), 0); 510 translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS); 511 translationYAnimation.setInterpolator(OPEN_TRANSLATION_Y_INTERPOLATOR); 512 animator.play(translationYAnimation); 513 514 view.setAlpha(0.0f); 515 Animator contentAlphaAnimation = ObjectAnimator.ofFloat(view, ALPHA, 0f, 516 1f); 517 contentAlphaAnimation.setStartDelay(CONTENT_ALPHA_ANIMATION_START_DELAY_MS); 518 contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS); 519 animator.play(contentAlphaAnimation); 520 } 521 522 protected void animateOpen(int currentFocusIndexOverride) { 523 if (mOpenAnimation != null) { 524 // Restart animation since currentFocusIndexOverride can change the initial scroll. 525 mOpenAnimation.cancel(); 526 } 527 528 // Reset the alpha for the case where the KQS view is opened before. 529 setAlpha(0); 530 mScrollView.setAlpha(0); 531 mNoRecentItemsPane.setAlpha(0); 532 533 mOpenAnimation = new AnimatorSet(); 534 535 Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(1f); 536 outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS); 537 mOpenAnimation.play(outlineAnimation); 538 539 Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 0f, 1f); 540 alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS); 541 mOpenAnimation.play(alphaAnimation); 542 543 View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane; 544 animateDisplayedContentForOpen(displayedContent, mOpenAnimation); 545 if (mSupportsScrollArrows) { 546 animateDisplayedContentForOpen(mStartScrollArrow, mOpenAnimation); 547 animateDisplayedContentForOpen(mEndScrollArrow, mOpenAnimation); 548 } 549 550 551 ViewOutlineProvider outlineProvider = getOutlineProvider(); 552 int defaultFocusedTaskIndex = Math.min( 553 getTaskCount() - 1, 554 currentFocusIndexOverride == -1 ? 1 : currentFocusIndexOverride); 555 mOpenAnimation.addListener(new AnimatorListenerAdapter() { 556 @Override 557 public void onAnimationStart(Animator animation) { 558 super.onAnimationStart(animation); 559 InteractionJankMonitorWrapper.begin( 560 KeyboardQuickSwitchView.this, Cuj.CUJ_LAUNCHER_KEYBOARD_QUICK_SWITCH_OPEN); 561 setClipToPadding(false); 562 setOutlineProvider(new ViewOutlineProvider() { 563 @Override 564 public void getOutline(View view, Outline outline) { 565 outline.setRoundRect( 566 /* rect= */ new Rect( 567 /* left= */ 0, 568 /* top= */ 0, 569 /* right= */ getWidth(), 570 /* bottom= */ 571 (int) (getHeight() * Utilities.mapBoundToRange( 572 mOutlineAnimationProgress.value, 573 /* lowerBound= */ 0f, 574 /* upperBound= */ 1f, 575 /* toMin= */ OUTLINE_START_HEIGHT_FACTOR, 576 /* toMax= */ 1f, 577 OPEN_OUTLINE_INTERPOLATOR))), 578 /* radius= */ mOutlineRadius * Utilities.mapBoundToRange( 579 mOutlineAnimationProgress.value, 580 /* lowerBound= */ 0f, 581 /* upperBound= */ 1f, 582 /* toMin= */ OUTLINE_START_RADIUS_FACTOR, 583 /* toMax= */ 1f, 584 OPEN_OUTLINE_INTERPOLATOR)); 585 } 586 }); 587 588 if (mSupportsScrollArrows) { 589 mScrollView.getViewTreeObserver().addOnGlobalLayoutListener( 590 new ViewTreeObserver.OnGlobalLayoutListener() { 591 @Override 592 public void onGlobalLayout() { 593 if (mScrollView.getWidth() == 0) { 594 return; 595 } 596 mScrollView.getWidth()597 if (mContent.getWidth() > mScrollView.getWidth()) { 598 mStartScrollArrow.setVisibility(VISIBLE); 599 mEndScrollArrow.setVisibility(VISIBLE); 600 updateArrowButtonsEnabledState(); 601 } 602 mScrollView.getViewTreeObserver().removeOnGlobalLayoutListener( 603 this); 604 } 605 }); 606 } 607 608 animateFocusMove(-1, defaultFocusedTaskIndex); 609 displayedContent.setVisibility(VISIBLE); 610 setVisibility(VISIBLE); requestFocus()611 requestFocus(); 612 } 613 614 @Override 615 public void onAnimationCancel(Animator animation) { 616 super.onAnimationCancel(animation); 617 InteractionJankMonitorWrapper.cancel(Cuj.CUJ_LAUNCHER_KEYBOARD_QUICK_SWITCH_OPEN); 618 } 619 620 @Override 621 public void onAnimationEnd(Animator animation) { 622 super.onAnimationEnd(animation); 623 setClipToPadding(true); 624 setOutlineProvider(outlineProvider); 625 invalidateOutline(); 626 mOpenAnimation = null; 627 InteractionJankMonitorWrapper.end(Cuj.CUJ_LAUNCHER_KEYBOARD_QUICK_SWITCH_OPEN); 628 629 View focusedTask = getTaskAt(defaultFocusedTaskIndex); 630 if (focusedTask != null) { 631 focusedTask.requestAccessibilityFocus(); 632 } 633 } 634 }); 635 636 mOpenAnimation.start(); 637 } 638 639 protected void animateFocusMove(int fromIndex, int toIndex) { 640 if (!mDisplayingRecentTasks) { 641 return; 642 } 643 KeyboardQuickSwitchTaskView focusedTask = getTaskAt(toIndex); 644 if (focusedTask == null) { 645 return; 646 } 647 AnimatorSet focusAnimation = new AnimatorSet(); 648 focusAnimation.play(focusedTask.getFocusAnimator(true)); 649 650 KeyboardQuickSwitchTaskView previouslyFocusedTask = getTaskAt(fromIndex); 651 if (previouslyFocusedTask != null) { 652 focusAnimation.play(previouslyFocusedTask.getFocusAnimator(false)); 653 } 654 655 focusAnimation.addListener(new AnimatorListenerAdapter() { 656 @Override 657 public void onAnimationStart(Animator animation) { 658 super.onAnimationStart(animation); 659 focusedTask.requestAccessibilityFocus(); 660 if (fromIndex == -1) { 661 int firstVisibleTaskIndex = toIndex == 0 662 ? toIndex 663 : getTaskAt(toIndex - 1) == null 664 ? toIndex : toIndex - 1; 665 // Scroll so that the previous task view is truncated as a visual hint that 666 // there are more tasks 667 initializeScroll( 668 firstVisibleTaskIndex, 669 /* shouldTruncateTarget= */ firstVisibleTaskIndex != 0 670 && firstVisibleTaskIndex != toIndex); 671 } else if (toIndex > fromIndex || toIndex == 0) { 672 // Scrolling to next task view 673 if (mIsRtl) { 674 scrollLeftTo(focusedTask); 675 } else { 676 scrollRightTo(focusedTask); 677 } 678 } else { 679 // Scrolling to previous task view 680 if (mIsRtl) { 681 scrollRightTo(focusedTask); 682 } else { 683 scrollLeftTo(focusedTask); 684 } 685 } 686 if (mViewCallbacks != null) { 687 mViewCallbacks.updateCurrentFocusIndex(toIndex); 688 } 689 } 690 }); 691 692 focusAnimation.start(); 693 } 694 695 @Override 696 public boolean dispatchKeyEvent(KeyEvent event) { 697 TestLogging.recordKeyEvent( 698 TestProtocol.SEQUENCE_MAIN, "KeyboardQuickSwitchView key event", event); 699 return super.dispatchKeyEvent(event); 700 } 701 702 @Override 703 public boolean onKeyUp(int keyCode, KeyEvent event) { 704 return (mViewCallbacks != null 705 && mViewCallbacks.onKeyUp(keyCode, event, mIsRtl, mDisplayingRecentTasks)) 706 || super.onKeyUp(keyCode, event); 707 } 708 709 private void initializeScroll(int index, boolean shouldTruncateTarget) { 710 if (!mDisplayingRecentTasks) { 711 return; 712 } 713 View task = getTaskAt(index); 714 if (task == null) { 715 return; 716 } 717 if (mIsRtl) { 718 scrollLeftTo( 719 task, 720 shouldTruncateTarget, 721 /* smoothScroll= */ false, 722 /* waitForLayout= */ true); 723 } else { 724 scrollRightTo( 725 task, 726 shouldTruncateTarget, 727 /* smoothScroll= */ false, 728 /* waitForLayout= */ true); 729 } 730 } 731 732 private void scrollRightTo(@NonNull View targetTask) { 733 scrollRightTo( 734 targetTask, 735 /* shouldTruncateTarget= */ false, 736 /* smoothScroll= */ true, 737 /* waitForLayout= */ false); 738 } 739 740 private void scrollRightTo( 741 @NonNull View targetTask, 742 boolean shouldTruncateTarget, 743 boolean smoothScroll, 744 boolean waitForLayout) { 745 if (!mDisplayingRecentTasks) { 746 return; 747 } 748 if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) { 749 return; 750 } 751 runScrollCommand(waitForLayout, () -> { 752 int scrollTo = targetTask.getLeft() - mSpacing 753 + (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0); 754 // Scroll so that the focused task is to the left of the list 755 if (smoothScroll) { 756 mScrollView.smoothScrollTo(scrollTo, 0); 757 } else { 758 mScrollView.scrollTo(scrollTo, 0); 759 } 760 }); 761 } 762 763 private void scrollLeftTo(@NonNull View targetTask) { 764 scrollLeftTo( 765 targetTask, 766 /* shouldTruncateTarget= */ false, 767 /* smoothScroll= */ true, 768 /* waitForLayout= */ false); 769 } 770 771 private void scrollLeftTo( 772 @NonNull View targetTask, 773 boolean shouldTruncateTarget, 774 boolean smoothScroll, 775 boolean waitForLayout) { 776 if (!mDisplayingRecentTasks) { 777 return; 778 } 779 if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) { 780 return; 781 } 782 runScrollCommand(waitForLayout, () -> { 783 int scrollTo = targetTask.getRight() + mSpacing - mScrollView.getWidth() 784 - (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0); 785 // Scroll so that the focused task is to the right of the list 786 if (smoothScroll) { 787 mScrollView.smoothScrollTo(scrollTo, 0); 788 } else { 789 mScrollView.scrollTo(scrollTo, 0); 790 } 791 }); 792 } 793 794 private boolean shouldScroll(@NonNull View targetTask, boolean shouldTruncateTarget) { 795 boolean isTargetTruncated = 796 targetTask.getRight() + mSpacing > mScrollView.getScrollX() + mScrollView.getWidth() 797 || Math.max(0, targetTask.getLeft() - mSpacing) < mScrollView.getScrollX(); 798 799 return isTargetTruncated && !shouldTruncateTarget; 800 } 801 802 private void runScrollCommand(boolean waitForLayout, @NonNull Runnable scrollCommand) { 803 if (!waitForLayout) { 804 scrollCommand.run(); 805 return; 806 } 807 mScrollView.getViewTreeObserver().addOnGlobalLayoutListener( 808 new ViewTreeObserver.OnGlobalLayoutListener() { 809 @Override 810 public void onGlobalLayout() { 811 scrollCommand.run(); 812 mScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); 813 } 814 }); 815 } 816 817 @Nullable 818 protected KeyboardQuickSwitchTaskView getTaskAt(int index) { 819 return !mDisplayingRecentTasks || index < 0 || index >= getTaskCount() 820 ? null : (KeyboardQuickSwitchTaskView) mContent.getChildAt(index); 821 } 822 823 public int getTaskCount() { 824 return mContent.getChildCount(); 825 } 826 } 827