Loading quickstep/src/com/android/quickstep/util/SplitAnimationController.kt +213 −85 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.ActivityManager.RunningTaskInfo import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN import android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW import android.content.Context import android.graphics.Bitmap Loading Loading @@ -51,6 +52,7 @@ import com.android.launcher3.anim.PendingAnimation import com.android.launcher3.apppairs.AppPairIcon import com.android.launcher3.config.FeatureFlags import com.android.launcher3.logging.StatsLogManager.EventEnum import com.android.launcher3.model.data.WorkspaceItemInfo import com.android.launcher3.statehandlers.DepthController import com.android.launcher3.statemanager.StateManager import com.android.launcher3.statemanager.StatefulActivity Loading @@ -69,6 +71,7 @@ import com.android.quickstep.views.TaskThumbnailView import com.android.quickstep.views.TaskView import com.android.quickstep.views.TaskView.TaskIdAttributeContainer import com.android.quickstep.views.TaskViewIcon import com.android.wm.shell.shared.TransitionUtil import java.util.Optional import java.util.function.Supplier Loading Loading @@ -532,8 +535,14 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC check(info != null && t != null) { "trying to launch an app pair icon, but encountered an unexpected null" } val appPairLaunchingAppIndex = hasChangesForBothAppPairs(launchingIconView, info) if (appPairLaunchingAppIndex == -1) { // Launch split app pair animation composeIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback) } else { composeFullscreenIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback, appPairLaunchingAppIndex) } } else { // Fallback case: simple fade-in animation check(info != null && t != null) { Loading Loading @@ -597,6 +606,39 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC ) } /** * @return -1 if [transitionInfo] contains both apps of the app pair to be animated, otherwise * the integer index corresponding to [launchingIconView]'s contents for the single app * to be animated */ fun hasChangesForBothAppPairs(launchingIconView: AppPairIcon, transitionInfo: TransitionInfo) : Int { val intent1 = launchingIconView.info.getFirstApp().intent.component?.packageName val intent2 = launchingIconView.info.getSecondApp().intent.component?.packageName var launchFullscreenAppIndex = -1 for (change in transitionInfo.changes) { val taskInfo: RunningTaskInfo = change.taskInfo ?: continue if (TransitionUtil.isOpeningType(change.mode) && taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN) { val baseIntent = taskInfo.baseIntent.component?.packageName if (baseIntent == intent1) { if (launchFullscreenAppIndex > -1) { launchFullscreenAppIndex = -1 break } launchFullscreenAppIndex = 0 } else if (baseIntent == intent2) { if (launchFullscreenAppIndex > -1) { launchFullscreenAppIndex = -1 break } launchFullscreenAppIndex = 1 } } } return launchFullscreenAppIndex } /** * When the user taps an app pair icon to launch split, this will play the tasks' launch * animation from the position of the icon. Loading Loading @@ -632,7 +674,8 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // If launching an app pair from Taskbar inside of an app context (no access to Launcher), // use the scale-up animation if (launchingIconView.context is TaskbarActivityContext) { composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback) composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback, WINDOWING_MODE_MULTI_WINDOW) return } Loading @@ -642,11 +685,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // Create an AnimatorSet that will run both shell and launcher transitions together val launchAnimation = AnimatorSet() val progressUpdater = ValueAnimator.ofFloat(0f, 1f) val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet) progressUpdater.setDuration(timings.getDuration().toLong()) progressUpdater.interpolator = Interpolators.LINEAR var rootCandidate: Change? = null for (change in transitionInfo.changes) { Loading Loading @@ -690,27 +728,13 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // Make sure nothing weird happened, like getChange() returning null. check(rootCandidate != null) { "Failed to find a root leash" } // Shell animation: the apps are revealed toward end of the launch animation progressUpdater.addUpdateListener { valueAnimator: ValueAnimator -> val progress = Interpolators.clampToProgress( Interpolators.LINEAR, valueAnimator.animatedFraction, timings.appRevealStartOffset, timings.appRevealEndOffset ) // Set the alpha of the shell layer (2 apps + divider) t.setAlpha(rootCandidate.leash, progress) t.apply() } // Create a new floating view in Launcher, positioned above the launching icon val drawableArea = launchingIconView.iconDrawableArea val appIcon1 = launchingIconView.info.getFirstApp().newIcon(launchingIconView.context) val appIcon2 = launchingIconView.info.getSecondApp().newIcon(launchingIconView.context) appIcon1.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) appIcon2.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) val floatingView = FloatingAppPairView.getFloatingAppPairView( launcher, Loading @@ -721,7 +745,112 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC ) floatingView.bringToFront() // Launcher animation: animate the floating view, expanding to fill the display surface launchAnimation.play( getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)) launchAnimation.start() } /** * Similar to [composeIconSplitLaunchAnimator], but instructs [FloatingAppPairView] to animate * a single fullscreen icon + background instead of for a pair */ @VisibleForTesting fun composeFullscreenIconSplitLaunchAnimator( launchingIconView: AppPairIcon, transitionInfo: TransitionInfo, t: Transaction, finishCallback: Runnable, launchFullscreenIndex: Int ) { // If launching an app pair from Taskbar inside of an app context (no access to Launcher), // use the scale-up animation if (launchingIconView.context is TaskbarActivityContext) { composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback, WINDOWING_MODE_FULLSCREEN) return } // Else we are in Launcher and can launch with the full icon stretch-and-split animation. val launcher = Launcher.getLauncher(launchingIconView.context) val dp = launcher.deviceProfile // Create an AnimatorSet that will run both shell and launcher transitions together val launchAnimation = AnimatorSet() val appInfo = launchingIconView.info .getContents()[launchFullscreenIndex] as WorkspaceItemInfo val intentToLaunch = appInfo.intent.component?.packageName var rootCandidate: Change? = null for (change in transitionInfo.changes) { val taskInfo: RunningTaskInfo = change.taskInfo ?: continue val baseIntent = taskInfo.baseIntent.component?.packageName if (TransitionUtil.isOpeningType(change.mode) && taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN && baseIntent == intentToLaunch) { rootCandidate = change } } // If we could not find a proper root candidate, something went wrong. check(rootCandidate != null) { "Could not find a split root candidate" } // Recurse up the tree until parent is null, then we've found our root. var parentToken: WindowContainerToken? = rootCandidate.parent while (parentToken != null) { rootCandidate = transitionInfo.getChange(parentToken) ?: break parentToken = rootCandidate.parent } // Make sure nothing weird happened, like getChange() returning null. check(rootCandidate != null) { "Failed to find a root leash" } // Create a new floating view in Launcher, positioned above the launching icon val drawableArea = launchingIconView.iconDrawableArea val appIcon = appInfo.newIcon(launchingIconView.context) appIcon.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) val floatingView = FloatingAppPairView.getFloatingAppPairView( launcher, drawableArea, appIcon, null /*appIcon2*/, 0 /*dividerPos*/ ) floatingView.bringToFront() launchAnimation.play( getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)) launchAnimation.start() } private fun getIconLaunchValueAnimator(t: Transaction, dp: com.android.launcher3.DeviceProfile, finishCallback: Runnable, launcher: Launcher, floatingView: FloatingAppPairView, rootCandidate: Change) : ValueAnimator { val progressUpdater = ValueAnimator.ofFloat(0f, 1f) val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet) progressUpdater.setDuration(timings.getDuration().toLong()) progressUpdater.interpolator = Interpolators.LINEAR // Shell animation: the apps are revealed toward end of the launch animation progressUpdater.addUpdateListener { valueAnimator: ValueAnimator -> val progress = Interpolators.clampToProgress( Interpolators.LINEAR, valueAnimator.animatedFraction, timings.appRevealStartOffset, timings.appRevealEndOffset ) // Set the alpha of the shell layer (2 apps + divider) t.setAlpha(rootCandidate.leash, progress) t.apply() } progressUpdater.addUpdateListener( object : MultiValueUpdateListener() { var mDx = Loading Loading @@ -775,8 +904,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC } } ) // When animation ends, remove the floating view and run finishCallback progressUpdater.addListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { Loading @@ -786,19 +913,21 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC } ) launchAnimation.play(progressUpdater) launchAnimation.start() return progressUpdater } /** * This is a scale-up-and-fade-in animation (34% to 100%) for launching an app in Overview when * there is no visible associated tile to expand from. * [windowingMode] helps determine whether we are looking for a split or a single fullscreen * [Change] */ @VisibleForTesting fun composeScaleUpLaunchAnimation( transitionInfo: TransitionInfo, t: Transaction, finishCallback: Runnable finishCallback: Runnable, windowingMode: Int ) { val launchAnimation = AnimatorSet() val progressUpdater = ValueAnimator.ofFloat(0f, 1f) Loading @@ -812,9 +941,8 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // TODO (b/316490565): Replace this logic when SplitBounds is available to // startAnimation() and we can know the precise taskIds of launching tasks. // Find a change that has WINDOWING_MODE_MULTI_WINDOW. if ( taskInfo.windowingMode == WINDOWING_MODE_MULTI_WINDOW && taskInfo.windowingMode == windowingMode && (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT) ) { // Found one! Loading quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt +16 −15 Original line number Diff line number Diff line Loading @@ -37,17 +37,18 @@ import com.android.systemui.shared.system.QuickStepContract * animation. Consists of a rectangular background that splits into two, and two app icons that * increase in size during the animation. */ class FloatingAppPairBackground( open class FloatingAppPairBackground( context: Context, private val floatingView: FloatingAppPairView, // the view that we will draw this background on // the view that we will draw this background on protected val floatingView: FloatingAppPairView, private val appIcon1: Drawable, private val appIcon2: Drawable, private val appIcon2: Drawable?, dividerPos: Int ) : Drawable() { companion object { // Design specs -- app icons start small and expand during the animation private val STARTING_ICON_SIZE_PX = Utilities.dpToPx(22f) private val ENDING_ICON_SIZE_PX = Utilities.dpToPx(66f) internal val STARTING_ICON_SIZE_PX = Utilities.dpToPx(22f) internal val ENDING_ICON_SIZE_PX = Utilities.dpToPx(66f) // Null values to use with drawDoubleRoundRect(), since there doesn't seem to be any other // API for drawing rectangles with 4 different corner radii. Loading @@ -59,13 +60,13 @@ class FloatingAppPairBackground( private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) // Animation interpolators private val expandXInterpolator: Interpolator private val expandYInterpolator: Interpolator protected val expandXInterpolator: Interpolator protected val expandYInterpolator: Interpolator private val cellSplitInterpolator: Interpolator private val iconFadeInterpolator: Interpolator protected val iconFadeInterpolator: Interpolator // Device-specific measurements private val deviceCornerRadius: Float protected val deviceCornerRadius: Float private val deviceHalfDividerSize: Float private val desiredSplitRatio: Float Loading Loading @@ -217,7 +218,7 @@ class FloatingAppPairBackground( canvas.save() canvas.translate(changingIcon2Left, changingIconTop) canvas.scale(changingIconScaleX, changingIconScaleY) appIcon2.alpha = changingIconAlpha appIcon2!!.alpha = changingIconAlpha appIcon2.draw(canvas) canvas.restore() } Loading Loading @@ -317,7 +318,7 @@ class FloatingAppPairBackground( canvas.save() canvas.translate(changingIconLeft, changingIcon2Top) canvas.scale(changingIconScaleX, changingIconScaleY) appIcon2.alpha = changingIconAlpha appIcon2!!.alpha = changingIconAlpha appIcon2.draw(canvas) canvas.restore() } Loading @@ -330,7 +331,7 @@ class FloatingAppPairBackground( * @param radii An array of 8 radii for the corners: top left x, top left y, top right x, top * right y, bottom right x, and so on. */ private fun drawCustomRoundedRect(c: Canvas, rect: RectF, radii: FloatArray) { protected fun drawCustomRoundedRect(c: Canvas, rect: RectF, radii: FloatArray) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Canvas.drawDoubleRoundRect is supported from Q onward c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, backgroundPaint) Loading quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt +12 −5 Original line number Diff line number Diff line Loading @@ -40,8 +40,8 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att fun getFloatingAppPairView( launcher: StatefulActivity<*>, originalView: View, appIcon1: Drawable, appIcon2: Drawable, appIcon1: Drawable?, appIcon2: Drawable?, dividerPos: Int ): FloatingAppPairView { val dragLayer: ViewGroup = launcher.getDragLayer() Loading @@ -64,8 +64,8 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att fun init( launcher: StatefulActivity<*>, originalView: View, appIcon1: Drawable, appIcon2: Drawable, appIcon1: Drawable?, appIcon2: Drawable?, dividerPos: Int ) { val viewBounds = Rect(0, 0, originalView.width, originalView.height) Loading @@ -92,7 +92,14 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att layoutParams = lp // Prepare to draw app pair icon background background = FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos) background = if (appIcon1 == null || appIcon2 == null) { val iconToAnimate = appIcon1 ?: appIcon2 checkNotNull(iconToAnimate) FloatingFullscreenAppPairBackground(context, this, iconToAnimate, dividerPos) } else { FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos) } background.setBounds(0, 0, lp.width, lp.height) } Loading quickstep/src/com/android/quickstep/views/FloatingFullscreenAppPairBackground.kt 0 → 100644 +95 −0 Original line number Diff line number Diff line /* * 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.views import android.content.Context import android.graphics.Canvas import android.graphics.RectF import android.graphics.drawable.Drawable class FloatingFullscreenAppPairBackground( context: Context, floatingView: FloatingAppPairView, private val iconToLaunch: Drawable, dividerPos: Int) : FloatingAppPairBackground( context, floatingView, iconToLaunch, null /*appIcon2*/, dividerPos ) { /** Animates the background as if launching a fullscreen task. */ override fun draw(canvas: Canvas) { val progress = floatingView.progress // Since the entire floating app pair surface is scaling up during this animation, we // scale down most of these drawn elements so that they appear the proper size on-screen. val scaleFactorX = floatingView.scaleX val scaleFactorY = floatingView.scaleY // Get the bounds where we will draw the background image val width = bounds.width().toFloat() val height = bounds.height().toFloat() // Get device-specific measurements val cornerRadiusX = deviceCornerRadius / scaleFactorX val cornerRadiusY = deviceCornerRadius / scaleFactorY // Draw background drawCustomRoundedRect( canvas, RectF(0f, 0f, width, height), floatArrayOf( cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, ) ) // Calculate changing measurements for icon. val changingIconSizeX = (STARTING_ICON_SIZE_PX + ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) * expandXInterpolator.getInterpolation(progress))) / scaleFactorX val changingIconSizeY = (STARTING_ICON_SIZE_PX + ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) * expandYInterpolator.getInterpolation(progress))) / scaleFactorY val changingIcon1Left = (width / 2f) - (changingIconSizeX / 2f) val changingIconTop = (height / 2f) - (changingIconSizeY / 2f) val changingIconScaleX = changingIconSizeX / iconToLaunch.bounds.width() val changingIconScaleY = changingIconSizeY / iconToLaunch.bounds.height() val changingIconAlpha = (255 - (255 * iconFadeInterpolator.getInterpolation(progress))).toInt() // Draw icon canvas.save() canvas.translate(changingIcon1Left, changingIconTop) canvas.scale(changingIconScaleX, changingIconScaleY) iconToLaunch.alpha = changingIconAlpha iconToLaunch.draw(canvas) canvas.restore() } } No newline at end of file quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt +73 −4 Original line number Diff line number Diff line Loading @@ -17,6 +17,8 @@ package com.android.quickstep.util import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN import android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.view.ContextThemeWrapper Loading @@ -39,8 +41,10 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any import org.mockito.kotlin.doNothing import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.verify Loading Loading @@ -255,6 +259,9 @@ class SplitAnimationControllerTest { doNothing() .whenever(spySplitAnimationController) .composeIconSplitLaunchAnimator(any(), any(), any(), any()) doReturn(-1) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, Loading @@ -276,13 +283,74 @@ class SplitAnimationControllerTest { } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarContextCorrectly() { fun playsAppropriateSplitLaunchAnimation_playsIconFullscreenLaunchCorrectly() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockContextThemeWrapper) doNothing() .whenever(spySplitAnimationController) .composeFullscreenIconSplitLaunchAnimator(any(), any(), any(), any(), any()) doReturn(0) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, taskId, taskId2, null /* apps */, null /* wallpapers */, null /* nonApps */, stateManager, depthController, transitionInfo, transaction, {} /* finishCallback */ ) verify(spySplitAnimationController) .composeFullscreenIconSplitLaunchAnimator(any(), any(), any(), any(), eq(0)) } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarCMultiWindow() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext) doNothing() .whenever(spySplitAnimationController) .composeScaleUpLaunchAnimation(any(), any(), any()) .composeScaleUpLaunchAnimation(any(), any(), any(), any()) doReturn(-1) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, taskId, taskId2, null /* apps */, null /* wallpapers */, null /* nonApps */, stateManager, depthController, transitionInfo, transaction, {} /* finishCallback */ ) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any(), eq(WINDOWING_MODE_MULTI_WINDOW)) } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarFullscreen() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext) doNothing() .whenever(spySplitAnimationController) .composeScaleUpLaunchAnimation(any(), any(), any(), any()) doReturn(0) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, Loading @@ -298,7 +366,8 @@ class SplitAnimationControllerTest { {} /* finishCallback */ ) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any()) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any(), eq(WINDOWING_MODE_FULLSCREEN)) } @Test Loading Loading
quickstep/src/com/android/quickstep/util/SplitAnimationController.kt +213 −85 Original line number Diff line number Diff line Loading @@ -23,6 +23,7 @@ import android.animation.AnimatorSet import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.app.ActivityManager.RunningTaskInfo import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN import android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW import android.content.Context import android.graphics.Bitmap Loading Loading @@ -51,6 +52,7 @@ import com.android.launcher3.anim.PendingAnimation import com.android.launcher3.apppairs.AppPairIcon import com.android.launcher3.config.FeatureFlags import com.android.launcher3.logging.StatsLogManager.EventEnum import com.android.launcher3.model.data.WorkspaceItemInfo import com.android.launcher3.statehandlers.DepthController import com.android.launcher3.statemanager.StateManager import com.android.launcher3.statemanager.StatefulActivity Loading @@ -69,6 +71,7 @@ import com.android.quickstep.views.TaskThumbnailView import com.android.quickstep.views.TaskView import com.android.quickstep.views.TaskView.TaskIdAttributeContainer import com.android.quickstep.views.TaskViewIcon import com.android.wm.shell.shared.TransitionUtil import java.util.Optional import java.util.function.Supplier Loading Loading @@ -532,8 +535,14 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC check(info != null && t != null) { "trying to launch an app pair icon, but encountered an unexpected null" } val appPairLaunchingAppIndex = hasChangesForBothAppPairs(launchingIconView, info) if (appPairLaunchingAppIndex == -1) { // Launch split app pair animation composeIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback) } else { composeFullscreenIconSplitLaunchAnimator(launchingIconView, info, t, finishCallback, appPairLaunchingAppIndex) } } else { // Fallback case: simple fade-in animation check(info != null && t != null) { Loading Loading @@ -597,6 +606,39 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC ) } /** * @return -1 if [transitionInfo] contains both apps of the app pair to be animated, otherwise * the integer index corresponding to [launchingIconView]'s contents for the single app * to be animated */ fun hasChangesForBothAppPairs(launchingIconView: AppPairIcon, transitionInfo: TransitionInfo) : Int { val intent1 = launchingIconView.info.getFirstApp().intent.component?.packageName val intent2 = launchingIconView.info.getSecondApp().intent.component?.packageName var launchFullscreenAppIndex = -1 for (change in transitionInfo.changes) { val taskInfo: RunningTaskInfo = change.taskInfo ?: continue if (TransitionUtil.isOpeningType(change.mode) && taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN) { val baseIntent = taskInfo.baseIntent.component?.packageName if (baseIntent == intent1) { if (launchFullscreenAppIndex > -1) { launchFullscreenAppIndex = -1 break } launchFullscreenAppIndex = 0 } else if (baseIntent == intent2) { if (launchFullscreenAppIndex > -1) { launchFullscreenAppIndex = -1 break } launchFullscreenAppIndex = 1 } } } return launchFullscreenAppIndex } /** * When the user taps an app pair icon to launch split, this will play the tasks' launch * animation from the position of the icon. Loading Loading @@ -632,7 +674,8 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // If launching an app pair from Taskbar inside of an app context (no access to Launcher), // use the scale-up animation if (launchingIconView.context is TaskbarActivityContext) { composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback) composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback, WINDOWING_MODE_MULTI_WINDOW) return } Loading @@ -642,11 +685,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // Create an AnimatorSet that will run both shell and launcher transitions together val launchAnimation = AnimatorSet() val progressUpdater = ValueAnimator.ofFloat(0f, 1f) val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet) progressUpdater.setDuration(timings.getDuration().toLong()) progressUpdater.interpolator = Interpolators.LINEAR var rootCandidate: Change? = null for (change in transitionInfo.changes) { Loading Loading @@ -690,27 +728,13 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // Make sure nothing weird happened, like getChange() returning null. check(rootCandidate != null) { "Failed to find a root leash" } // Shell animation: the apps are revealed toward end of the launch animation progressUpdater.addUpdateListener { valueAnimator: ValueAnimator -> val progress = Interpolators.clampToProgress( Interpolators.LINEAR, valueAnimator.animatedFraction, timings.appRevealStartOffset, timings.appRevealEndOffset ) // Set the alpha of the shell layer (2 apps + divider) t.setAlpha(rootCandidate.leash, progress) t.apply() } // Create a new floating view in Launcher, positioned above the launching icon val drawableArea = launchingIconView.iconDrawableArea val appIcon1 = launchingIconView.info.getFirstApp().newIcon(launchingIconView.context) val appIcon2 = launchingIconView.info.getSecondApp().newIcon(launchingIconView.context) appIcon1.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) appIcon2.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) val floatingView = FloatingAppPairView.getFloatingAppPairView( launcher, Loading @@ -721,7 +745,112 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC ) floatingView.bringToFront() // Launcher animation: animate the floating view, expanding to fill the display surface launchAnimation.play( getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)) launchAnimation.start() } /** * Similar to [composeIconSplitLaunchAnimator], but instructs [FloatingAppPairView] to animate * a single fullscreen icon + background instead of for a pair */ @VisibleForTesting fun composeFullscreenIconSplitLaunchAnimator( launchingIconView: AppPairIcon, transitionInfo: TransitionInfo, t: Transaction, finishCallback: Runnable, launchFullscreenIndex: Int ) { // If launching an app pair from Taskbar inside of an app context (no access to Launcher), // use the scale-up animation if (launchingIconView.context is TaskbarActivityContext) { composeScaleUpLaunchAnimation(transitionInfo, t, finishCallback, WINDOWING_MODE_FULLSCREEN) return } // Else we are in Launcher and can launch with the full icon stretch-and-split animation. val launcher = Launcher.getLauncher(launchingIconView.context) val dp = launcher.deviceProfile // Create an AnimatorSet that will run both shell and launcher transitions together val launchAnimation = AnimatorSet() val appInfo = launchingIconView.info .getContents()[launchFullscreenIndex] as WorkspaceItemInfo val intentToLaunch = appInfo.intent.component?.packageName var rootCandidate: Change? = null for (change in transitionInfo.changes) { val taskInfo: RunningTaskInfo = change.taskInfo ?: continue val baseIntent = taskInfo.baseIntent.component?.packageName if (TransitionUtil.isOpeningType(change.mode) && taskInfo.windowingMode == WINDOWING_MODE_FULLSCREEN && baseIntent == intentToLaunch) { rootCandidate = change } } // If we could not find a proper root candidate, something went wrong. check(rootCandidate != null) { "Could not find a split root candidate" } // Recurse up the tree until parent is null, then we've found our root. var parentToken: WindowContainerToken? = rootCandidate.parent while (parentToken != null) { rootCandidate = transitionInfo.getChange(parentToken) ?: break parentToken = rootCandidate.parent } // Make sure nothing weird happened, like getChange() returning null. check(rootCandidate != null) { "Failed to find a root leash" } // Create a new floating view in Launcher, positioned above the launching icon val drawableArea = launchingIconView.iconDrawableArea val appIcon = appInfo.newIcon(launchingIconView.context) appIcon.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) val floatingView = FloatingAppPairView.getFloatingAppPairView( launcher, drawableArea, appIcon, null /*appIcon2*/, 0 /*dividerPos*/ ) floatingView.bringToFront() launchAnimation.play( getIconLaunchValueAnimator(t, dp, finishCallback, launcher, floatingView, rootCandidate)) launchAnimation.start() } private fun getIconLaunchValueAnimator(t: Transaction, dp: com.android.launcher3.DeviceProfile, finishCallback: Runnable, launcher: Launcher, floatingView: FloatingAppPairView, rootCandidate: Change) : ValueAnimator { val progressUpdater = ValueAnimator.ofFloat(0f, 1f) val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet) progressUpdater.setDuration(timings.getDuration().toLong()) progressUpdater.interpolator = Interpolators.LINEAR // Shell animation: the apps are revealed toward end of the launch animation progressUpdater.addUpdateListener { valueAnimator: ValueAnimator -> val progress = Interpolators.clampToProgress( Interpolators.LINEAR, valueAnimator.animatedFraction, timings.appRevealStartOffset, timings.appRevealEndOffset ) // Set the alpha of the shell layer (2 apps + divider) t.setAlpha(rootCandidate.leash, progress) t.apply() } progressUpdater.addUpdateListener( object : MultiValueUpdateListener() { var mDx = Loading Loading @@ -775,8 +904,6 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC } } ) // When animation ends, remove the floating view and run finishCallback progressUpdater.addListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { Loading @@ -786,19 +913,21 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC } ) launchAnimation.play(progressUpdater) launchAnimation.start() return progressUpdater } /** * This is a scale-up-and-fade-in animation (34% to 100%) for launching an app in Overview when * there is no visible associated tile to expand from. * [windowingMode] helps determine whether we are looking for a split or a single fullscreen * [Change] */ @VisibleForTesting fun composeScaleUpLaunchAnimation( transitionInfo: TransitionInfo, t: Transaction, finishCallback: Runnable finishCallback: Runnable, windowingMode: Int ) { val launchAnimation = AnimatorSet() val progressUpdater = ValueAnimator.ofFloat(0f, 1f) Loading @@ -812,9 +941,8 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC // TODO (b/316490565): Replace this logic when SplitBounds is available to // startAnimation() and we can know the precise taskIds of launching tasks. // Find a change that has WINDOWING_MODE_MULTI_WINDOW. if ( taskInfo.windowingMode == WINDOWING_MODE_MULTI_WINDOW && taskInfo.windowingMode == windowingMode && (change.mode == TRANSIT_OPEN || change.mode == TRANSIT_TO_FRONT) ) { // Found one! Loading
quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt +16 −15 Original line number Diff line number Diff line Loading @@ -37,17 +37,18 @@ import com.android.systemui.shared.system.QuickStepContract * animation. Consists of a rectangular background that splits into two, and two app icons that * increase in size during the animation. */ class FloatingAppPairBackground( open class FloatingAppPairBackground( context: Context, private val floatingView: FloatingAppPairView, // the view that we will draw this background on // the view that we will draw this background on protected val floatingView: FloatingAppPairView, private val appIcon1: Drawable, private val appIcon2: Drawable, private val appIcon2: Drawable?, dividerPos: Int ) : Drawable() { companion object { // Design specs -- app icons start small and expand during the animation private val STARTING_ICON_SIZE_PX = Utilities.dpToPx(22f) private val ENDING_ICON_SIZE_PX = Utilities.dpToPx(66f) internal val STARTING_ICON_SIZE_PX = Utilities.dpToPx(22f) internal val ENDING_ICON_SIZE_PX = Utilities.dpToPx(66f) // Null values to use with drawDoubleRoundRect(), since there doesn't seem to be any other // API for drawing rectangles with 4 different corner radii. Loading @@ -59,13 +60,13 @@ class FloatingAppPairBackground( private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) // Animation interpolators private val expandXInterpolator: Interpolator private val expandYInterpolator: Interpolator protected val expandXInterpolator: Interpolator protected val expandYInterpolator: Interpolator private val cellSplitInterpolator: Interpolator private val iconFadeInterpolator: Interpolator protected val iconFadeInterpolator: Interpolator // Device-specific measurements private val deviceCornerRadius: Float protected val deviceCornerRadius: Float private val deviceHalfDividerSize: Float private val desiredSplitRatio: Float Loading Loading @@ -217,7 +218,7 @@ class FloatingAppPairBackground( canvas.save() canvas.translate(changingIcon2Left, changingIconTop) canvas.scale(changingIconScaleX, changingIconScaleY) appIcon2.alpha = changingIconAlpha appIcon2!!.alpha = changingIconAlpha appIcon2.draw(canvas) canvas.restore() } Loading Loading @@ -317,7 +318,7 @@ class FloatingAppPairBackground( canvas.save() canvas.translate(changingIconLeft, changingIcon2Top) canvas.scale(changingIconScaleX, changingIconScaleY) appIcon2.alpha = changingIconAlpha appIcon2!!.alpha = changingIconAlpha appIcon2.draw(canvas) canvas.restore() } Loading @@ -330,7 +331,7 @@ class FloatingAppPairBackground( * @param radii An array of 8 radii for the corners: top left x, top left y, top right x, top * right y, bottom right x, and so on. */ private fun drawCustomRoundedRect(c: Canvas, rect: RectF, radii: FloatArray) { protected fun drawCustomRoundedRect(c: Canvas, rect: RectF, radii: FloatArray) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Canvas.drawDoubleRoundRect is supported from Q onward c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, backgroundPaint) Loading
quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt +12 −5 Original line number Diff line number Diff line Loading @@ -40,8 +40,8 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att fun getFloatingAppPairView( launcher: StatefulActivity<*>, originalView: View, appIcon1: Drawable, appIcon2: Drawable, appIcon1: Drawable?, appIcon2: Drawable?, dividerPos: Int ): FloatingAppPairView { val dragLayer: ViewGroup = launcher.getDragLayer() Loading @@ -64,8 +64,8 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att fun init( launcher: StatefulActivity<*>, originalView: View, appIcon1: Drawable, appIcon2: Drawable, appIcon1: Drawable?, appIcon2: Drawable?, dividerPos: Int ) { val viewBounds = Rect(0, 0, originalView.width, originalView.height) Loading @@ -92,7 +92,14 @@ class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: Att layoutParams = lp // Prepare to draw app pair icon background background = FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos) background = if (appIcon1 == null || appIcon2 == null) { val iconToAnimate = appIcon1 ?: appIcon2 checkNotNull(iconToAnimate) FloatingFullscreenAppPairBackground(context, this, iconToAnimate, dividerPos) } else { FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos) } background.setBounds(0, 0, lp.width, lp.height) } Loading
quickstep/src/com/android/quickstep/views/FloatingFullscreenAppPairBackground.kt 0 → 100644 +95 −0 Original line number Diff line number Diff line /* * 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.views import android.content.Context import android.graphics.Canvas import android.graphics.RectF import android.graphics.drawable.Drawable class FloatingFullscreenAppPairBackground( context: Context, floatingView: FloatingAppPairView, private val iconToLaunch: Drawable, dividerPos: Int) : FloatingAppPairBackground( context, floatingView, iconToLaunch, null /*appIcon2*/, dividerPos ) { /** Animates the background as if launching a fullscreen task. */ override fun draw(canvas: Canvas) { val progress = floatingView.progress // Since the entire floating app pair surface is scaling up during this animation, we // scale down most of these drawn elements so that they appear the proper size on-screen. val scaleFactorX = floatingView.scaleX val scaleFactorY = floatingView.scaleY // Get the bounds where we will draw the background image val width = bounds.width().toFloat() val height = bounds.height().toFloat() // Get device-specific measurements val cornerRadiusX = deviceCornerRadius / scaleFactorX val cornerRadiusY = deviceCornerRadius / scaleFactorY // Draw background drawCustomRoundedRect( canvas, RectF(0f, 0f, width, height), floatArrayOf( cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, cornerRadiusX, cornerRadiusY, ) ) // Calculate changing measurements for icon. val changingIconSizeX = (STARTING_ICON_SIZE_PX + ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) * expandXInterpolator.getInterpolation(progress))) / scaleFactorX val changingIconSizeY = (STARTING_ICON_SIZE_PX + ((ENDING_ICON_SIZE_PX - STARTING_ICON_SIZE_PX) * expandYInterpolator.getInterpolation(progress))) / scaleFactorY val changingIcon1Left = (width / 2f) - (changingIconSizeX / 2f) val changingIconTop = (height / 2f) - (changingIconSizeY / 2f) val changingIconScaleX = changingIconSizeX / iconToLaunch.bounds.width() val changingIconScaleY = changingIconSizeY / iconToLaunch.bounds.height() val changingIconAlpha = (255 - (255 * iconFadeInterpolator.getInterpolation(progress))).toInt() // Draw icon canvas.save() canvas.translate(changingIcon1Left, changingIconTop) canvas.scale(changingIconScaleX, changingIconScaleY) iconToLaunch.alpha = changingIconAlpha iconToLaunch.draw(canvas) canvas.restore() } } No newline at end of file
quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt +73 −4 Original line number Diff line number Diff line Loading @@ -17,6 +17,8 @@ package com.android.quickstep.util import android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN import android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.view.ContextThemeWrapper Loading @@ -39,8 +41,10 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.eq import org.mockito.kotlin.any import org.mockito.kotlin.doNothing import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.spy import org.mockito.kotlin.verify Loading Loading @@ -255,6 +259,9 @@ class SplitAnimationControllerTest { doNothing() .whenever(spySplitAnimationController) .composeIconSplitLaunchAnimator(any(), any(), any(), any()) doReturn(-1) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, Loading @@ -276,13 +283,74 @@ class SplitAnimationControllerTest { } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarContextCorrectly() { fun playsAppropriateSplitLaunchAnimation_playsIconFullscreenLaunchCorrectly() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockContextThemeWrapper) doNothing() .whenever(spySplitAnimationController) .composeFullscreenIconSplitLaunchAnimator(any(), any(), any(), any(), any()) doReturn(0) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, taskId, taskId2, null /* apps */, null /* wallpapers */, null /* nonApps */, stateManager, depthController, transitionInfo, transaction, {} /* finishCallback */ ) verify(spySplitAnimationController) .composeFullscreenIconSplitLaunchAnimator(any(), any(), any(), any(), eq(0)) } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarCMultiWindow() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext) doNothing() .whenever(spySplitAnimationController) .composeScaleUpLaunchAnimation(any(), any(), any()) .composeScaleUpLaunchAnimation(any(), any(), any(), any()) doReturn(-1) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, taskId, taskId2, null /* apps */, null /* wallpapers */, null /* nonApps */, stateManager, depthController, transitionInfo, transaction, {} /* finishCallback */ ) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any(), eq(WINDOWING_MODE_MULTI_WINDOW)) } @Test fun playsAppropriateSplitLaunchAnimation_playsIconLaunchFromTaskbarFullscreen() { val spySplitAnimationController = spy(splitAnimationController) whenever(mockAppPairIcon.context).thenReturn(mockTaskbarActivityContext) doNothing() .whenever(spySplitAnimationController) .composeScaleUpLaunchAnimation(any(), any(), any(), any()) doReturn(0) .whenever(spySplitAnimationController) .hasChangesForBothAppPairs(any(), any()) spySplitAnimationController.playSplitLaunchAnimation( null /* launchingTaskView */, mockAppPairIcon, Loading @@ -298,7 +366,8 @@ class SplitAnimationControllerTest { {} /* finishCallback */ ) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any()) verify(spySplitAnimationController).composeScaleUpLaunchAnimation(any(), any(), any(), eq(WINDOWING_MODE_FULLSCREEN)) } @Test Loading