• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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