From 6bb9d56ded70fed1335b36d3bc5fd99208990936 Mon Sep 17 00:00:00 2001 From: Gustav Sennton Date: Wed, 21 Aug 2024 14:55:38 +0000 Subject: [PATCH] Add animation runner for alt-tab desktop app launch Create a remote transition supporting alt-tab app-launches (app launches caused by a user using alt-tab to select a minimized app) and any minimization caused by hitting the window limit during such an app launch. Test: manual, and WindowAnimatorTest Bug: 349791584 Flag: com.android.window.flags.enable_desktop_app_launch_alttab_transitions Change-Id: I6474fff351f3d7681ca25cd7331e4955e3d1c6e0 --- .../desktop/DesktopAppLaunchTransition.kt | 166 ++++++++++++++++++ .../launcher3/desktop/WindowAnimator.kt | 100 +++++++++++ .../KeyboardQuickSwitchViewController.java | 14 +- .../taskbar/TaskbarActivityContext.java | 23 ++- .../com/android/quickstep/SystemUiProxy.java | 4 +- .../quickstep/desktop/WindowAnimatorTest.kt | 139 +++++++++++++++ 6 files changed, 435 insertions(+), 11 deletions(-) create mode 100644 quickstep/src/com/android/launcher3/desktop/DesktopAppLaunchTransition.kt create mode 100644 quickstep/src/com/android/launcher3/desktop/WindowAnimator.kt create mode 100644 quickstep/tests/src/com/android/quickstep/desktop/WindowAnimatorTest.kt diff --git a/quickstep/src/com/android/launcher3/desktop/DesktopAppLaunchTransition.kt b/quickstep/src/com/android/launcher3/desktop/DesktopAppLaunchTransition.kt new file mode 100644 index 0000000000..dd2ff2d580 --- /dev/null +++ b/quickstep/src/com/android/launcher3/desktop/DesktopAppLaunchTransition.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2024 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.desktop + +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ValueAnimator +import android.content.Context +import android.os.IBinder +import android.view.SurfaceControl.Transaction +import android.view.WindowManager.TRANSIT_OPEN +import android.view.WindowManager.TRANSIT_TO_BACK +import android.view.WindowManager.TRANSIT_TO_FRONT +import android.window.IRemoteTransitionFinishedCallback +import android.window.RemoteTransitionStub +import android.window.TransitionInfo +import android.window.TransitionInfo.Change +import androidx.core.animation.addListener +import com.android.app.animation.Interpolators +import com.android.quickstep.RemoteRunnable +import java.util.concurrent.Executor + +/** + * [android.window.RemoteTransition] for Desktop app launches. + * + * This transition supports minimize-changes, i.e. in a launch-transition, if a window is moved back + * ([android.view.WindowManager.TRANSIT_TO_BACK]) this transition will apply a minimize animation to + * that window. + */ +class DesktopAppLaunchTransition(private val context: Context, private val mainExecutor: Executor) : + RemoteTransitionStub() { + + override fun startAnimation( + token: IBinder, + info: TransitionInfo, + t: Transaction, + transitionFinishedCallback: IRemoteTransitionFinishedCallback, + ) { + val safeTransitionFinishedCallback = RemoteRunnable { + transitionFinishedCallback.onTransitionFinished(/* wct= */ null, /* sct= */ null) + } + mainExecutor.execute { + runAnimators(info, safeTransitionFinishedCallback) + t.apply() + } + } + + private fun runAnimators(info: TransitionInfo, finishedCallback: RemoteRunnable) { + val animators = mutableListOf() + val animatorFinishedCallback: (Animator) -> Unit = { animator -> + animators -= animator + if (animators.isEmpty()) finishedCallback.run() + } + animators += createAnimators(info, animatorFinishedCallback) + animators.forEach { it.start() } + } + + private fun createAnimators( + info: TransitionInfo, + finishCallback: (Animator) -> Unit, + ): List { + val transaction = Transaction() + val launchAnimator = + createLaunchAnimator(getLaunchChange(info), transaction, finishCallback) + val minimizeChange = getMinimizeChange(info) ?: return listOf(launchAnimator) + val minimizeAnimator = createMinimizeAnimator(minimizeChange, transaction, finishCallback) + return listOf(launchAnimator, minimizeAnimator) + } + + private fun getLaunchChange(info: TransitionInfo): Change = + requireNotNull(info.changes.firstOrNull { change -> change.mode in LAUNCH_CHANGE_MODES }) { + "expected an app launch Change" + } + + private fun getMinimizeChange(info: TransitionInfo): Change? = + info.changes.firstOrNull { change -> change.mode == TRANSIT_TO_BACK } + + private fun createLaunchAnimator( + change: Change, + transaction: Transaction, + onAnimFinish: (Animator) -> Unit, + ): Animator { + val boundsAnimator = + WindowAnimator.createBoundsAnimator( + context, + launchBoundsAnimationDef, + change, + transaction, + ) + val alphaAnimator = + ValueAnimator.ofFloat(0f, 1f).apply { + duration = LAUNCH_ANIM_ALPHA_DURATION_MS + interpolator = Interpolators.LINEAR + addUpdateListener { animation -> + transaction.setAlpha(change.leash, animation.animatedValue as Float).apply() + } + } + return AnimatorSet().apply { + playTogether(boundsAnimator, alphaAnimator) + addListener(onEnd = { animation -> onAnimFinish(animation) }) + } + } + + private fun createMinimizeAnimator( + change: Change, + transaction: Transaction, + onAnimFinish: (Animator) -> Unit, + ): Animator { + val boundsAnimator = + WindowAnimator.createBoundsAnimator( + context, + minimizeBoundsAnimationDef, + change, + transaction, + ) + val alphaAnimator = + ValueAnimator.ofFloat(1f, 0f).apply { + duration = MINIMIZE_ANIM_ALPHA_DURATION_MS + interpolator = Interpolators.LINEAR + addUpdateListener { animation -> + transaction.setAlpha(change.leash, animation.animatedValue as Float).apply() + } + } + return AnimatorSet().apply { + playTogether(boundsAnimator, alphaAnimator) + addListener(onEnd = { animation -> onAnimFinish(animation) }) + } + } + + companion object { + private val LAUNCH_CHANGE_MODES = intArrayOf(TRANSIT_OPEN, TRANSIT_TO_FRONT) + + private const val LAUNCH_ANIM_ALPHA_DURATION_MS = 100L + private const val MINIMIZE_ANIM_ALPHA_DURATION_MS = 100L + + private val launchBoundsAnimationDef = + WindowAnimator.BoundsAnimationParams( + durationMs = 300, + startOffsetYDp = 12f, + startScale = 0.97f, + interpolator = Interpolators.STANDARD_DECELERATE, + ) + + private val minimizeBoundsAnimationDef = + WindowAnimator.BoundsAnimationParams( + durationMs = 200, + endOffsetYDp = 12f, + endScale = 0.97f, + interpolator = Interpolators.STANDARD_ACCELERATE, + ) + } +} diff --git a/quickstep/src/com/android/launcher3/desktop/WindowAnimator.kt b/quickstep/src/com/android/launcher3/desktop/WindowAnimator.kt new file mode 100644 index 0000000000..1a99a36b4c --- /dev/null +++ b/quickstep/src/com/android/launcher3/desktop/WindowAnimator.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 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.desktop + +import android.animation.RectEvaluator +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Rect +import android.util.TypedValue +import android.view.SurfaceControl +import android.view.animation.Interpolator +import android.window.TransitionInfo + +/** Creates animations that can be applied to windows/surfaces. */ +object WindowAnimator { + + /** Parameters defining a window bounds animation. */ + data class BoundsAnimationParams( + val durationMs: Long, + val startOffsetYDp: Float = 0f, + val endOffsetYDp: Float = 0f, + val startScale: Float = 1f, + val endScale: Float = 1f, + val interpolator: Interpolator, + ) + + /** + * Creates an animator to reposition and scale the bounds of the leash of the given change. + * + * @param boundsAnimDef the parameters for the animation itself (duration, scale, position) + * @param change the change to which the animation should be applied + * @param transaction the transaction to apply the animation to + */ + fun createBoundsAnimator( + context: Context, + boundsAnimDef: BoundsAnimationParams, + change: TransitionInfo.Change, + transaction: SurfaceControl.Transaction, + ): ValueAnimator { + val startBounds = + createBounds( + context, + change.startAbsBounds, + boundsAnimDef.startScale, + boundsAnimDef.startOffsetYDp, + ) + val leash = change.leash + val endBounds = + createBounds( + context, + change.startAbsBounds, + boundsAnimDef.endScale, + boundsAnimDef.endOffsetYDp, + ) + return ValueAnimator.ofObject(RectEvaluator(), startBounds, endBounds).apply { + duration = boundsAnimDef.durationMs + interpolator = boundsAnimDef.interpolator + addUpdateListener { animation -> + val animBounds = animation.animatedValue as Rect + val animScale = 1 - (1 - boundsAnimDef.endScale) * animation.animatedFraction + transaction + .setPosition(leash, animBounds.left.toFloat(), animBounds.top.toFloat()) + .setScale(leash, animScale, animScale) + .apply() + } + } + } + + private fun createBounds(context: Context, origBounds: Rect, scale: Float, offsetYDp: Float) = + Rect(origBounds).apply { + check(scale in 0.0..1.0) + // Scale the bounds down with an anchor in the center + inset( + (origBounds.width().toFloat() * (1 - scale) / 2).toInt(), + (origBounds.height().toFloat() * (1 - scale) / 2).toInt(), + ) + val offsetYPx = + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + offsetYDp, + context.resources.displayMetrics, + ) + .toInt() + offset(/* dx= */ 0, offsetYPx) + } +} diff --git a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java index 1c8a094c06..a80c11cc17 100644 --- a/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java +++ b/quickstep/src/com/android/launcher3/taskbar/KeyboardQuickSwitchViewController.java @@ -15,6 +15,7 @@ */ package com.android.launcher3.taskbar; +import static com.android.launcher3.util.Executors.MAIN_EXECUTOR; import static com.android.launcher3.util.Executors.UI_HELPER_EXECUTOR; import android.animation.Animator; @@ -31,6 +32,7 @@ import androidx.annotation.Nullable; import com.android.internal.jank.Cuj; import com.android.launcher3.Utilities; import com.android.launcher3.anim.AnimatorListeners; +import com.android.launcher3.desktop.DesktopAppLaunchTransition; import com.android.launcher3.taskbar.overlay.TaskbarOverlayContext; import com.android.launcher3.taskbar.overlay.TaskbarOverlayDragLayer; import com.android.launcher3.views.BaseDragLayer; @@ -41,6 +43,7 @@ import com.android.systemui.shared.recents.model.Task; import com.android.systemui.shared.recents.model.ThumbnailData; import com.android.systemui.shared.system.InteractionJankMonitorWrapper; import com.android.systemui.shared.system.QuickStepContract; +import com.android.window.flags.Flags; import java.io.PrintWriter; import java.util.List; @@ -181,7 +184,7 @@ public class KeyboardQuickSwitchViewController { Runnable onFinishCallback = () -> InteractionJankMonitorWrapper.end( Cuj.CUJ_LAUNCHER_KEYBOARD_QUICK_SWITCH_APP_LAUNCH); TaskbarActivityContext context = mControllers.taskbarActivityContext; - RemoteTransition remoteTransition = new RemoteTransition(new SlideInRemoteTransition( + final RemoteTransition slideInTransition = new RemoteTransition(new SlideInRemoteTransition( Utilities.isRtl(mControllers.taskbarActivityContext.getResources()), context.getDeviceProfile().overviewPageSpacing, QuickStepContract.getWindowCornerRadius(context), @@ -195,7 +198,7 @@ public class KeyboardQuickSwitchViewController { SystemUiProxy.INSTANCE.get(mKeyboardQuickSwitchView.getContext()) .showDesktopApps( mKeyboardQuickSwitchView.getDisplay().getDisplayId(), - remoteTransition)); + slideInTransition)); return -1; } // Even with a valid index, this can be null if the user tries to quick switch before the @@ -208,6 +211,13 @@ public class KeyboardQuickSwitchViewController { // Ignore attempts to run the selected task if it is already running. return -1; } + RemoteTransition remoteTransition = slideInTransition; + if (mOnDesktop && task.task1.isMinimized + && Flags.enableDesktopAppLaunchAlttabTransitions()) { + // This app is being unminimized - use our own transition runner. + remoteTransition = new RemoteTransition( + new DesktopAppLaunchTransition(context, MAIN_EXECUTOR)); + } mControllers.taskbarActivityContext.handleGroupTaskLaunch( task, remoteTransition, diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java index 1b9614a33a..56c00cba92 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java @@ -1234,7 +1234,8 @@ public class TaskbarActivityContext extends BaseTaskbarContext { } } else if (tag instanceof TaskItemInfo info) { UI_HELPER_EXECUTOR.execute(() -> - SystemUiProxy.INSTANCE.get(this).showDesktopApp(info.getTaskId())); + SystemUiProxy.INSTANCE.get(this).showDesktopApp( + info.getTaskId(), /* remoteTransition= */ null)); mControllers.taskbarStashController.updateAndAnimateTransientTaskbar( /* stash= */ true); } else if (tag instanceof WorkspaceItemInfo) { @@ -1325,7 +1326,8 @@ public class TaskbarActivityContext extends BaseTaskbarContext { GroupTask task, @Nullable RemoteTransition remoteTransition, boolean onDesktop) { - handleGroupTaskLaunch(task, remoteTransition, onDesktop, null, null); + handleGroupTaskLaunch(task, remoteTransition, onDesktop, + /* onStartCallback= */ null, /* onFinishCallback= */ null); } /** @@ -1349,17 +1351,24 @@ public class TaskbarActivityContext extends BaseTaskbarContext { UI_HELPER_EXECUTOR.execute(() -> SystemUiProxy.INSTANCE.get(this).showDesktopApps(getDisplay().getDisplayId(), remoteTransition)); - } else if (onDesktop) { + return; + } + if (onDesktop) { + boolean useRemoteTransition = task.task1.isMinimized + && com.android.window.flags.Flags.enableDesktopAppLaunchAlttabTransitions(); UI_HELPER_EXECUTOR.execute(() -> { if (onStartCallback != null) { onStartCallback.run(); } - SystemUiProxy.INSTANCE.get(this).showDesktopApp(task.task1.key.id); + SystemUiProxy.INSTANCE.get(this).showDesktopApp( + task.task1.key.id, useRemoteTransition ? remoteTransition : null); if (onFinishCallback != null) { onFinishCallback.run(); } }); - } else if (task.task2 == null) { + return; + } + if (task.task2 == null) { UI_HELPER_EXECUTOR.execute(() -> { ActivityOptions activityOptions = makeDefaultActivityOptions(SPLASH_SCREEN_STYLE_UNDEFINED).options; @@ -1368,9 +1377,9 @@ public class TaskbarActivityContext extends BaseTaskbarContext { ActivityManagerWrapper.getInstance().startActivityFromRecents( task.task1.key, activityOptions); }); - } else { - mControllers.uiController.launchSplitTasks(task, remoteTransition); + return; } + mControllers.uiController.launchSplitTasks(task, remoteTransition); } /** diff --git a/quickstep/src/com/android/quickstep/SystemUiProxy.java b/quickstep/src/com/android/quickstep/SystemUiProxy.java index 5f028937b0..4721648f62 100644 --- a/quickstep/src/com/android/quickstep/SystemUiProxy.java +++ b/quickstep/src/com/android/quickstep/SystemUiProxy.java @@ -1436,10 +1436,10 @@ public class SystemUiProxy implements ISystemUiProxy, NavHandle, SafeCloseable { /** * If task with the given id is on the desktop, bring it to front */ - public void showDesktopApp(int taskId) { + public void showDesktopApp(int taskId, @Nullable RemoteTransition transition) { if (mDesktopMode != null) { try { - mDesktopMode.showDesktopApp(taskId); + mDesktopMode.showDesktopApp(taskId, transition); } catch (RemoteException e) { Log.w(TAG, "Failed call showDesktopApp", e); } diff --git a/quickstep/tests/src/com/android/quickstep/desktop/WindowAnimatorTest.kt b/quickstep/tests/src/com/android/quickstep/desktop/WindowAnimatorTest.kt new file mode 100644 index 0000000000..e5e6df3b1e --- /dev/null +++ b/quickstep/tests/src/com/android/quickstep/desktop/WindowAnimatorTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 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.quickstep.desktop + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.Resources +import android.graphics.Rect +import android.util.DisplayMetrics +import android.view.SurfaceControl +import android.window.TransitionInfo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.internal.runner.junit4.statement.UiThreadStatement.runOnUiThread +import com.android.app.animation.Interpolators +import com.android.launcher3.desktop.WindowAnimator +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.ArgumentMatchers.anyFloat +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@SmallTest +@RunWith(AndroidJUnit4::class) +class WindowAnimatorTest { + + private val context = mock() + private val resources = mock() + private val transaction = mock() + private val change = mock() + private val leash = mock() + + private val displayMetrics = DisplayMetrics().apply { density = 1f } + + @Before + fun setup() { + whenever(context.resources).thenReturn(resources) + whenever(resources.displayMetrics).thenReturn(displayMetrics) + whenever(change.leash).thenReturn(leash) + whenever(change.startAbsBounds).thenReturn(START_BOUNDS) + whenever(transaction.setPosition(any(), anyFloat(), anyFloat())).thenReturn(transaction) + whenever(transaction.setScale(any(), anyFloat(), anyFloat())).thenReturn(transaction) + } + + @Test + fun createBoundsAnimator_returnsCorrectDefaultAnimatorParams() = runOnUiThread { + val boundsAnimParams = + WindowAnimator.BoundsAnimationParams( + durationMs = 100L, + interpolator = Interpolators.STANDARD_ACCELERATE, + ) + + val valueAnimator = + WindowAnimator.createBoundsAnimator(context, boundsAnimParams, change, transaction) + + assertThat(valueAnimator.duration).isEqualTo(100L) + assertThat(valueAnimator.interpolator).isEqualTo(Interpolators.STANDARD_ACCELERATE) + assertStartAndEndBounds(valueAnimator, startBounds = START_BOUNDS, endBounds = START_BOUNDS) + } + + @Test + fun createBoundsAnimator_startScaleAndOffset_returnsCorrectBounds() = runOnUiThread { + val bounds = Rect(/* left= */ 100, /* top= */ 200, /* right= */ 300, /* bottom= */ 400) + whenever(change.startAbsBounds).thenReturn(bounds) + val boundsAnimParams = + WindowAnimator.BoundsAnimationParams( + durationMs = 100L, + startOffsetYDp = 10f, + startScale = 0.5f, + interpolator = Interpolators.STANDARD_ACCELERATE, + ) + + val valueAnimator = + WindowAnimator.createBoundsAnimator(context, boundsAnimParams, change, transaction) + + assertStartAndEndBounds( + valueAnimator, + startBounds = + Rect(/* left= */ 150, /* top= */ 260, /* right= */ 250, /* bottom= */ 360), + endBounds = bounds, + ) + } + + @Test + fun createBoundsAnimator_endScaleAndOffset_returnsCorrectBounds() = runOnUiThread { + val bounds = Rect(/* left= */ 100, /* top= */ 200, /* right= */ 300, /* bottom= */ 400) + whenever(change.startAbsBounds).thenReturn(bounds) + val boundsAnimParams = + WindowAnimator.BoundsAnimationParams( + durationMs = 100L, + endOffsetYDp = 10f, + endScale = 0.5f, + interpolator = Interpolators.STANDARD_ACCELERATE, + ) + + val valueAnimator = + WindowAnimator.createBoundsAnimator(context, boundsAnimParams, change, transaction) + + assertStartAndEndBounds( + valueAnimator, + startBounds = bounds, + endBounds = Rect(/* left= */ 150, /* top= */ 260, /* right= */ 250, /* bottom= */ 360), + ) + } + + private fun assertStartAndEndBounds( + valueAnimator: ValueAnimator, + startBounds: Rect, + endBounds: Rect, + ) { + valueAnimator.start() + valueAnimator.animatedValue + assertThat(valueAnimator.animatedValue).isEqualTo(startBounds) + valueAnimator.end() + assertThat(valueAnimator.animatedValue).isEqualTo(endBounds) + } + + companion object { + private val START_BOUNDS = + Rect(/* left= */ 10, /* top= */ 20, /* right= */ 30, /* bottom= */ 40) + } +}