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