/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3.taskbar; import static androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID; import static com.android.launcher3.taskbar.KeyboardQuickSwitchController.MAX_TASKS; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.content.Context; import android.content.res.Resources; import android.graphics.Outline; import android.graphics.Rect; import android.icu.text.MessageFormat; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; import android.view.ViewOutlineProvider; import android.view.ViewTreeObserver; import android.view.animation.Interpolator; import android.widget.HorizontalScrollView; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.LayoutRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.res.ResourcesCompat; import com.android.app.animation.Interpolators; import com.android.launcher3.R; import com.android.launcher3.Utilities; import com.android.launcher3.anim.AnimatedFloat; import com.android.launcher3.testing.TestLogging; import com.android.launcher3.testing.shared.TestProtocol; import com.android.quickstep.util.DesktopTask; import com.android.quickstep.util.GroupTask; import java.util.HashMap; import java.util.List; import java.util.Locale; /** * View that allows quick switching between recent tasks through keyboard alt-tab and alt-shift-tab * commands. */ public class KeyboardQuickSwitchView extends ConstraintLayout { private static final long OUTLINE_ANIMATION_DURATION_MS = 333; private static final float OUTLINE_START_HEIGHT_FACTOR = 0.45f; private static final float OUTLINE_START_RADIUS_FACTOR = 0.25f; private static final Interpolator OPEN_OUTLINE_INTERPOLATOR = Interpolators.EMPHASIZED_DECELERATE; private static final Interpolator CLOSE_OUTLINE_INTERPOLATOR = Interpolators.EMPHASIZED_ACCELERATE; private static final long ALPHA_ANIMATION_DURATION_MS = 83; private static final long ALPHA_ANIMATION_START_DELAY_MS = 67; private static final long CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS = 500; private static final long CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS = 333; private static final float CONTENT_START_TRANSLATION_X_DP = 32; private static final float CONTENT_START_TRANSLATION_Y_DP = 40; private static final Interpolator OPEN_TRANSLATION_X_INTERPOLATOR = Interpolators.EMPHASIZED; private static final Interpolator OPEN_TRANSLATION_Y_INTERPOLATOR = Interpolators.EMPHASIZED_DECELERATE; private static final Interpolator CLOSE_TRANSLATION_Y_INTERPOLATOR = Interpolators.EMPHASIZED_ACCELERATE; private static final long CONTENT_ALPHA_ANIMATION_DURATION_MS = 83; private static final long CONTENT_ALPHA_ANIMATION_START_DELAY_MS = 83; private final AnimatedFloat mOutlineAnimationProgress = new AnimatedFloat( this::invalidateOutline); private boolean mDisplayingRecentTasks; private View mNoRecentItemsPane; private HorizontalScrollView mScrollView; private ConstraintLayout mContent; private int mTaskViewWidth; private int mTaskViewHeight; private int mSpacing; private int mOutlineRadius; private boolean mIsRtl; @Nullable private AnimatorSet mOpenAnimation; @Nullable private KeyboardQuickSwitchViewController.ViewCallbacks mViewCallbacks; public KeyboardQuickSwitchView(@NonNull Context context) { this(context, null); } public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs) { this(context, attrs, 0); } public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public KeyboardQuickSwitchView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } @Override protected void onFinishInflate() { super.onFinishInflate(); mNoRecentItemsPane = findViewById(R.id.no_recent_items_pane); mScrollView = findViewById(R.id.scroll_view); mContent = findViewById(R.id.content); Resources resources = getResources(); mTaskViewWidth = resources.getDimensionPixelSize( R.dimen.keyboard_quick_switch_taskview_width); mTaskViewHeight = resources.getDimensionPixelSize( R.dimen.keyboard_quick_switch_taskview_height); mSpacing = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_spacing); mOutlineRadius = resources.getDimensionPixelSize(R.dimen.keyboard_quick_switch_view_radius); mIsRtl = Utilities.isRtl(resources); } private KeyboardQuickSwitchTaskView createAndAddTaskView( int index, boolean isFinalView, @LayoutRes int resId, @NonNull LayoutInflater layoutInflater, @Nullable View previousView) { KeyboardQuickSwitchTaskView taskView = (KeyboardQuickSwitchTaskView) layoutInflater.inflate( resId, mContent, false); taskView.setId(View.generateViewId()); taskView.setOnClickListener(v -> mViewCallbacks.launchTaskAt(index)); LayoutParams lp = new LayoutParams(mTaskViewWidth, mTaskViewHeight); // Create a left-to-right ordering of views (or right-to-left in RTL locales) if (previousView != null) { lp.startToEnd = previousView.getId(); } else { lp.startToStart = PARENT_ID; } lp.topToTop = PARENT_ID; lp.bottomToBottom = PARENT_ID; // Add spacing between views lp.setMarginStart(mSpacing); if (isFinalView) { // Add spacing to the end of the final view so that scrolling ends with some padding. lp.endToEnd = PARENT_ID; lp.setMarginEnd(mSpacing); lp.horizontalBias = 1f; } mContent.addView(taskView, lp); return taskView; } protected void applyLoadPlan( @NonNull Context context, @NonNull List<GroupTask> groupTasks, int numHiddenTasks, boolean updateTasks, int currentFocusIndexOverride, @NonNull KeyboardQuickSwitchViewController.ViewCallbacks viewCallbacks) { mViewCallbacks = viewCallbacks; Resources resources = context.getResources(); Resources.Theme theme = context.getTheme(); View previousTaskView = null; LayoutInflater layoutInflater = LayoutInflater.from(context); int tasksToDisplay = Math.min(MAX_TASKS, groupTasks.size()); for (int i = 0; i < tasksToDisplay; i++) { GroupTask groupTask = groupTasks.get(i); KeyboardQuickSwitchTaskView currentTaskView = createAndAddTaskView( i, /* isFinalView= */ i == tasksToDisplay - 1 && numHiddenTasks == 0, groupTask instanceof DesktopTask ? R.layout.keyboard_quick_switch_textonly_taskview : R.layout.keyboard_quick_switch_taskview, layoutInflater, previousTaskView); if (groupTask instanceof DesktopTask desktopTask) { HashMap<String, Integer> args = new HashMap<>(); args.put("count", desktopTask.tasks.size()); currentTaskView.<ImageView>findViewById(R.id.icon).setImageDrawable( ResourcesCompat.getDrawable(resources, R.drawable.ic_desktop, theme)); currentTaskView.<TextView>findViewById(R.id.text).setText(new MessageFormat( resources.getString(R.string.quick_switch_desktop), Locale.getDefault()).format(args)); } else { currentTaskView.setThumbnails( groupTask.task1, groupTask.task2, updateTasks ? mViewCallbacks::updateThumbnailInBackground : null, updateTasks ? mViewCallbacks::updateIconInBackground : null); } previousTaskView = currentTaskView; } if (numHiddenTasks > 0) { HashMap<String, Integer> args = new HashMap<>(); args.put("count", numHiddenTasks); View overviewButton = createAndAddTaskView( MAX_TASKS, /* isFinalView= */ true, R.layout.keyboard_quick_switch_textonly_taskview, layoutInflater, previousTaskView); overviewButton.<ImageView>findViewById(R.id.icon).setImageDrawable( ResourcesCompat.getDrawable(resources, R.drawable.view_carousel, theme)); overviewButton.<TextView>findViewById(R.id.text).setText(new MessageFormat( resources.getString(R.string.quick_switch_overflow), Locale.getDefault()).format(args)); } mDisplayingRecentTasks = !groupTasks.isEmpty(); getViewTreeObserver().addOnGlobalLayoutListener( new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { animateOpen(currentFocusIndexOverride); getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); } protected Animator getCloseAnimation() { AnimatorSet closeAnimation = new AnimatorSet(); Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(0f); outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS); outlineAnimation.setInterpolator(CLOSE_OUTLINE_INTERPOLATOR); closeAnimation.play(outlineAnimation); Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 1f, 0f); alphaAnimation.setStartDelay(ALPHA_ANIMATION_START_DELAY_MS); alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS); closeAnimation.play(alphaAnimation); View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane; Animator translationYAnimation = ObjectAnimator.ofFloat( displayedContent, TRANSLATION_Y, 0, -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP)); translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS); translationYAnimation.setInterpolator(CLOSE_TRANSLATION_Y_INTERPOLATOR); closeAnimation.play(translationYAnimation); Animator contentAlphaAnimation = ObjectAnimator.ofFloat(displayedContent, ALPHA, 1f, 0f); contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS); closeAnimation.play(contentAlphaAnimation); closeAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { super.onAnimationStart(animation); if (mOpenAnimation != null) { mOpenAnimation.cancel(); } } }); return closeAnimation; } private void animateOpen(int currentFocusIndexOverride) { if (mOpenAnimation != null) { // Restart animation since currentFocusIndexOverride can change the initial scroll. mOpenAnimation.cancel(); } mOpenAnimation = new AnimatorSet(); Animator outlineAnimation = mOutlineAnimationProgress.animateToValue(1f); outlineAnimation.setDuration(OUTLINE_ANIMATION_DURATION_MS); mOpenAnimation.play(outlineAnimation); Animator alphaAnimation = ObjectAnimator.ofFloat(this, ALPHA, 0f, 1f); alphaAnimation.setDuration(ALPHA_ANIMATION_DURATION_MS); mOpenAnimation.play(alphaAnimation); View displayedContent = mDisplayingRecentTasks ? mScrollView : mNoRecentItemsPane; Animator translationXAnimation = ObjectAnimator.ofFloat( displayedContent, TRANSLATION_X, -Utilities.dpToPx(CONTENT_START_TRANSLATION_X_DP), 0); translationXAnimation.setDuration(CONTENT_TRANSLATION_X_ANIMATION_DURATION_MS); translationXAnimation.setInterpolator(OPEN_TRANSLATION_X_INTERPOLATOR); mOpenAnimation.play(translationXAnimation); Animator translationYAnimation = ObjectAnimator.ofFloat( displayedContent, TRANSLATION_Y, -Utilities.dpToPx(CONTENT_START_TRANSLATION_Y_DP), 0); translationYAnimation.setDuration(CONTENT_TRANSLATION_Y_ANIMATION_DURATION_MS); translationYAnimation.setInterpolator(OPEN_TRANSLATION_Y_INTERPOLATOR); mOpenAnimation.play(translationYAnimation); Animator contentAlphaAnimation = ObjectAnimator.ofFloat(displayedContent, ALPHA, 0f, 1f); contentAlphaAnimation.setStartDelay(CONTENT_ALPHA_ANIMATION_START_DELAY_MS); contentAlphaAnimation.setDuration(CONTENT_ALPHA_ANIMATION_DURATION_MS); mOpenAnimation.play(contentAlphaAnimation); ViewOutlineProvider outlineProvider = getOutlineProvider(); mOpenAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { super.onAnimationStart(animation); setClipToPadding(false); setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { outline.setRoundRect( /* rect= */ new Rect( /* left= */ 0, /* top= */ 0, /* right= */ getWidth(), /* bottom= */ (int) (getHeight() * Utilities.mapBoundToRange( mOutlineAnimationProgress.value, /* lowerBound= */ 0f, /* upperBound= */ 1f, /* toMin= */ OUTLINE_START_HEIGHT_FACTOR, /* toMax= */ 1f, OPEN_OUTLINE_INTERPOLATOR))), /* radius= */ mOutlineRadius * Utilities.mapBoundToRange( mOutlineAnimationProgress.value, /* lowerBound= */ 0f, /* upperBound= */ 1f, /* toMin= */ OUTLINE_START_RADIUS_FACTOR, /* toMax= */ 1f, OPEN_OUTLINE_INTERPOLATOR)); } }); animateFocusMove(-1, Math.min( mContent.getChildCount() - 1, currentFocusIndexOverride == -1 ? 1 : currentFocusIndexOverride)); displayedContent.setVisibility(VISIBLE); setVisibility(VISIBLE); requestFocus(); } @Override public void onAnimationEnd(Animator animation) { super.onAnimationEnd(animation); setClipToPadding(true); setOutlineProvider(outlineProvider); invalidateOutline(); mOpenAnimation = null; } }); mOpenAnimation.start(); } protected void animateFocusMove(int fromIndex, int toIndex) { if (!mDisplayingRecentTasks) { return; } KeyboardQuickSwitchTaskView focusedTask = getTaskAt(toIndex); if (focusedTask == null) { return; } AnimatorSet focusAnimation = new AnimatorSet(); focusAnimation.play(focusedTask.getFocusAnimator(true)); KeyboardQuickSwitchTaskView previouslyFocusedTask = getTaskAt(fromIndex); if (previouslyFocusedTask != null) { focusAnimation.play(previouslyFocusedTask.getFocusAnimator(false)); } focusAnimation.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { super.onAnimationStart(animation); focusedTask.requestAccessibilityFocus(); if (fromIndex == -1) { int firstVisibleTaskIndex = toIndex == 0 ? toIndex : getTaskAt(toIndex - 1) == null ? toIndex : toIndex - 1; // Scroll so that the previous task view is truncated as a visual hint that // there are more tasks initializeScroll( firstVisibleTaskIndex, /* shouldTruncateTarget= */ firstVisibleTaskIndex != 0 && firstVisibleTaskIndex != toIndex); } else if (toIndex > fromIndex || toIndex == 0) { // Scrolling to next task view if (mIsRtl) { scrollLeftTo(focusedTask); } else { scrollRightTo(focusedTask); } } else { // Scrolling to previous task view if (mIsRtl) { scrollRightTo(focusedTask); } else { scrollLeftTo(focusedTask); } } if (mViewCallbacks != null) { mViewCallbacks.updateCurrentFocusIndex(toIndex); } } }); focusAnimation.start(); } @Override public boolean dispatchKeyEvent(KeyEvent event) { TestLogging.recordKeyEvent( TestProtocol.SEQUENCE_MAIN, "KeyboardQuickSwitchView key event", event); return super.dispatchKeyEvent(event); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { return (mViewCallbacks != null && mViewCallbacks.onKeyUp(keyCode, event, mIsRtl, mDisplayingRecentTasks)) || super.onKeyUp(keyCode, event); } private void initializeScroll(int index, boolean shouldTruncateTarget) { if (!mDisplayingRecentTasks) { return; } View task = getTaskAt(index); if (task == null) { return; } if (mIsRtl) { scrollLeftTo( task, shouldTruncateTarget, /* smoothScroll= */ false, /* waitForLayout= */ true); } else { scrollRightTo( task, shouldTruncateTarget, /* smoothScroll= */ false, /* waitForLayout= */ true); } } private void scrollRightTo(@NonNull View targetTask) { scrollRightTo( targetTask, /* shouldTruncateTarget= */ false, /* smoothScroll= */ true, /* waitForLayout= */ false); } private void scrollRightTo( @NonNull View targetTask, boolean shouldTruncateTarget, boolean smoothScroll, boolean waitForLayout) { if (!mDisplayingRecentTasks) { return; } if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) { return; } runScrollCommand(waitForLayout, () -> { int scrollTo = targetTask.getLeft() - mSpacing + (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0); // Scroll so that the focused task is to the left of the list if (smoothScroll) { mScrollView.smoothScrollTo(scrollTo, 0); } else { mScrollView.scrollTo(scrollTo, 0); } }); } private void scrollLeftTo(@NonNull View targetTask) { scrollLeftTo( targetTask, /* shouldTruncateTarget= */ false, /* smoothScroll= */ true, /* waitForLayout= */ false); } private void scrollLeftTo( @NonNull View targetTask, boolean shouldTruncateTarget, boolean smoothScroll, boolean waitForLayout) { if (!mDisplayingRecentTasks) { return; } if (smoothScroll && !shouldScroll(targetTask, shouldTruncateTarget)) { return; } runScrollCommand(waitForLayout, () -> { int scrollTo = targetTask.getRight() + mSpacing - mScrollView.getWidth() - (shouldTruncateTarget ? targetTask.getWidth() / 2 : 0); // Scroll so that the focused task is to the right of the list if (smoothScroll) { mScrollView.smoothScrollTo(scrollTo, 0); } else { mScrollView.scrollTo(scrollTo, 0); } }); } private boolean shouldScroll(@NonNull View targetTask, boolean shouldTruncateTarget) { boolean isTargetTruncated = targetTask.getRight() + mSpacing > mScrollView.getScrollX() + mScrollView.getWidth() || Math.max(0, targetTask.getLeft() - mSpacing) < mScrollView.getScrollX(); return isTargetTruncated && !shouldTruncateTarget; } private void runScrollCommand(boolean waitForLayout, @NonNull Runnable scrollCommand) { if (!waitForLayout) { scrollCommand.run(); return; } mScrollView.getViewTreeObserver().addOnGlobalLayoutListener( new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { scrollCommand.run(); mScrollView.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); } @Nullable protected KeyboardQuickSwitchTaskView getTaskAt(int index) { return !mDisplayingRecentTasks || index < 0 || index >= mContent.getChildCount() ? null : (KeyboardQuickSwitchTaskView) mContent.getChildAt(index); } }