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