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