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
This commit is contained in:
Gustav Sennton
2024-08-21 14:55:38 +00:00
parent 247bc0461a
commit 6bb9d56ded
6 changed files with 435 additions and 11 deletions

View File

@@ -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<Animator>()
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<Animator> {
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,
)
}
}

View File

@@ -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)
}
}

View File

@@ -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,

View File

@@ -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);
}
/**

View File

@@ -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);
}

View File

@@ -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<Context>()
private val resources = mock<Resources>()
private val transaction = mock<SurfaceControl.Transaction>()
private val change = mock<TransitionInfo.Change>()
private val leash = mock<SurfaceControl>()
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)
}
}