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 static com.android.launcher3.taskbar.KeyboardQuickSwitchController.MAX_TASKS;
21 
22 import android.animation.Animator;
23 import android.animation.AnimatorListenerAdapter;
24 import android.animation.AnimatorSet;
25 import android.animation.ObjectAnimator;
26 import android.content.Context;
27 import android.content.res.Resources;
28 import android.graphics.Outline;
29 import android.graphics.Rect;
30 import android.icu.text.MessageFormat;
31 import android.util.AttributeSet;
32 import android.view.KeyEvent;
33 import android.view.LayoutInflater;
34 import android.view.View;
35 import android.view.ViewOutlineProvider;
36 import android.view.ViewTreeObserver;
37 import android.view.animation.Interpolator;
38 import android.widget.HorizontalScrollView;
39 import android.widget.ImageView;
40 import android.widget.TextView;
41 
42 import androidx.annotation.LayoutRes;
43 import androidx.annotation.NonNull;
44 import androidx.annotation.Nullable;
45 import androidx.constraintlayout.widget.ConstraintLayout;
46 import androidx.core.content.res.ResourcesCompat;
47 
48 import com.android.app.animation.Interpolators;
49 import com.android.launcher3.R;
50 import com.android.launcher3.Utilities;
51 import com.android.launcher3.anim.AnimatedFloat;
52 import com.android.launcher3.testing.TestLogging;
53 import com.android.launcher3.testing.shared.TestProtocol;
54 import com.android.quickstep.util.DesktopTask;
55 import com.android.quickstep.util.GroupTask;
56 
57 import java.util.HashMap;
58 import java.util.List;
59 import java.util.Locale;
60 
61 /**
62  * View that allows quick switching between recent tasks through keyboard alt-tab and alt-shift-tab
63  * commands.
64  */
65 public class KeyboardQuickSwitchView extends ConstraintLayout {
66 
67     private static final long OUTLINE_ANIMATION_DURATION_MS = 333;
68     private static final float OUTLINE_START_HEIGHT_FACTOR = 0.45f;
69     private static final float OUTLINE_START_RADIUS_FACTOR = 0.25f;
70     private static final Interpolator OPEN_OUTLINE_INTERPOLATOR =
71             Interpolators.EMPHASIZED_DECELERATE;
72     private static final Interpolator CLOSE_OUTLINE_INTERPOLATOR =
73             Interpolators.EMPHASIZED_ACCELERATE;
74 
75     private static final long ALPHA_ANIMATION_DURATION_MS = 83;
76     private static final long ALPHA_ANIMATION_START_DELAY_MS = 67;
77 
78     private static final long CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS = 500;
79     private static final long CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS = 333;
80     private static final float CONTENT_START_TRANSLATION_X_DP = 32;
81     private static final float CONTENT_START_TRANSLATION_Y_DP = 40;
82     private static final Interpolator OPEN_TRANSLATION_X_INTERPOLATOR = Interpolators.EMPHASIZED;
83     private static final Interpolator OPEN_TRANSLATION_Y_INTERPOLATOR =
84             Interpolators.EMPHASIZED_DECELERATE;
85     private static final Interpolator CLOSE_TRANSLATION_Y_INTERPOLATOR =
86             Interpolators.EMPHASIZED_ACCELERATE;
87 
88     private static final long CONTENT_ALPHA_ANIMATION_DURATION_MS = 83;
89     private static final long CONTENT_ALPHA_ANIMATION_START_DELAY_MS = 83;
90 
91     private final AnimatedFloat mOutlineAnimationProgress = new AnimatedFloat(
92             this::invalidateOutline);
93 
94     private boolean mDisplayingRecentTasks;
95     private View mNoRecentItemsPane;
96     private HorizontalScrollView mScrollView;
97     private ConstraintLayout mContent;
98 
99     private int mTaskViewWidth;
100     private int mTaskViewHeight;
101     private int mSpacing;
102     private int mOutlineRadius;
103     private boolean mIsRtl;
104 
105     @Nullable private AnimatorSet mOpenAnimation;
106 
107     @Nullable private KeyboardQuickSwitchViewController.ViewCallbacks mViewCallbacks;
108 
KeyboardQuickSwitchView(@onNull Context context)109     public KeyboardQuickSwitchView(@NonNull Context context) {
110         this(context, null);
111     }
112 
KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs)113     public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs) {
114         this(context, attrs, 0);
115     }
116 
KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr)117     public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs,
118             int defStyleAttr) {
119         this(context, attrs, defStyleAttr, 0);
120     }
121 
KeyboardQuickSwitchView(@onNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes)122     public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs,
123             int defStyleAttr,
124             int defStyleRes) {
125         super(context, attrs, defStyleAttr, defStyleRes);
126     }
127 
128     @Override
onFinishInflate()129     protected void onFinishInflate() {
130         super.onFinishInflate();
131         mNoRecentItemsPane = findViewById(R.id.no_recent_items_pane);
132         mScrollView = findViewById(R.id.scroll_view);
133         mContent = findViewById(R.id.content);
134 
135         Resources resources = getResources();
136         mTaskViewWidth = resources.getDimensionPixelSize(
137                 R.dimen.keyboard_quick_switch_taskview_width);
138         mTaskViewHeight = resources.getDimensionPixelSize(
139                 R.dimen.keyboard_quick_switch_taskview_height);
140         mSpacing = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_spacing);
141         mOutlineRadius = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_radius);
142         mIsRtl = Utilities.isRtl(resources);
143     }
144 
createAndAddTaskView( int index, boolean isFinalView, @LayoutRes int resId, @NonNull LayoutInflater layoutInflater, @Nullable View previousView)145     private KeyboardQuickSwitchTaskView createAndAddTaskView(
146             int index,
147             boolean isFinalView,
148             @LayoutRes int resId,
149             @NonNull LayoutInflater layoutInflater,
150             @Nullable View previousView) {
151         KeyboardQuickSwitchTaskView taskView = (KeyboardQuickSwitchTaskView) layoutInflater.inflate(
152                 resId, mContent, false);
153         taskView.setId(View.generateViewId());
154         taskView.setOnClickListener(v -> mViewCallbacks.launchTaskAt(index));
155 
156         LayoutParams lp = new LayoutParams(mTaskViewWidth, mTaskViewHeight);
157         // Create a left-to-right ordering of views (or right-to-left in RTL locales)
158         if (previousView != null) {
159             lp.startToEnd = previousView.getId();
160         } else {
161             lp.startToStart = PARENT_ID;
162         }
163         lp.topToTop = PARENT_ID;
164         lp.bottomToBottom = PARENT_ID;
165         // Add spacing between views
166         lp.setMarginStart(mSpacing);
167         if (isFinalView) {
168             // Add spacing to the end of the final view so that scrolling ends with some padding.
169             lp.endToEnd = PARENT_ID;
170             lp.setMarginEnd(mSpacing);
171             lp.horizontalBias = 1f;
172         }
173 
174         mContent.addView(taskView, lp);
175 
176         return taskView;
177     }
178 
applyLoadPlan( @onNull Context context, @NonNull List<GroupTask> groupTasks, int numHiddenTasks, boolean updateTasks, int currentFocusIndexOverride, @NonNull KeyboardQuickSwitchViewController.ViewCallbacks viewCallbacks)179     protected void applyLoadPlan(
180             @NonNull Context context,
181             @NonNull List<GroupTask> groupTasks,
182             int numHiddenTasks,
183             boolean updateTasks,
184             int currentFocusIndexOverride,
185             @NonNull KeyboardQuickSwitchViewController.ViewCallbacks viewCallbacks) {
186         mViewCallbacks = viewCallbacks;
187         Resources resources = context.getResources();
188         Resources.Theme theme = context.getTheme();
189 
190         View previousTaskView = null;
191         LayoutInflater layoutInflater = LayoutInflater.from(context);
192         int tasksToDisplay = Math.min(MAX_TASKS, groupTasks.size());
193         for (int i = 0; i < tasksToDisplay; i++) {
194             GroupTask groupTask = groupTasks.get(i);
195             KeyboardQuickSwitchTaskView currentTaskView = createAndAddTaskView(
196                     i,
197                     /* isFinalView= */ i == tasksToDisplay - 1 && numHiddenTasks == 0,
198                     groupTask instanceof DesktopTask
199                             ? R.layout.keyboard_quick_switch_textonly_taskview
200                             : R.layout.keyboard_quick_switch_taskview,
201                     layoutInflater,
202                     previousTaskView);
203 
204             if (groupTask instanceof DesktopTask desktopTask) {
205                 HashMap<String, Integer> args = new HashMap<>();
206                 args.put("count", desktopTask.tasks.size());
207 
208                 currentTaskView.<ImageView>findViewById(R.id.icon).setImageDrawable(
209                         ResourcesCompat.getDrawable(resources, R.drawable.ic_desktop, theme));
210                 currentTaskView.<TextView>findViewById(R.id.text).setText(new MessageFormat(
211                         resources.getString(R.string.quick_switch_desktop),
212                         Locale.getDefault()).format(args));
213             } else {
214                 currentTaskView.setThumbnails(
215                         groupTask.task1,
216                         groupTask.task2,
217                         updateTasks ? mViewCallbacks::updateThumbnailInBackground : null,
218                         updateTasks ? mViewCallbacks::updateIconInBackground : null);
219             }
220             previousTaskView = currentTaskView;
221         }
222 
223         if (numHiddenTasks > 0) {
224             HashMap<String, Integer> args = new HashMap<>();
225             args.put("count", numHiddenTasks);
226 
227             View overviewButton = createAndAddTaskView(
228                     MAX_TASKS,
229                     /* isFinalView= */ true,
230                     R.layout.keyboard_quick_switch_textonly_taskview,
231                     layoutInflater,
232                     previousTaskView);
233 
234             overviewButton.<ImageView>findViewById(R.id.icon).setImageDrawable(
235                     ResourcesCompat.getDrawable(resources, R.drawable.view_carousel, theme));
236             overviewButton.<TextView>findViewById(R.id.text).setText(new MessageFormat(
237                     resources.getString(R.string.quick_switch_overflow),
238                     Locale.getDefault()).format(args));
239         }
240         mDisplayingRecentTasks = !groupTasks.isEmpty();
241 
242         getViewTreeObserver().addOnGlobalLayoutListener(
243                 new ViewTreeObserver.OnGlobalLayoutListener() {
244                     @Override
245                     public void onGlobalLayout() {
246                         animateOpen(currentFocusIndexOverride);
247 
248                         getViewTreeObserver().removeOnGlobalLayoutListener(this);
249                     }
250                 });
251     }
252 
getCloseAnimation()253     protected Animator getCloseAnimation() {
254         AnimatorSet closeAnimation = new AnimatorSet();
255 
256         Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(0f);
257         outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS);
258         outlineAnimation.setInterpolator(CLOSE_OUTLINE_INTERPOLATOR);
259         closeAnimation.play(outlineAnimation);
260 
261         Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 1f, 0f);
262         alphaAnimation.setStartDelay(ALPHA_ANIMATION_START_DELAY_MS);
263         alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS);
264         closeAnimation.play(alphaAnimation);
265 
266         View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane;
267         Animator translationYAnimation = ObjectAnimator.ofFloat(
268                 displayedContent,
269                 TRANSLATION_Y,
270                 0, -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP));
271         translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS);
272         translationYAnimation.setInterpolator(CLOSE_TRANSLATION_Y_INTERPOLATOR);
273         closeAnimation.play(translationYAnimation);
274 
275         Animator contentAlphaAnimation = ObjectAnimator.ofFloat(displayedContent, ALPHA, 1f, 0f);
276         contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS);
277         closeAnimation.play(contentAlphaAnimation);
278 
279         closeAnimation.addListener(new AnimatorListenerAdapter() {
280             @Override
281             public void onAnimationStart(Animator animation) {
282                 super.onAnimationStart(animation);
283                 if (mOpenAnimation != null) {
284                     mOpenAnimation.cancel();
285                 }
286             }
287         });
288 
289         return closeAnimation;
290     }
291 
animateOpen(int currentFocusIndexOverride)292     private void animateOpen(int currentFocusIndexOverride) {
293         if (mOpenAnimation != null) {
294             // Restart animation since currentFocusIndexOverride can change the initial scroll.
295             mOpenAnimation.cancel();
296         }
297         mOpenAnimation = new AnimatorSet();
298 
299         Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(1f);
300         outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS);
301         mOpenAnimation.play(outlineAnimation);
302 
303         Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 0f, 1f);
304         alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS);
305         mOpenAnimation.play(alphaAnimation);
306 
307         View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane;
308         Animator translationXAnimation = ObjectAnimator.ofFloat(
309                 displayedContent,
310                 TRANSLATION_X,
311                 -Utilities.dpToPx(CONTENT_START_TRANSLATION_X_DP), 0);
312         translationXAnimation.setDuration(CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS);
313         translationXAnimation.setInterpolator(OPEN_TRANSLATION_X_INTERPOLATOR);
314         mOpenAnimation.play(translationXAnimation);
315 
316         Animator translationYAnimation = ObjectAnimator.ofFloat(
317                 displayedContent,
318                 TRANSLATION_Y,
319                 -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP), 0);
320         translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS);
321         translationYAnimation.setInterpolator(OPEN_TRANSLATION_Y_INTERPOLATOR);
322         mOpenAnimation.play(translationYAnimation);
323 
324         Animator contentAlphaAnimation = ObjectAnimator.ofFloat(displayedContent, ALPHA, 0f, 1f);
325         contentAlphaAnimation.setStartDelay(CONTENT_ALPHA_ANIMATION_START_DELAY_MS);
326         contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS);
327         mOpenAnimation.play(contentAlphaAnimation);
328 
329         ViewOutlineProvider outlineProvider = getOutlineProvider();
330         mOpenAnimation.addListener(new AnimatorListenerAdapter() {
331             @Override
332             public void onAnimationStart(Animator animation) {
333                 super.onAnimationStart(animation);
334                 setClipToPadding(false);
335                 setOutlineProvider(new ViewOutlineProvider() {
336                     @Override
337                     public void getOutline(View view, Outline outline) {
338                         outline.setRoundRect(
339                                 /* rect= */ new Rect(
340                                         /* left= */ 0,
341                                         /* top= */ 0,
342                                         /* right= */ getWidth(),
343                                         /* bottom= */
344                                         (int) (getHeight() * Utilities.mapBoundToRange(
345                                                 mOutlineAnimationProgress.value,
346                                                 /* lowerBound= */ 0f,
347                                                 /* upperBound= */ 1f,
348                                                 /* toMin= */ OUTLINE_START_HEIGHT_FACTOR,
349                                                 /* toMax= */ 1f,
350                                                 OPEN_OUTLINE_INTERPOLATOR))),
351                                 /* radius= */ mOutlineRadius * Utilities.mapBoundToRange(
352                                         mOutlineAnimationProgress.value,
353                                         /* lowerBound= */ 0f,
354                                         /* upperBound= */ 1f,
355                                         /* toMin= */ OUTLINE_START_RADIUS_FACTOR,
356                                         /* toMax= */ 1f,
357                                         OPEN_OUTLINE_INTERPOLATOR));
358                     }
359                 });
360                 animateFocusMove(-1, Math.min(
361                         mContent.getChildCount() - 1,
362                         currentFocusIndexOverride == -1 ? 1 : currentFocusIndexOverride));
363                 displayedContent.setVisibility(VISIBLE);
364                 setVisibility(VISIBLE);
365                 requestFocus();
366             }
367 
368             @Override
369             public void onAnimationEnd(Animator animation) {
370                 super.onAnimationEnd(animation);
371                 setClipToPadding(true);
372                 setOutlineProvider(outlineProvider);
373                 invalidateOutline();
374                 mOpenAnimation = null;
375             }
376         });
377 
378         mOpenAnimation.start();
379     }
380 
animateFocusMove(int fromIndex, int toIndex)381     protected void animateFocusMove(int fromIndex, int toIndex) {
382         if (!mDisplayingRecentTasks) {
383             return;
384         }
385         KeyboardQuickSwitchTaskView focusedTask = getTaskAt(toIndex);
386         if (focusedTask == null) {
387             return;
388         }
389         AnimatorSet focusAnimation = new AnimatorSet();
390         focusAnimation.play(focusedTask.getFocusAnimator(true));
391 
392         KeyboardQuickSwitchTaskView previouslyFocusedTask = getTaskAt(fromIndex);
393         if (previouslyFocusedTask != null) {
394             focusAnimation.play(previouslyFocusedTask.getFocusAnimator(false));
395         }
396 
397         focusAnimation.addListener(new AnimatorListenerAdapter() {
398             @Override
399             public void onAnimationStart(Animator animation) {
400                 super.onAnimationStart(animation);
401                 focusedTask.requestAccessibilityFocus();
402                 if (fromIndex == -1) {
403                     int firstVisibleTaskIndex = toIndex == 0
404                             ? toIndex
405                             : getTaskAt(toIndex - 1) == null
406                                     ? toIndex : toIndex - 1;
407                     // Scroll so that the previous task view is truncated as a visual hint that
408                     // there are more tasks
409                     initializeScroll(
410                             firstVisibleTaskIndex,
411                             /* shouldTruncateTarget= */ firstVisibleTaskIndex != 0
412                                     && firstVisibleTaskIndex != toIndex);
413                 } else if (toIndex > fromIndex || toIndex == 0) {
414                     // Scrolling to next task view
415                     if (mIsRtl) {
416                         scrollLeftTo(focusedTask);
417                     } else {
418                         scrollRightTo(focusedTask);
419                     }
420                 } else {
421                     // Scrolling to previous task view
422                     if (mIsRtl) {
423                         scrollRightTo(focusedTask);
424                     } else {
425                         scrollLeftTo(focusedTask);
426                     }
427                 }
428                 if (mViewCallbacks != null) {
429                     mViewCallbacks.updateCurrentFocusIndex(toIndex);
430                 }
431             }
432         });
433 
434         focusAnimation.start();
435     }
436 
437     @Override
dispatchKeyEvent(KeyEvent event)438     public boolean dispatchKeyEvent(KeyEvent event) {
439         TestLogging.recordKeyEvent(
440                 TestProtocol.SEQUENCE_MAIN, "KeyboardQuickSwitchView key event", event);
441         return super.dispatchKeyEvent(event);
442     }
443 
444     @Override
onKeyUp(int keyCode, KeyEvent event)445     public boolean onKeyUp(int keyCode, KeyEvent event) {
446         return (mViewCallbacks != null
447                 && mViewCallbacks.onKeyUp(keyCode, event, mIsRtl, mDisplayingRecentTasks))
448                 || super.onKeyUp(keyCode, event);
449     }
450 
initializeScroll(int index, boolean shouldTruncateTarget)451     private void initializeScroll(int index, boolean shouldTruncateTarget) {
452         if (!mDisplayingRecentTasks) {
453             return;
454         }
455         View task = getTaskAt(index);
456         if (task == null) {
457             return;
458         }
459         if (mIsRtl) {
460             scrollLeftTo(
461                     task,
462                     shouldTruncateTarget,
463                     /* smoothScroll= */ false,
464                     /* waitForLayout= */ true);
465         } else {
466             scrollRightTo(
467                     task,
468                     shouldTruncateTarget,
469                     /* smoothScroll= */ false,
470                     /* waitForLayout= */ true);
471         }
472     }
473 
scrollRightTo(@onNull View targetTask)474     private void scrollRightTo(@NonNull View targetTask) {
475         scrollRightTo(
476                 targetTask,
477                 /* shouldTruncateTarget= */ false,
478                 /* smoothScroll= */ true,
479                 /* waitForLayout= */ false);
480     }
481 
scrollRightTo( @onNull View targetTask, boolean shouldTruncateTarget, boolean smoothScroll, boolean waitForLayout)482     private void scrollRightTo(
483             @NonNull View targetTask,
484             boolean shouldTruncateTarget,
485             boolean smoothScroll,
486             boolean waitForLayout) {
487         if (!mDisplayingRecentTasks) {
488             return;
489         }
490         if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) {
491             return;
492         }
493         runScrollCommand(waitForLayout, () -> {
494             int scrollTo = targetTask.getLeft() - mSpacing
495                     + (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0);
496             // Scroll so that the focused task is to the left of the list
497             if (smoothScroll) {
498                 mScrollView.smoothScrollTo(scrollTo, 0);
499             } else {
500                 mScrollView.scrollTo(scrollTo, 0);
501             }
502         });
503     }
504 
scrollLeftTo(@onNull View targetTask)505     private void scrollLeftTo(@NonNull View targetTask) {
506         scrollLeftTo(
507                 targetTask,
508                 /* shouldTruncateTarget= */ false,
509                 /* smoothScroll= */ true,
510                 /* waitForLayout= */ false);
511     }
512 
scrollLeftTo( @onNull View targetTask, boolean shouldTruncateTarget, boolean smoothScroll, boolean waitForLayout)513     private void scrollLeftTo(
514             @NonNull View targetTask,
515             boolean shouldTruncateTarget,
516             boolean smoothScroll,
517             boolean waitForLayout) {
518         if (!mDisplayingRecentTasks) {
519             return;
520         }
521         if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) {
522             return;
523         }
524         runScrollCommand(waitForLayout, () -> {
525             int scrollTo = targetTask.getRight() + mSpacing - mScrollView.getWidth()
526                     - (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0);
527             // Scroll so that the focused task is to the right of the list
528             if (smoothScroll) {
529                 mScrollView.smoothScrollTo(scrollTo, 0);
530             } else {
531                 mScrollView.scrollTo(scrollTo, 0);
532             }
533         });
534     }
535 
shouldScroll(@onNull View targetTask, boolean shouldTruncateTarget)536     private boolean shouldScroll(@NonNull View targetTask, boolean shouldTruncateTarget) {
537         boolean isTargetTruncated =
538                 targetTask.getRight() + mSpacing > mScrollView.getScrollX() + mScrollView.getWidth()
539                         || Math.max(0, targetTask.getLeft() - mSpacing) < mScrollView.getScrollX();
540 
541         return isTargetTruncated && !shouldTruncateTarget;
542     }
543 
544     private void runScrollCommand(boolean waitForLayout, @NonNull Runnable scrollCommand) {
545         if (!waitForLayout) {
546             scrollCommand.run();
547             return;
548         }
549         mScrollView.getViewTreeObserver().addOnGlobalLayoutListener(
550                 new ViewTreeObserver.OnGlobalLayoutListener() {
551                     @Override
552                     public void onGlobalLayout() {
553                         scrollCommand.run();
554                         mScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
555                     }
556                 });
557     }
558 
559     @Nullable
560     protected KeyboardQuickSwitchTaskView getTaskAt(int index) {
561         return !mDisplayingRecentTasks || index < 0 || index >= mContent.getChildCount()
562                 ? null : (KeyboardQuickSwitchTaskView) mContent.getChildAt(index);
563     }
564 }
565