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