diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java index e106506179..41635b8618 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java @@ -77,6 +77,7 @@ import com.android.launcher3.DeviceProfile; import com.android.launcher3.LauncherSettings.Favorites; import com.android.launcher3.R; import com.android.launcher3.anim.AnimatorPlaybackController; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.dot.DotInfo; import com.android.launcher3.folder.Folder; @@ -998,7 +999,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext { Toast.LENGTH_SHORT).show(); } else { // Else launch the selected app pair - launchFromTaskbarPreservingSplitIfVisible(recents, fi.contents); + launchFromTaskbarPreservingSplitIfVisible(recents, view, fi.contents); mControllers.uiController.onTaskbarIconLaunched(fi); mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true); } @@ -1034,7 +1035,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext { .startShortcut(packageName, id, null, null, info.user); } else { launchFromTaskbarPreservingSplitIfVisible( - recents, Collections.singletonList(info)); + recents, view, Collections.singletonList(info)); } } catch (NullPointerException @@ -1072,7 +1073,8 @@ public class TaskbarActivityContext extends BaseTaskbarContext { // If we are selecting a second app for split, launch the split tasks taskbarUIController.triggerSecondAppForSplit(info, info.intent, view); } else { - launchFromTaskbarPreservingSplitIfVisible(recents, Collections.singletonList(info)); + launchFromTaskbarPreservingSplitIfVisible( + recents, view, Collections.singletonList(info)); } mControllers.uiController.onTaskbarIconLaunched(info); mControllers.taskbarStashController.updateAndAnimateTransientTaskbar(true); @@ -1094,7 +1096,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext { * (potentially breaking a split pair). */ private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents, - List itemInfos) { + @Nullable View launchingIconView, List itemInfos) { if (recents == null) { return; } @@ -1122,8 +1124,7 @@ public class TaskbarActivityContext extends BaseTaskbarContext { if (findExactPairMatch) { // We did not find the app pair we were looking for, so launch one. recents.getSplitSelectController().getAppPairsController().launchAppPair( - (WorkspaceItemInfo) itemInfos.get(0), - (WorkspaceItemInfo) itemInfos.get(1)); + (AppPairIcon) launchingIconView); } else { startItemInfoActivity(itemInfos.get(0)); } diff --git a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java index b685d3c24c..14e258b625 100644 --- a/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java +++ b/quickstep/src/com/android/launcher3/uioverrides/QuickstepLauncher.java @@ -106,6 +106,7 @@ import com.android.launcher3.Workspace; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; import com.android.launcher3.anim.AnimatorPlaybackController; import com.android.launcher3.anim.PendingAnimation; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.appprediction.PredictionRowView; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.desktop.DesktopRecentsTransitionController; @@ -116,7 +117,6 @@ import com.android.launcher3.logging.StatsLogManager.StatsLogger; import com.android.launcher3.model.BgDataModel.FixedContainerItems; import com.android.launcher3.model.WellbeingModel; import com.android.launcher3.model.data.ItemInfo; -import com.android.launcher3.model.data.WorkspaceItemInfo; import com.android.launcher3.popup.SystemShortcut; import com.android.launcher3.proxy.ProxyActivityStarter; import com.android.launcher3.statehandlers.DepthController; @@ -1284,8 +1284,8 @@ public class QuickstepLauncher extends Launcher { /** * Launches two apps as an app pair. */ - public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { - mSplitSelectStateController.getAppPairsController().launchAppPair(app1, app2); + public void launchAppPair(AppPairIcon appPairIcon) { + mSplitSelectStateController.getAppPairsController().launchAppPair(appPairIcon); } public boolean canStartHomeSafely() { diff --git a/quickstep/src/com/android/quickstep/TaskViewUtils.java b/quickstep/src/com/android/quickstep/TaskViewUtils.java index ddddc89a78..11c5ab476a 100644 --- a/quickstep/src/com/android/quickstep/TaskViewUtils.java +++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java @@ -18,8 +18,6 @@ package com.android.quickstep; import static android.view.RemoteAnimationTarget.MODE_CLOSING; import static android.view.RemoteAnimationTarget.MODE_OPENING; import static android.view.WindowManager.LayoutParams.TYPE_DOCK_DIVIDER; -import static android.view.WindowManager.TRANSIT_OPEN; -import static android.view.WindowManager.TRANSIT_TO_FRONT; import static com.android.app.animation.Interpolators.LINEAR; import static com.android.app.animation.Interpolators.TOUCH_RESPONSE; @@ -421,106 +419,34 @@ public final class TaskViewUtils { * Technically this case should be taken care of by * {@link #composeRecentsSplitLaunchAnimatorLegacy} below, but the way we launch tasks whether * it's a single task or multiple tasks results in different entry-points. - * - * If it is null, then it will simply fade in the starting apps and fade out launcher (for the - * case where launcher handles animating starting split tasks from app icon) */ public static void composeRecentsSplitLaunchAnimator(GroupedTaskView launchingTaskView, @NonNull StateManager stateManager, @Nullable DepthController depthController, - int initialTaskId, int secondTaskId, @NonNull TransitionInfo transitionInfo, - SurfaceControl.Transaction t, @NonNull Runnable finishCallback) { - if (launchingTaskView != null) { - AnimatorSet animatorSet = new AnimatorSet(); - animatorSet.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - finishCallback.run(); - } - }); - - final RemoteAnimationTarget[] appTargets = - RemoteAnimationTargetCompat.wrapApps(transitionInfo, t, null /* leashMap */); - final RemoteAnimationTarget[] wallpaperTargets = - RemoteAnimationTargetCompat.wrapNonApps( - transitionInfo, true /* wallpapers */, t, null /* leashMap */); - final RemoteAnimationTarget[] nonAppTargets = - RemoteAnimationTargetCompat.wrapNonApps( - transitionInfo, false /* wallpapers */, t, null /* leashMap */); - final RecentsView recentsView = launchingTaskView.getRecentsView(); - composeRecentsLaunchAnimator(animatorSet, launchingTaskView, - appTargets, wallpaperTargets, nonAppTargets, - true, stateManager, - recentsView, depthController); - - t.apply(); - animatorSet.start(); - return; - } - - TransitionInfo.Change splitRoot1 = null; - TransitionInfo.Change splitRoot2 = null; - final ArrayList openingTargets = new ArrayList<>(); - for (int i = 0; i < transitionInfo.getChanges().size(); ++i) { - final TransitionInfo.Change change = transitionInfo.getChanges().get(i); - if (change.getTaskInfo() == null) { - continue; - } - final int taskId = change.getTaskInfo().taskId; - final int mode = change.getMode(); - - // Find the target tasks' root tasks since those are the split stages that need to - // be animated (the tasks themselves are children and thus inherit animation). - if (taskId == initialTaskId || taskId == secondTaskId) { - if (!(mode == TRANSIT_OPEN || mode == TRANSIT_TO_FRONT)) { - throw new IllegalStateException( - "Expected task to be showing, but it is " + mode); - } - } - if (taskId == initialTaskId) { - splitRoot1 = change.getParent() == null ? change : - transitionInfo.getChange(change.getParent()); - openingTargets.add(splitRoot1.getLeash()); - } - if (taskId == secondTaskId) { - splitRoot2 = change.getParent() == null ? change : - transitionInfo.getChange(change.getParent()); - openingTargets.add(splitRoot2.getLeash()); - } - } - - SurfaceControl.Transaction animTransaction = new SurfaceControl.Transaction(); - ValueAnimator animator = ValueAnimator.ofFloat(0f, 1f); - animator.setDuration(SPLIT_LAUNCH_DURATION); - animator.addUpdateListener(valueAnimator -> { - float progress = valueAnimator.getAnimatedFraction(); - for (SurfaceControl leash: openingTargets) { - animTransaction.setAlpha(leash, progress); - } - animTransaction.apply(); - }); - animator.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationStart(Animator animation) { - for (SurfaceControl leash: openingTargets) { - animTransaction.show(leash) - .setAlpha(leash, 0.0f); - } - animTransaction.apply(); - } - + @NonNull TransitionInfo transitionInfo, SurfaceControl.Transaction t, + @NonNull Runnable finishCallback) { + AnimatorSet animatorSet = new AnimatorSet(); + animatorSet.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { finishCallback.run(); } }); - if (splitRoot1 != null && splitRoot1.getParent() != null) { - // Set the highest level split root alpha; we could technically use the parent of either - // splitRoot1 or splitRoot2 - t.setAlpha(transitionInfo.getChange(splitRoot1.getParent()).getLeash(), 1f); - } + final RemoteAnimationTarget[] appTargets = + RemoteAnimationTargetCompat.wrapApps(transitionInfo, t, null /* leashMap */); + final RemoteAnimationTarget[] wallpaperTargets = + RemoteAnimationTargetCompat.wrapNonApps( + transitionInfo, true /* wallpapers */, t, null /* leashMap */); + final RemoteAnimationTarget[] nonAppTargets = + RemoteAnimationTargetCompat.wrapNonApps( + transitionInfo, false /* wallpapers */, t, null /* leashMap */); + final RecentsView recentsView = launchingTaskView.getRecentsView(); + composeRecentsLaunchAnimator(animatorSet, launchingTaskView, appTargets, wallpaperTargets, + nonAppTargets, /* launcherClosing */ true, stateManager, recentsView, + depthController); + t.apply(); - animator.start(); + animatorSet.start(); } /** diff --git a/quickstep/src/com/android/quickstep/util/AnimUtils.java b/quickstep/src/com/android/quickstep/util/AnimUtils.java index b7b7825d78..7fbbb6e5b8 100644 --- a/quickstep/src/com/android/quickstep/util/AnimUtils.java +++ b/quickstep/src/com/android/quickstep/util/AnimUtils.java @@ -39,4 +39,13 @@ public class AnimUtils { ? SplitAnimationTimings.TABLET_SPLIT_TO_CONFIRM : SplitAnimationTimings.PHONE_SPLIT_TO_CONFIRM; } + + /** + * Fetches device-specific timings for the app pair launch animation. + */ + public static SplitAnimationTimings getDeviceAppPairLaunchTimings(boolean isTablet) { + return isTablet + ? SplitAnimationTimings.TABLET_APP_PAIR_LAUNCH + : SplitAnimationTimings.PHONE_APP_PAIR_LAUNCH; + } } diff --git a/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt new file mode 100644 index 0000000000..086c8af6b4 --- /dev/null +++ b/quickstep/src/com/android/quickstep/util/AppPairLaunchTimings.kt @@ -0,0 +1,72 @@ +/* + * 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.quickstep.util + +import com.android.app.animation.Interpolators + +/** Timings for the app pair launch animation. */ +abstract class AppPairLaunchTimings : SplitAnimationTimings { + protected abstract val STAGED_RECT_SLIDE_DURATION: Int + + // Common timings that apply to app pair launches on any type of device + override fun getStagedRectSlideStart() = 0 + override fun getStagedRectSlideEnd() = stagedRectSlideStart + STAGED_RECT_SLIDE_DURATION + override fun getPlaceholderFadeInStart() = 0 + override fun getPlaceholderFadeInEnd() = 0 + override fun getPlaceholderIconFadeInStart() = 0 + override fun getPlaceholderIconFadeInEnd() = 0 + + private val iconFadeStart: Int + get() = getStagedRectSlideEnd() + private val iconFadeEnd: Int + get() = iconFadeStart + 83 + private val appRevealStart: Int + get() = getStagedRectSlideEnd() + 67 + private val appRevealEnd: Int + get() = appRevealStart + 217 + private val cellSplitStart: Int + get() = (getStagedRectSlideEnd() * 0.83f).toInt() + private val cellSplitEnd: Int + get() = cellSplitStart + 500 + + override fun getStagedRectXInterpolator() = Interpolators.EMPHASIZED_COMPLEMENT + override fun getStagedRectYInterpolator() = Interpolators.EMPHASIZED + override fun getStagedRectScaleXInterpolator() = Interpolators.EMPHASIZED + override fun getStagedRectScaleYInterpolator() = Interpolators.EMPHASIZED + override fun getCellSplitInterpolator() = Interpolators.EMPHASIZED + override fun getIconFadeInterpolator() = Interpolators.LINEAR + + override fun getCellSplitStartOffset(): Float { + return cellSplitStart.toFloat() / getDuration() + } + override fun getCellSplitEndOffset(): Float { + return cellSplitEnd.toFloat() / getDuration() + } + override fun getIconFadeStartOffset(): Float { + return iconFadeStart.toFloat() / getDuration() + } + override fun getIconFadeEndOffset(): Float { + return iconFadeEnd.toFloat() / getDuration() + } + override fun getAppRevealStartOffset(): Float { + return appRevealStart.toFloat() / getDuration() + } + override fun getAppRevealEndOffset(): Float { + return appRevealEnd.toFloat() / getDuration() + } + abstract override fun getDuration(): Int +} diff --git a/quickstep/src/com/android/quickstep/util/AppPairsController.java b/quickstep/src/com/android/quickstep/util/AppPairsController.java index b6a87975ad..3ca2531cd4 100644 --- a/quickstep/src/com/android/quickstep/util/AppPairsController.java +++ b/quickstep/src/com/android/quickstep/util/AppPairsController.java @@ -36,6 +36,7 @@ import com.android.launcher3.LauncherAppState; import com.android.launcher3.LauncherSettings; import com.android.launcher3.R; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.icons.IconCache; import com.android.launcher3.logging.StatsLogManager; import com.android.launcher3.model.data.FolderInfo; @@ -120,7 +121,9 @@ public class AppPairsController { * Launches an app pair by searching the RecentsModel for running instances of each app, and * staging either those running instances or launching the apps as new Intents. */ - public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { + public void launchAppPair(AppPairIcon appPairIcon) { + WorkspaceItemInfo app1 = appPairIcon.getInfo().contents.get(0); + WorkspaceItemInfo app2 = appPairIcon.getInfo().contents.get(1); ComponentKey app1Key = new ComponentKey(app1.getTargetComponent(), app1.user); ComponentKey app2Key = new ComponentKey(app2.getTargetComponent(), app2.user); mSplitSelectStateController.findLastActiveTasksAndRunCallback( @@ -152,6 +155,8 @@ public class AppPairsController { app2.intent, app2.user); } + mSplitSelectStateController.setLaunchingIconView(appPairIcon); + mSplitSelectStateController.launchSplitTasks( AppPairsController.convertRankToSnapPosition(app1.rank)); } diff --git a/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt new file mode 100644 index 0000000000..beab90f15f --- /dev/null +++ b/quickstep/src/com/android/quickstep/util/PhoneAppPairLaunchTimings.kt @@ -0,0 +1,23 @@ +/* + * 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.quickstep.util + +/** Timings for the app pair launch animation on phones. */ +class PhoneAppPairLaunchTimings : AppPairLaunchTimings(), SplitAnimationTimings { + override val STAGED_RECT_SLIDE_DURATION = 500 + override fun getDuration() = SplitAnimationTimings.PHONE_APP_PAIR_LAUNCH_DURATION +} diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt index dfbd32c8f3..ade8074d8b 100644 --- a/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt +++ b/quickstep/src/com/android/quickstep/util/SplitAnimationController.kt @@ -21,20 +21,37 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.AnimatorSet import android.animation.ObjectAnimator +import android.animation.ValueAnimator +import android.app.ActivityManager.RunningTaskInfo import android.graphics.Bitmap import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.Drawable +import android.view.RemoteAnimationTarget +import android.view.SurfaceControl +import android.view.SurfaceControl.Transaction import android.view.View +import android.view.WindowManager +import android.window.TransitionInfo +import android.window.TransitionInfo.Change +import androidx.annotation.VisibleForTesting import com.android.app.animation.Interpolators import com.android.launcher3.DeviceProfile +import com.android.launcher3.Launcher +import com.android.launcher3.QuickstepTransitionManager import com.android.launcher3.Utilities import com.android.launcher3.anim.PendingAnimation +import com.android.launcher3.apppairs.AppPairIcon import com.android.launcher3.config.FeatureFlags +import com.android.launcher3.statehandlers.DepthController +import com.android.launcher3.statemanager.StateManager import com.android.launcher3.statemanager.StatefulActivity import com.android.launcher3.util.SplitConfigurationOptions.SplitSelectSource import com.android.launcher3.views.BaseDragLayer +import com.android.quickstep.TaskViewUtils +import com.android.quickstep.views.FloatingAppPairView import com.android.quickstep.views.FloatingTaskView +import com.android.quickstep.views.GroupedTaskView import com.android.quickstep.views.RecentsView import com.android.quickstep.views.SplitInstructionsView import com.android.quickstep.views.TaskThumbnailView @@ -308,6 +325,407 @@ class SplitAnimationController(val splitSelectStateController: SplitSelectStateC pendingAnimation.buildAnim().start() } + /** + * Called when launching a specific pair of apps, e.g. when tapping a pair of apps in Overview, + * or launching an app pair from its Home icon. Selects the appropriate launch animation and + * plays it. + */ + fun playSplitLaunchAnimation( + launchingTaskView: GroupedTaskView?, + launchingIconView: AppPairIcon?, + initialTaskId: Int, + secondTaskId: Int, + apps: Array?, + wallpapers: Array?, + nonApps: Array?, + stateManager: StateManager<*>, + depthController: DepthController?, + info: TransitionInfo?, + t: Transaction?, + finishCallback: Runnable + ) { + if (info == null && t == null) { + // (Legacy animation) Tapping a split tile in Overview + // TODO (b/315490678): Ensure that this works with app pairs flow + check(apps != null && wallpapers != null && nonApps != null) { + "trying to call composeRecentsSplitLaunchAnimatorLegacy, but encountered an " + + "unexpected null" + } + + composeRecentsSplitLaunchAnimatorLegacy( + launchingTaskView, + initialTaskId, + secondTaskId, + apps, + wallpapers, + nonApps, + stateManager, + depthController, + finishCallback + ) + + return + } + + if (launchingTaskView != null) { + // Tapping a split tile in Overview + check(info != null && t != null) { + "trying to launch a GroupedTaskView, but encountered an unexpected null" + } + + composeRecentsSplitLaunchAnimator( + launchingTaskView, + stateManager, + depthController, + info, + t, + finishCallback + ) + } else if (launchingIconView != null) { + // Tapping an app pair icon + check(info != null && t != null) { + "trying to launch an app pair icon, but encountered an unexpected null" + } + + composeIconSplitLaunchAnimator( + launchingIconView, + initialTaskId, + secondTaskId, + info, + t, + finishCallback + ) + } else { + // Fallback case: simple fade-in animation + check(info != null && t != null) { + "trying to call composeFadeInSplitLaunchAnimator, but encountered an " + + "unexpected null" + } + + composeFadeInSplitLaunchAnimator(initialTaskId, secondTaskId, info, t, finishCallback) + } + } + + /** + * When the user taps a split tile in Overview, this will play the tasks' launch animation from + * the position of the tapped tile. + */ + @VisibleForTesting + fun composeRecentsSplitLaunchAnimator( + launchingTaskView: GroupedTaskView, + stateManager: StateManager<*>, + depthController: DepthController?, + info: TransitionInfo, + t: Transaction, + finishCallback: Runnable + ) { + TaskViewUtils.composeRecentsSplitLaunchAnimator( + launchingTaskView, + stateManager, + depthController, + info, + t, + finishCallback + ) + } + + /** + * LEGACY VERSION: When the user taps a split tile in Overview, this will play the tasks' launch + * animation from the position of the tapped tile. + */ + @VisibleForTesting + fun composeRecentsSplitLaunchAnimatorLegacy( + launchingTaskView: GroupedTaskView?, + initialTaskId: Int, + secondTaskId: Int, + apps: Array, + wallpapers: Array, + nonApps: Array, + stateManager: StateManager<*>, + depthController: DepthController?, + finishCallback: Runnable + ) { + TaskViewUtils.composeRecentsSplitLaunchAnimatorLegacy( + launchingTaskView, + initialTaskId, + secondTaskId, + apps, + wallpapers, + nonApps, + stateManager, + depthController, + finishCallback + ) + } + + /** + * When the user taps an app pair icon to launch split, this will play the tasks' launch + * animation from the position of the icon. + */ + @VisibleForTesting + fun composeIconSplitLaunchAnimator( + launchingIconView: AppPairIcon, + initialTaskId: Int, + secondTaskId: Int, + transitionInfo: TransitionInfo, + t: Transaction, + finishCallback: Runnable + ) { + 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 progressUpdater = ValueAnimator.ofFloat(0f, 1f) + val timings = AnimUtils.getDeviceAppPairLaunchTimings(dp.isTablet) + progressUpdater.setDuration(timings.getDuration().toLong()) + progressUpdater.interpolator = Interpolators.LINEAR + + // Find the root shell leash that we want to fade in (parent of both app windows and + // the divider). For simplicity, we search using the initialTaskId. + var rootShellLayer: SurfaceControl? = null + var dividerPos = 0 + + for (change in transitionInfo.changes) { + val taskInfo: RunningTaskInfo = change.taskInfo ?: continue + val taskId = taskInfo.taskId + val mode = change.mode + + if (taskId == initialTaskId || taskId == secondTaskId) { + check( + mode == WindowManager.TRANSIT_OPEN || mode == WindowManager.TRANSIT_TO_FRONT + ) { + "Expected task to be showing, but it is $mode" + } + } + + if (taskId == initialTaskId) { + var splitRoot1 = change + val parentToken = change.parent + if (parentToken != null) { + splitRoot1 = transitionInfo.getChange(parentToken) ?: change + } + + val topLevelToken = splitRoot1.parent + if (topLevelToken != null) { + rootShellLayer = transitionInfo.getChange(topLevelToken)?.leash + } + + dividerPos = + if (dp.isLeftRightSplit) change.endAbsBounds.right + else change.endAbsBounds.bottom + } + } + + check(rootShellLayer != null) { + "Could not find a TransitionInfo.Change matching the initialTaskId" + } + + // 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(rootShellLayer, progress) + t.apply() + } + + // Create a new floating view in Launcher, positioned above the launching icon + val drawableArea = launchingIconView.iconDrawableArea + val appIcon1 = launchingIconView.info.contents[0].newIcon(launchingIconView.context) + val appIcon2 = launchingIconView.info.contents[1].newIcon(launchingIconView.context) + appIcon1.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) + appIcon2.setBounds(0, 0, dp.iconSizePx, dp.iconSizePx) + val floatingView = + FloatingAppPairView.getFloatingAppPairView( + launcher, + drawableArea, + appIcon1, + appIcon2, + dividerPos + ) + + // Launcher animation: animate the floating view, expanding to fill the display surface + progressUpdater.addUpdateListener( + object : MultiValueUpdateListener() { + var mDx = + FloatProp( + floatingView.startingPosition.left, + dp.widthPx / 2f - floatingView.startingPosition.width() / 2f, + 0f /* delay */, + timings.getDuration().toFloat(), + Interpolators.clampToProgress( + timings.getStagedRectXInterpolator(), + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + ) + var mDy = + FloatProp( + floatingView.startingPosition.top, + dp.heightPx / 2f - floatingView.startingPosition.height() / 2f, + 0f /* delay */, + timings.getDuration().toFloat(), + Interpolators.clampToProgress( + Interpolators.EMPHASIZED, + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + ) + var mScaleX = + FloatProp( + 1f /* start */, + dp.widthPx / floatingView.startingPosition.width(), + 0f /* delay */, + timings.getDuration().toFloat(), + Interpolators.clampToProgress( + Interpolators.EMPHASIZED, + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + ) + var mScaleY = + FloatProp( + 1f /* start */, + dp.heightPx / floatingView.startingPosition.height(), + 0f /* delay */, + timings.getDuration().toFloat(), + Interpolators.clampToProgress( + Interpolators.EMPHASIZED, + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + ) + + override fun onUpdate(percent: Float, initOnly: Boolean) { + floatingView.progress = percent + floatingView.x = mDx.value + floatingView.y = mDy.value + floatingView.scaleX = mScaleX.value + floatingView.scaleY = mScaleY.value + floatingView.invalidate() + } + } + ) + + // When animation ends, remove the floating view and run finishCallback + progressUpdater.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + safeRemoveViewFromDragLayer(launcher, floatingView) + finishCallback.run() + } + } + ) + + launchAnimation.play(progressUpdater) + launchAnimation.start() + } + + /** + * If we are launching split screen without any special animation from a starting View, we + * simply fade in the starting apps and fade out launcher. + */ + @VisibleForTesting + fun composeFadeInSplitLaunchAnimator( + initialTaskId: Int, + secondTaskId: Int, + transitionInfo: TransitionInfo, + t: Transaction, + finishCallback: Runnable + ) { + var splitRoot1: Change? = null + var splitRoot2: Change? = null + val openingTargets = ArrayList() + for (change in transitionInfo.changes) { + val taskInfo: RunningTaskInfo = change.taskInfo ?: continue + val taskId = taskInfo.taskId + val mode = change.mode + + // Find the target tasks' root tasks since those are the split stages that need to + // be animated (the tasks themselves are children and thus inherit animation). + if (taskId == initialTaskId || taskId == secondTaskId) { + check( + mode == WindowManager.TRANSIT_OPEN || mode == WindowManager.TRANSIT_TO_FRONT + ) { + "Expected task to be showing, but it is $mode" + } + } + + if (taskId == initialTaskId) { + splitRoot1 = change + val parentToken1 = change.parent + if (parentToken1 != null) { + splitRoot1 = transitionInfo.getChange(parentToken1) ?: change + } + + if (splitRoot1?.leash != null) { + openingTargets.add(splitRoot1.leash) + } + } + + if (taskId == secondTaskId) { + splitRoot2 = change + val parentToken2 = change.parent + if (parentToken2 != null) { + splitRoot2 = transitionInfo.getChange(parentToken2) ?: change + } + + if (splitRoot2?.leash != null) { + openingTargets.add(splitRoot2.leash) + } + } + } + + val animTransaction = Transaction() + val animator = ValueAnimator.ofFloat(0f, 1f) + animator.setDuration(QuickstepTransitionManager.SPLIT_LAUNCH_DURATION.toLong()) + animator.addUpdateListener { valueAnimator: ValueAnimator -> + val progress = valueAnimator.animatedFraction + for (leash in openingTargets) { + animTransaction.setAlpha(leash, progress) + } + animTransaction.apply() + } + + animator.addListener( + object : AnimatorListenerAdapter() { + override fun onAnimationStart(animation: Animator) { + for (leash in openingTargets) { + animTransaction.show(leash).setAlpha(leash, 0.0f) + } + animTransaction.apply() + } + + override fun onAnimationEnd(animation: Animator) { + finishCallback.run() + } + } + ) + + if (splitRoot1 != null) { + // Set the highest level split root alpha; we could technically use the parent of + // either splitRoot1 or splitRoot2 + val parentToken = splitRoot1.parent + var rootLayer: Change? = null + if (parentToken != null) { + rootLayer = transitionInfo.getChange(parentToken) + } + if (rootLayer != null && rootLayer.leash != null) { + t.setAlpha(rootLayer.leash, 1f) + } + } + + t.apply() + animator.start() + } + private fun safeRemoveViewFromDragLayer(launcher: StatefulActivity<*>, view: View?) { if (view != null) { launcher.dragLayer.removeView(view) diff --git a/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java b/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java index 93f2255a4d..b618546576 100644 --- a/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java +++ b/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java @@ -21,31 +21,44 @@ import static com.android.app.animation.Interpolators.LINEAR; import android.view.animation.Interpolator; /** - * An interface that supports the centralization of timing information for splitscreen animations. + * Organizes timing information for split screen animations. */ public interface SplitAnimationTimings { + /** Total duration (ms) for initiating split screen (staging the first app) on tablets. */ int TABLET_ENTER_DURATION = 866; + /** Total duration (ms) for confirming split screen (selecting the second app) on tablets. */ int TABLET_CONFIRM_DURATION = 500; - + /** Total duration (ms) for initiating split screen (staging the first app) on phones. */ int PHONE_ENTER_DURATION = 517; + /** Total duration (ms) for confirming split screen (selecting the second app) on phones. */ int PHONE_CONFIRM_DURATION = 333; - + /** Total duration (ms) for aborting split screen (before selecting the second app). */ int ABORT_DURATION = 500; + /** Total duration (ms) for launching an app pair from its icon on tablets. */ + int TABLET_APP_PAIR_LAUNCH_DURATION = 998; + /** Total duration (ms) for launching an app pair from its icon on phones. */ + int PHONE_APP_PAIR_LAUNCH_DURATION = 915; + // Initialize timing classes so they can be accessed statically SplitAnimationTimings TABLET_OVERVIEW_TO_SPLIT = new TabletOverviewToSplitTimings(); SplitAnimationTimings TABLET_HOME_TO_SPLIT = new TabletHomeToSplitTimings(); SplitAnimationTimings TABLET_SPLIT_TO_CONFIRM = new TabletSplitToConfirmTimings(); - SplitAnimationTimings PHONE_OVERVIEW_TO_SPLIT = new PhoneOverviewToSplitTimings(); SplitAnimationTimings PHONE_SPLIT_TO_CONFIRM = new PhoneSplitToConfirmTimings(); + SplitAnimationTimings TABLET_APP_PAIR_LAUNCH = new TabletAppPairLaunchTimings(); + SplitAnimationTimings PHONE_APP_PAIR_LAUNCH = new PhoneAppPairLaunchTimings(); - // Shared methods + // Shared methods: all split animations have these parameters int getDuration(); + /** Start fading in the floating view tile at this time (in ms). */ int getPlaceholderFadeInStart(); int getPlaceholderFadeInEnd(); + /** Start fading in the app icon at this time (in ms). */ int getPlaceholderIconFadeInStart(); int getPlaceholderIconFadeInEnd(); + /** Start translating the floating view tile at this time (in ms). */ int getStagedRectSlideStart(); + /** The floating tile has reached its final position at this time (in ms). */ int getStagedRectSlideEnd(); Interpolator getStagedRectXInterpolator(); Interpolator getStagedRectYInterpolator(); @@ -70,6 +83,11 @@ public interface SplitAnimationTimings { return (float) getStagedRectSlideEnd() / getDuration(); } + // DEFAULT VALUES: We define default values here so that SplitAnimationTimings can be used + // flexibly in animation-running functions, e.g. a single function that handles 2 types of split + // animations. The values are not intended to be used, and can safely be removed if refactoring + // these classes. + // Defaults for OverviewToSplit default float getGridSlideStartOffset() { return 0; } default float getGridSlideStaggerOffset() { return 0; } @@ -94,5 +112,13 @@ public interface SplitAnimationTimings { // Defaults for SplitToConfirm default float getInstructionsFadeStartOffset() { return 0; } default float getInstructionsFadeEndOffset() { return 0; } + + // Defaults for AppPair + default float getCellSplitStartOffset() { return 0; } + default float getCellSplitEndOffset() { return 0; } + default float getAppRevealStartOffset() { return 0; } + default float getAppRevealEndOffset() { return 0; } + default Interpolator getCellSplitInterpolator() { return LINEAR; } + default Interpolator getIconFadeInterpolator() { return LINEAR; } } diff --git a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt index 596bb47861..38bbe601b6 100644 --- a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt +++ b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt @@ -56,4 +56,4 @@ class SplitScreenUtils { } } } -} \ No newline at end of file +} diff --git a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java index 24d6d274d4..d5899e47f4 100644 --- a/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java +++ b/quickstep/src/com/android/quickstep/util/SplitSelectStateController.java @@ -71,6 +71,7 @@ import com.android.internal.logging.InstanceId; import com.android.launcher3.Launcher; import com.android.launcher3.R; import com.android.launcher3.anim.PendingAnimation; +import com.android.launcher3.apppairs.AppPairIcon; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.icons.IconProvider; import com.android.launcher3.logging.StatsLogManager; @@ -92,7 +93,6 @@ import com.android.quickstep.RecentsModel; import com.android.quickstep.SplitSelectionListener; import com.android.quickstep.SystemUiProxy; import com.android.quickstep.TaskAnimationManager; -import com.android.quickstep.TaskViewUtils; import com.android.quickstep.views.FloatingTaskView; import com.android.quickstep.views.GroupedTaskView; import com.android.quickstep.views.RecentsView; @@ -141,6 +141,8 @@ public class SplitSelectStateController { /** If not null, this is the TaskView we want to launch from */ @Nullable private GroupedTaskView mLaunchingTaskView; + /** If not null, this is the icon we want to launch from */ + private AppPairIcon mLaunchingIconView; /** True when the first selected split app is being launched in fullscreen. */ private boolean mLaunchingFirstAppFullscreen; @@ -664,9 +666,17 @@ public class SplitSelectStateController { // Only animate from taskView if it's already visible boolean shouldLaunchFromTaskView = mLaunchingTaskView != null && mLaunchingTaskView.getRecentsView().isTaskViewVisible(mLaunchingTaskView); - TaskViewUtils.composeRecentsSplitLaunchAnimator(shouldLaunchFromTaskView - ? mLaunchingTaskView : null, mStateManager, - mDepthController, mInitialTaskId, mSecondTaskId, info, t, () -> { + mSplitAnimationController.playSplitLaunchAnimation( + shouldLaunchFromTaskView ? mLaunchingTaskView : null, + mLaunchingIconView, + mInitialTaskId, + mSecondTaskId, + null /* apps */, + null /* wallpapers */, + null /* nonApps */, + mStateManager, + mDepthController, + info, t, () -> { finishAdapter.run(); cleanup(true /*success*/); }); @@ -722,9 +732,10 @@ public class SplitSelectStateController { RemoteAnimationTarget[] wallpapers, RemoteAnimationTarget[] nonApps, Runnable finishedCallback) { postAsyncCallback(mHandler, - () -> TaskViewUtils.composeRecentsSplitLaunchAnimatorLegacy( - mLaunchingTaskView, mInitialTaskId, mSecondTaskId, apps, wallpapers, - nonApps, mStateManager, mDepthController, () -> { + () -> mSplitAnimationController.playSplitLaunchAnimation(mLaunchingTaskView, + mLaunchingIconView, mInitialTaskId, mSecondTaskId, apps, wallpapers, + nonApps, mStateManager, mDepthController, null /* info */, null /* t */, + () -> { finishedCallback.run(); if (mSuccessCallback != null) { mSuccessCallback.accept(true); @@ -757,6 +768,7 @@ public class SplitSelectStateController { dispatchOnSplitSelectionExit(); mRecentsAnimationRunning = false; mLaunchingTaskView = null; + mLaunchingIconView = null; mAnimateCurrentTaskDismissal = false; mDismissingFromSplitPair = false; mFirstFloatingTaskView = null; @@ -817,6 +829,10 @@ public class SplitSelectStateController { return mAppPairsController; } + public void setLaunchingIconView(AppPairIcon launchingIconView) { + mLaunchingIconView = launchingIconView; + } + public BackPressHandler getSplitBackHandler() { return mSplitBackHandler; } diff --git a/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt b/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt new file mode 100644 index 0000000000..fb2d63f1ad --- /dev/null +++ b/quickstep/src/com/android/quickstep/util/TabletAppPairLaunchTimings.kt @@ -0,0 +1,23 @@ +/* + * 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.quickstep.util + +/** Timings for the app pair launch animation on tablets. */ +class TabletAppPairLaunchTimings : AppPairLaunchTimings(), SplitAnimationTimings { + override val STAGED_RECT_SLIDE_DURATION = 600 + override fun getDuration() = SplitAnimationTimings.TABLET_APP_PAIR_LAUNCH_DURATION +} diff --git a/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt b/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt new file mode 100644 index 0000000000..3a5873ba33 --- /dev/null +++ b/quickstep/src/com/android/quickstep/views/FloatingAppPairBackground.kt @@ -0,0 +1,358 @@ +/* + * 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.quickstep.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.os.Build +import android.view.animation.Interpolator +import com.android.app.animation.Interpolators +import com.android.launcher3.Launcher +import com.android.launcher3.R +import com.android.launcher3.Utilities +import com.android.quickstep.util.AnimUtils +import com.android.systemui.shared.system.QuickStepContract + +/** + * A Drawable that is drawn onto [FloatingAppPairView] every frame during the app pair launch + * animation. Consists of a rectangular background that splits into two, and two app icons that + * increase in size during the animation. + */ +class FloatingAppPairBackground( + context: Context, + private val floatingView: FloatingAppPairView, // the view that we will draw this background on + private val appIcon1: 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) + + // Null values to use with drawDoubleRoundRect(), since there doesn't seem to be any other + // API for drawing rectangles with 4 different corner radii. + private val EMPTY_RECT = RectF() + private val ARRAY_OF_ZEROES = FloatArray(8) + } + + private val launcher: Launcher + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Animation interpolators + private val expandXInterpolator: Interpolator + private val expandYInterpolator: Interpolator + private val cellSplitInterpolator: Interpolator + private val iconFadeInterpolator: Interpolator + + // Device-specific measurements + private val deviceCornerRadius: Float + private val deviceHalfDividerSize: Float + private val desiredSplitRatio: Float + + init { + launcher = Launcher.getLauncher(context) + val dp = launcher.deviceProfile + // Set up background paint color + val ta = context.theme.obtainStyledAttributes(R.styleable.FolderIconPreview) + backgroundPaint.style = Paint.Style.FILL + backgroundPaint.color = ta.getColor(R.styleable.FolderIconPreview_folderPreviewColor, 0) + ta.recycle() + // Set up timings and interpolators + val timings = AnimUtils.getDeviceAppPairLaunchTimings(launcher.deviceProfile.isTablet) + expandXInterpolator = + Interpolators.clampToProgress( + timings.getStagedRectScaleXInterpolator(), + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + expandYInterpolator = + Interpolators.clampToProgress( + timings.getStagedRectScaleYInterpolator(), + timings.stagedRectSlideStartOffset, + timings.stagedRectSlideEndOffset + ) + cellSplitInterpolator = + Interpolators.clampToProgress( + timings.cellSplitInterpolator, + timings.cellSplitStartOffset, + timings.cellSplitEndOffset + ) + iconFadeInterpolator = + Interpolators.clampToProgress( + timings.iconFadeInterpolator, + timings.iconFadeStartOffset, + timings.iconFadeEndOffset + ) + + // Find device-specific measurements + deviceCornerRadius = QuickStepContract.getWindowCornerRadius(launcher) + deviceHalfDividerSize = + launcher.resources.getDimensionPixelSize(R.dimen.multi_window_task_divider_size) / 2f + val dividerCenterPos = dividerPos + deviceHalfDividerSize + desiredSplitRatio = + if (dp.isLeftRightSplit) dividerCenterPos / dp.widthPx + else dividerCenterPos / dp.heightPx + } + + override fun draw(canvas: Canvas) { + if (launcher.deviceProfile.isLandscape) { + drawLeftRightSplit(canvas) + } else { + drawTopBottomSplit(canvas) + } + } + + /** When device is in landscape, we draw the rectangles with a left-right split. */ + private fun drawLeftRightSplit(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 + val halfDividerSize = deviceHalfDividerSize / scaleFactorX + + // Calculate changing measurements for background + // We add one pixel to some measurements to create a smooth edge with no gaps + val onePixel = 1f / scaleFactorX + val changingDividerSize = + (cellSplitInterpolator.getInterpolation(progress) * halfDividerSize) - onePixel + val changingInnerRadiusX = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusX + val changingInnerRadiusY = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusY + val dividerCenterPos = width * desiredSplitRatio + + // The left half of the background image + val leftSide = RectF( + 0f, + 0f, + dividerCenterPos - changingDividerSize, + height + ) + // The right half of the background image + val rightSide = RectF( + dividerCenterPos + changingDividerSize, + 0f, + width, + height + ) + + // Draw background + drawCustomRoundedRect( + canvas, + leftSide, + floatArrayOf( + cornerRadiusX, cornerRadiusY, + changingInnerRadiusX, changingInnerRadiusY, + changingInnerRadiusX, changingInnerRadiusY, + cornerRadiusX, cornerRadiusY + ) + ) + drawCustomRoundedRect( + canvas, + rightSide, + floatArrayOf( + changingInnerRadiusX, changingInnerRadiusY, + cornerRadiusX, cornerRadiusY, + cornerRadiusX, cornerRadiusY, + changingInnerRadiusX, changingInnerRadiusY + ) + ) + + // Calculate changing measurements for icons. + 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 - halfDividerSize) / 2f) - (changingIconSizeX / 2f) + val changingIcon2Left = + (width - ((width / 2f - halfDividerSize) / 2f)) - (changingIconSizeX / 2f) + val changingIconTop = (height / 2f) - (changingIconSizeY / 2f) + val changingIconScaleX = changingIconSizeX / appIcon1.bounds.width() + val changingIconScaleY = changingIconSizeY / appIcon1.bounds.height() + val changingIconAlpha = + (255 - (255 * iconFadeInterpolator.getInterpolation(progress))).toInt() + + // Draw first icon + canvas.save() + canvas.translate(changingIcon1Left, changingIconTop) + canvas.scale(changingIconScaleX, changingIconScaleY) + appIcon1.alpha = changingIconAlpha + appIcon1.draw(canvas) + canvas.restore() + + // Draw second icon + canvas.save() + canvas.translate(changingIcon2Left, changingIconTop) + canvas.scale(changingIconScaleX, changingIconScaleY) + appIcon2.alpha = changingIconAlpha + appIcon2.draw(canvas) + canvas.restore() + } + + /** When device is in portrait, we draw the rectangles with a top-bottom split. */ + private fun drawTopBottomSplit(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 + val halfDividerSize = deviceHalfDividerSize / scaleFactorY + + // Calculate changing measurements for background + // We add one pixel to some measurements to create a smooth edge with no gaps + val onePixel = 1f / scaleFactorY + val changingDividerSize = + (cellSplitInterpolator.getInterpolation(progress) * halfDividerSize) - onePixel + val changingInnerRadiusX = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusX + val changingInnerRadiusY = cellSplitInterpolator.getInterpolation(progress) * cornerRadiusY + val dividerCenterPos = height * desiredSplitRatio + + // The top half of the background image + val topSide = RectF( + 0f, + 0f, + width, + dividerCenterPos - changingDividerSize + ) + // The bottom half of the background image + val bottomSide = RectF( + 0f, + dividerCenterPos + changingDividerSize, + width, + height + ) + + // Draw background + drawCustomRoundedRect( + canvas, + topSide, + floatArrayOf( + cornerRadiusX, cornerRadiusY, + cornerRadiusX, cornerRadiusY, + changingInnerRadiusX, changingInnerRadiusY, + changingInnerRadiusX, changingInnerRadiusY + ) + ) + drawCustomRoundedRect( + canvas, + bottomSide, + floatArrayOf( + changingInnerRadiusX, changingInnerRadiusY, + changingInnerRadiusX, changingInnerRadiusY, + cornerRadiusX, cornerRadiusY, + cornerRadiusX, cornerRadiusY + ) + ) + + // Calculate changing measurements for icons. + 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 changingIconLeft = (width / 2f) - (changingIconSizeX / 2f) + val changingIcon1Top = (((height / 2f) - halfDividerSize) / 2f) - (changingIconSizeY / 2f) + val changingIcon2Top = + (height - (((height / 2f) - halfDividerSize) / 2f)) - (changingIconSizeY / 2f) + val changingIconScaleX = changingIconSizeX / appIcon1.bounds.width() + val changingIconScaleY = changingIconSizeY / appIcon1.bounds.height() + val changingIconAlpha = + (255 - 255 * iconFadeInterpolator.getInterpolation(progress)).toInt() + + // Draw first icon + canvas.save() + canvas.translate(changingIconLeft, changingIcon1Top) + canvas.scale(changingIconScaleX, changingIconScaleY) + appIcon1.alpha = changingIconAlpha + appIcon1.draw(canvas) + canvas.restore() + + // Draw second icon + canvas.save() + canvas.translate(changingIconLeft, changingIcon2Top) + canvas.scale(changingIconScaleX, changingIconScaleY) + appIcon2.alpha = changingIconAlpha + appIcon2.draw(canvas) + canvas.restore() + } + + /** + * Draws a rectangle with custom rounded corners. + * + * @param c The Canvas to draw on. + * @param rect The bounds of the rectangle. + * @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) { + 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) + } else { + // Fallback rectangle with uniform rounded corners + val scaleFactorX = floatingView.scaleX + val scaleFactorY = floatingView.scaleY + val cornerRadiusX = QuickStepContract.getWindowCornerRadius(launcher) / scaleFactorX + val cornerRadiusY = QuickStepContract.getWindowCornerRadius(launcher) / scaleFactorY + c.drawRoundRect(rect, cornerRadiusX, cornerRadiusY, backgroundPaint) + } + } + + override fun getOpacity(): Int { + return PixelFormat.OPAQUE + } + + override fun setAlpha(i: Int) { + // Required by Drawable but not used. + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + // Required by Drawable but not used. + } +} diff --git a/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt b/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt new file mode 100644 index 0000000000..e90aa13d97 --- /dev/null +++ b/quickstep/src/com/android/quickstep/views/FloatingAppPairView.kt @@ -0,0 +1,103 @@ +/* + * 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.quickstep.views + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import com.android.launcher3.R +import com.android.launcher3.Utilities +import com.android.launcher3.statemanager.StatefulActivity +import com.android.launcher3.views.BaseDragLayer + +/** + * A temporary View that is created for the app pair launch animation and destroyed at the end. + * Matches the size & position of the app pair icon graphic, and expands to full screen. + */ +class FloatingAppPairView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : + FrameLayout(context, attrs) { + companion object { + fun getFloatingAppPairView( + launcher: StatefulActivity<*>, + originalView: View, + appIcon1: Drawable, + appIcon2: Drawable, + dividerPos: Int + ): FloatingAppPairView { + val dragLayer: ViewGroup = launcher.getDragLayer() + val floatingView = + launcher + .getLayoutInflater() + .inflate(R.layout.floating_app_pair_view, dragLayer, false) + as FloatingAppPairView + floatingView.init(launcher, originalView, appIcon1, appIcon2, dividerPos) + dragLayer.addView(floatingView, dragLayer.childCount - 1) + return floatingView + } + } + + val startingPosition = RectF() + private lateinit var background: FloatingAppPairBackground + var progress = 0f + + /** Initializes the view, copying the bounds and location of the original icon view. */ + fun init( + launcher: StatefulActivity<*>, + originalView: View, + appIcon1: Drawable, + appIcon2: Drawable, + dividerPos: Int + ) { + val viewBounds = Rect(0, 0, originalView.width, originalView.height) + Utilities.getBoundsForViewInDragLayer( + launcher.getDragLayer(), + originalView, + viewBounds, + false /* ignoreTransform */, + null /* recycle */, + startingPosition + ) + val lp = + BaseDragLayer.LayoutParams( + Math.round(startingPosition.width()), + Math.round(startingPosition.height()) + ) + lp.ignoreInsets = true + + // Position the floating view exactly on top of the original + lp.topMargin = Math.round(startingPosition.top) + lp.leftMargin = Math.round(startingPosition.left) + + layout(lp.leftMargin, lp.topMargin, lp.leftMargin + lp.width, lp.topMargin + lp.height) + layoutParams = lp + + // Prepare to draw app pair icon background + background = FloatingAppPairBackground(context, this, appIcon1, appIcon2, dividerPos) + background.setBounds(0, 0, lp.width, lp.height) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + background.draw(canvas) + } +} diff --git a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt index 50803fe3f4..86018b1783 100644 --- a/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt +++ b/quickstep/tests/src/com/android/quickstep/util/SplitAnimationControllerTest.kt @@ -19,8 +19,13 @@ package com.android.quickstep.util import android.graphics.Bitmap import android.graphics.drawable.Drawable +import android.view.SurfaceControl.Transaction import android.view.View +import android.window.TransitionInfo import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.launcher3.apppairs.AppPairIcon +import com.android.launcher3.statehandlers.DepthController +import com.android.launcher3.statemanager.StateManager import com.android.launcher3.util.SplitConfigurationOptions import com.android.quickstep.views.GroupedTaskView import com.android.quickstep.views.IconView @@ -32,13 +37,18 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) class SplitAnimationControllerTest { private val taskId = 9 + private val taskId2 = 10 private val mockSplitSelectStateController: SplitSelectStateController = mock() // TaskView @@ -52,12 +62,19 @@ class SplitAnimationControllerTest { private val mockTask: Task = mock() private val mockTaskKey: Task.TaskKey = mock() private val mockTaskIdAttributeContainer: TaskIdAttributeContainer = mock() + // AppPairIcon + private val mockAppPairIcon: AppPairIcon = mock() // SplitSelectSource private val splitSelectSource: SplitConfigurationOptions.SplitSelectSource = mock() private val mockSplitSourceDrawable: Drawable = mock() private val mockSplitSourceView: View = mock() + private val stateManager: StateManager<*> = mock() + private val depthController: DepthController = mock() + private val transitionInfo: TransitionInfo = mock() + private val transaction: Transaction = mock() + lateinit var splitAnimationController: SplitAnimationController @Before @@ -172,4 +189,110 @@ class SplitAnimationControllerTest { splitAnimInitProps.iconDrawable ) } + + @Test + fun playsAppropriateSplitLaunchAnimation_playsLegacyLaunchCorrectly() { + val spySplitAnimationController = spy(splitAnimationController) + doNothing() + .whenever(spySplitAnimationController) + .composeRecentsSplitLaunchAnimatorLegacy( + any(), any(), any(), any(), any(), any(), any(), any(), any()) + + spySplitAnimationController.playSplitLaunchAnimation( + mockGroupedTaskView, + null /* launchingIconView */, + taskId, + taskId2, + arrayOf() /* apps */, + arrayOf() /* wallpapers */, + arrayOf() /* nonApps */, + stateManager, + depthController, + null /* info */, + null /* t */, + {} /* finishCallback */ + ) + + verify(spySplitAnimationController) + .composeRecentsSplitLaunchAnimatorLegacy( + any(), any(), any(), any(), any(), any(), any(), any(), any()) + } + + @Test + fun playsAppropriateSplitLaunchAnimation_playsRecentsLaunchCorrectly() { + val spySplitAnimationController = spy(splitAnimationController) + doNothing() + .whenever(spySplitAnimationController) + .composeRecentsSplitLaunchAnimator(any(), any(), any(), any(), any(), any()) + + spySplitAnimationController.playSplitLaunchAnimation( + mockGroupedTaskView, + null /* launchingIconView */, + taskId, + taskId2, + null /* apps */, + null /* wallpapers */, + null /* nonApps */, + stateManager, + depthController, + transitionInfo, + transaction, + {} /* finishCallback */ + ) + + verify(spySplitAnimationController) + .composeRecentsSplitLaunchAnimator(any(), any(), any(), any(), any(), any()) + } + + @Test + fun playsAppropriateSplitLaunchAnimation_playsIconLaunchCorrectly() { + val spySplitAnimationController = spy(splitAnimationController) + doNothing() + .whenever(spySplitAnimationController) + .composeIconSplitLaunchAnimator(any(), any(), any(), any(), any(), any()) + + spySplitAnimationController.playSplitLaunchAnimation( + null /* launchingTaskView */, + mockAppPairIcon, + taskId, + taskId2, + null /* apps */, + null /* wallpapers */, + null /* nonApps */, + stateManager, + depthController, + transitionInfo, + transaction, + {} /* finishCallback */ + ) + + verify(spySplitAnimationController) + .composeIconSplitLaunchAnimator(any(), any(), any(), any(), any(), any()) + } + + @Test + fun playsAppropriateSplitLaunchAnimation_playsFadeInLaunchCorrectly() { + val spySplitAnimationController = spy(splitAnimationController) + doNothing() + .whenever(spySplitAnimationController) + .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any()) + + spySplitAnimationController.playSplitLaunchAnimation( + null /* launchingTaskView */, + null /* launchingIconView */, + taskId, + taskId2, + null /* apps */, + null /* wallpapers */, + null /* nonApps */, + stateManager, + depthController, + transitionInfo, + transaction, + {} /* finishCallback */ + ) + + verify(spySplitAnimationController) + .composeFadeInSplitLaunchAnimator(any(), any(), any(), any(), any()) + } } diff --git a/res/layout/app_pair_icon.xml b/res/layout/app_pair_icon.xml index 2b9a98b037..4e2dd58bc3 100644 --- a/res/layout/app_pair_icon.xml +++ b/res/layout/app_pair_icon.xml @@ -20,6 +20,11 @@ android:layout_height="match_parent" android:orientation="vertical" android:focusable="true" > + + + \ No newline at end of file diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java index e301bdb202..031ca5b916 100644 --- a/src/com/android/launcher3/Launcher.java +++ b/src/com/android/launcher3/Launcher.java @@ -3212,7 +3212,7 @@ public class Launcher extends StatefulActivity * Handles an app pair launch; overridden in * {@link com.android.launcher3.uioverrides.QuickstepLauncher} */ - public void launchAppPair(WorkspaceItemInfo app1, WorkspaceItemInfo app2) { + public void launchAppPair(AppPairIcon appPairIcon) { // Overridden } diff --git a/src/com/android/launcher3/apppairs/AppPairIcon.java b/src/com/android/launcher3/apppairs/AppPairIcon.java index 46932fb11e..d201b8f40d 100644 --- a/src/com/android/launcher3/apppairs/AppPairIcon.java +++ b/src/com/android/launcher3/apppairs/AppPairIcon.java @@ -17,11 +17,10 @@ package com.android.launcher3.apppairs; import android.content.Context; -import android.graphics.Canvas; import android.graphics.Rect; -import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.view.LayoutInflater; +import android.view.View; import android.view.ViewGroup; import android.widget.FrameLayout; @@ -33,7 +32,6 @@ import com.android.launcher3.R; import com.android.launcher3.Reorderable; import com.android.launcher3.dragndrop.DraggableView; import com.android.launcher3.model.data.FolderInfo; -import com.android.launcher3.model.data.WorkspaceItemInfo; import com.android.launcher3.util.MultiTranslateDelegate; import com.android.launcher3.views.ActivityContext; @@ -47,33 +45,8 @@ import java.util.Comparator; * member apps are set into these rectangles. */ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderable { - /** - * Design specs -- the below ratios are in relation to the size of a standard app icon. - */ - private static final float OUTER_PADDING_SCALE = 1 / 30f; - private static final float INNER_PADDING_SCALE = 1 / 24f; - private static final float MEMBER_ICON_SCALE = 11 / 30f; - private static final float CENTER_CHANNEL_SCALE = 1 / 30f; - private static final float BIG_RADIUS_SCALE = 1 / 5f; - private static final float SMALL_RADIUS_SCALE = 1 / 15f; - - // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on - // each side. - float mOuterPadding; - // Inside of the icon, the two member apps are padded by this much. - float mInnerPadding; - // The two member apps have icons that are this big (in diameter). - float mMemberIconSize; - // The size of the center channel. - float mCenterChannelSize; - // The large outer radius of the background rectangles. - float mBigRadius; - // The small inner radius of the background rectangles. - float mSmallRadius; - // The app pairs icon appears differently in portrait and landscape. - boolean mIsLandscape; - - private ActivityContext mActivity; + // A view that holds the app pair icon graphic. + private AppPairIconGraphic mIconGraphic; // A view that holds the app pair's title. private BubbleTextView mAppPairName; // The underlying ItemInfo that stores info about the app pair members, etc. @@ -109,7 +82,10 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab icon.setTag(appPairInfo); icon.setOnClickListener(activity.getItemOnClickListener()); icon.mInfo = appPairInfo; - icon.mActivity = activity; + + // Set up icon drawable area + icon.mIconGraphic = icon.findViewById(R.id.app_pair_icon_graphic); + icon.mIconGraphic.init(activity.getDeviceProfile(), icon); // Set up app pair title icon.mAppPairName = icon.findViewById(R.id.app_pair_icon_name); @@ -127,85 +103,6 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab return icon; } - @Override - protected void dispatchDraw(Canvas canvas) { - super.dispatchDraw(canvas); - - // Calculate device-specific measurements - DeviceProfile grid = mActivity.getDeviceProfile(); - int defaultIconSize = grid.iconSizePx; - mOuterPadding = OUTER_PADDING_SCALE * defaultIconSize; - mInnerPadding = INNER_PADDING_SCALE * defaultIconSize; - mMemberIconSize = MEMBER_ICON_SCALE * defaultIconSize; - mCenterChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize; - mBigRadius = BIG_RADIUS_SCALE * defaultIconSize; - mSmallRadius = SMALL_RADIUS_SCALE * defaultIconSize; - mIsLandscape = grid.isLeftRightSplit; - - // Calculate drawable area position - float leftBound = (canvas.getWidth() / 2f) - (defaultIconSize / 2f); - float topBound = getPaddingTop(); - - // Prepare to draw app pair icon background - Drawable background = new AppPairIconBackground(getContext(), this); - background.setBounds(0, 0, defaultIconSize, defaultIconSize); - - // Draw background - canvas.save(); - canvas.translate(leftBound, topBound); - background.draw(canvas); - canvas.restore(); - - // Prepare to draw icons - WorkspaceItemInfo app1 = mInfo.contents.get(0); - WorkspaceItemInfo app2 = mInfo.contents.get(1); - Drawable app1Icon = app1.newIcon(getContext()); - Drawable app2Icon = app2.newIcon(getContext()); - app1Icon.setBounds(0, 0, defaultIconSize, defaultIconSize); - app2Icon.setBounds(0, 0, defaultIconSize, defaultIconSize); - - // Draw first icon - canvas.save(); - canvas.translate(leftBound, topBound); - // The app icons are placed differently depending on device orientation. - if (mIsLandscape) { - canvas.translate( - (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding - - mMemberIconSize, - (defaultIconSize / 2f) - (mMemberIconSize / 2f) - ); - } else { - canvas.translate( - (defaultIconSize / 2f) - (mMemberIconSize / 2f), - (defaultIconSize / 2f) - (mCenterChannelSize / 2f) - mInnerPadding - - mMemberIconSize - ); - - } - canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE); - app1Icon.draw(canvas); - canvas.restore(); - - // Draw second icon - canvas.save(); - canvas.translate(leftBound, topBound); - // The app icons are placed differently depending on device orientation. - if (mIsLandscape) { - canvas.translate( - (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding, - (defaultIconSize / 2f) - (mMemberIconSize / 2f) - ); - } else { - canvas.translate( - (defaultIconSize / 2f) - (mMemberIconSize / 2f), - (defaultIconSize / 2f) + (mCenterChannelSize / 2f) + mInnerPadding - ); - } - canvas.scale(MEMBER_ICON_SCALE, MEMBER_ICON_SCALE); - app2Icon.draw(canvas); - canvas.restore(); - } - /** * Returns a formatted accessibility title for app pairs. */ @@ -257,4 +154,8 @@ public class AppPairIcon extends FrameLayout implements DraggableView, Reorderab public FolderInfo getInfo() { return mInfo; } + + public View getIconDrawableArea() { + return mIconGraphic; + } } diff --git a/src/com/android/launcher3/apppairs/AppPairIconBackground.java b/src/com/android/launcher3/apppairs/AppPairIconBackground.java index 735c82f9d1..4e60ece170 100644 --- a/src/com/android/launcher3/apppairs/AppPairIconBackground.java +++ b/src/com/android/launcher3/apppairs/AppPairIconBackground.java @@ -32,8 +32,8 @@ import com.android.launcher3.R; * A Drawable for the background behind the twin app icons (looks like two rectangles). */ class AppPairIconBackground extends Drawable { - // The icon that we will draw this background on. - private final AppPairIcon icon; + // The underlying view that we are drawing this background on. + private final AppPairIconGraphic icon; private final Paint mBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); /** @@ -44,8 +44,8 @@ class AppPairIconBackground extends Drawable { private static final RectF EMPTY_RECT = new RectF(); private static final float[] ARRAY_OF_ZEROES = new float[8]; - AppPairIconBackground(Context context, AppPairIcon appPairIcon) { - icon = appPairIcon; + AppPairIconBackground(Context context, AppPairIconGraphic iconGraphic) { + icon = iconGraphic; // Set up background paint color TypedArray ta = context.getTheme().obtainStyledAttributes(R.styleable.FolderIconPreview); mBackgroundPaint.setStyle(Paint.Style.FILL); @@ -56,7 +56,7 @@ class AppPairIconBackground extends Drawable { @Override public void draw(Canvas canvas) { - if (icon.mIsLandscape) { + if (icon.isLeftRightSplit()) { drawLeftRightSplit(canvas); } else { drawTopBottomSplit(canvas); @@ -73,29 +73,29 @@ class AppPairIconBackground extends Drawable { // The left half of the background image, excluding center channel RectF leftSide = new RectF( - icon.mOuterPadding, - icon.mOuterPadding, - (width / 2f) - (icon.mCenterChannelSize / 2f), - height - icon.mOuterPadding + 0, + 0, + (width / 2f) - (icon.getCenterChannelSize() / 2f), + height ); // The right half of the background image, excluding center channel RectF rightSide = new RectF( - (width / 2f) + (icon.mCenterChannelSize / 2f), - icon.mOuterPadding, - width - icon.mOuterPadding, - height - icon.mOuterPadding + (width / 2f) + (icon.getCenterChannelSize() / 2f), + 0, + width, + height ); drawCustomRoundedRect(canvas, leftSide, new float[]{ - icon.mBigRadius, icon.mBigRadius, - icon.mSmallRadius, icon.mSmallRadius, - icon.mSmallRadius, icon.mSmallRadius, - icon.mBigRadius, icon.mBigRadius}); + icon.getBigRadius(), icon.getBigRadius(), + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getBigRadius(), icon.getBigRadius()}); drawCustomRoundedRect(canvas, rightSide, new float[]{ - icon.mSmallRadius, icon.mSmallRadius, - icon.mBigRadius, icon.mBigRadius, - icon.mBigRadius, icon.mBigRadius, - icon.mSmallRadius, icon.mSmallRadius}); + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getBigRadius(), icon.getBigRadius(), + icon.getBigRadius(), icon.getBigRadius(), + icon.getSmallRadius(), icon.getSmallRadius()}); } /** @@ -108,29 +108,29 @@ class AppPairIconBackground extends Drawable { // The top half of the background image, excluding center channel RectF topSide = new RectF( - icon.mOuterPadding, - icon.mOuterPadding, - width - icon.mOuterPadding, - (height / 2f) - (icon.mCenterChannelSize / 2f) + 0, + 0, + width, + (height / 2f) - (icon.getCenterChannelSize() / 2f) ); // The bottom half of the background image, excluding center channel RectF bottomSide = new RectF( - icon.mOuterPadding, - (height / 2f) + (icon.mCenterChannelSize / 2f), - width - icon.mOuterPadding, - height - icon.mOuterPadding + 0, + (height / 2f) + (icon.getCenterChannelSize() / 2f), + width, + height ); drawCustomRoundedRect(canvas, topSide, new float[]{ - icon.mBigRadius, icon.mBigRadius, - icon.mBigRadius, icon.mBigRadius, - icon.mSmallRadius, icon.mSmallRadius, - icon.mSmallRadius, icon.mSmallRadius}); + icon.getBigRadius(), icon.getBigRadius(), + icon.getBigRadius(), icon.getBigRadius(), + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getSmallRadius(), icon.getSmallRadius()}); drawCustomRoundedRect(canvas, bottomSide, new float[]{ - icon.mSmallRadius, icon.mSmallRadius, - icon.mSmallRadius, icon.mSmallRadius, - icon.mBigRadius, icon.mBigRadius, - icon.mBigRadius, icon.mBigRadius}); + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getSmallRadius(), icon.getSmallRadius(), + icon.getBigRadius(), icon.getBigRadius(), + icon.getBigRadius(), icon.getBigRadius()}); } /** @@ -146,7 +146,7 @@ class AppPairIconBackground extends Drawable { c.drawDoubleRoundRect(rect, radii, EMPTY_RECT, ARRAY_OF_ZEROES, mBackgroundPaint); } else { // Fallback rectangle with uniform rounded corners - c.drawRoundRect(rect, icon.mBigRadius, icon.mBigRadius, mBackgroundPaint); + c.drawRoundRect(rect, icon.getBigRadius(), icon.getBigRadius(), mBackgroundPaint); } } diff --git a/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt new file mode 100644 index 0000000000..34467eca9c --- /dev/null +++ b/src/com/android/launcher3/apppairs/AppPairIconGraphic.kt @@ -0,0 +1,128 @@ +/* + * 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.apppairs + +import android.content.Context +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.view.Gravity +import android.widget.FrameLayout +import com.android.launcher3.DeviceProfile + +/** + * A FrameLayout marking the area on an [AppPairIcon] where the visual icon will be drawn. One of + * two child UI elements on an [AppPairIcon], along with a BubbleTextView holding the text title. + */ +class AppPairIconGraphic +@JvmOverloads +constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { + companion object { + // Design specs -- the below ratios are in relation to the size of a standard app icon. + private const val OUTER_PADDING_SCALE = 1 / 30f + private const val INNER_PADDING_SCALE = 1 / 24f + private const val MEMBER_ICON_SCALE = 11 / 30f + private const val CENTER_CHANNEL_SCALE = 1 / 30f + private const val BIG_RADIUS_SCALE = 1 / 5f + private const val SMALL_RADIUS_SCALE = 1 / 15f + } + + // App pair icons are slightly smaller than regular icons, so we pad the icon by this much on + // each side. + private var outerPadding = 0f + // Inside of the icon, the two member apps are padded by this much. + private var innerPadding = 0f + // The colored background (two rectangles in a square area) is this big. + private var backgroundSize = 0f + // The two member apps have icons that are this big (in diameter). + private var memberIconSize = 0f + // The size of the center channel. + var centerChannelSize = 0f + // The large outer radius of the background rectangles. + var bigRadius = 0f + // The small inner radius of the background rectangles. + var smallRadius = 0f + // The app pairs icon appears differently in portrait and landscape. + var isLeftRightSplit = false + + private lateinit var appPairBackground: Drawable + private lateinit var appIcon1: Drawable + private lateinit var appIcon2: Drawable + + fun init(grid: DeviceProfile, icon: AppPairIcon) { + // Calculate device-specific measurements + val defaultIconSize = grid.iconSizePx + outerPadding = OUTER_PADDING_SCALE * defaultIconSize + innerPadding = INNER_PADDING_SCALE * defaultIconSize + backgroundSize = defaultIconSize - outerPadding * 2 + memberIconSize = MEMBER_ICON_SCALE * defaultIconSize + centerChannelSize = CENTER_CHANNEL_SCALE * defaultIconSize + bigRadius = BIG_RADIUS_SCALE * defaultIconSize + smallRadius = SMALL_RADIUS_SCALE * defaultIconSize + isLeftRightSplit = grid.isLeftRightSplit + + appPairBackground = AppPairIconBackground(context, this) + appPairBackground.setBounds(0, 0, backgroundSize.toInt(), backgroundSize.toInt()) + appIcon1 = icon.info.contents[0].newIcon(context) + appIcon2 = icon.info.contents[1].newIcon(context) + appIcon1.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt()) + appIcon2.setBounds(0, 0, memberIconSize.toInt(), memberIconSize.toInt()) + } + + override fun dispatchDraw(canvas: Canvas) { + super.dispatchDraw(canvas) + + // Center the drawable area in the larger icon canvas + val lp: LayoutParams = layoutParams as LayoutParams + lp.gravity = Gravity.CENTER_HORIZONTAL + lp.topMargin = outerPadding.toInt() + lp.height = backgroundSize.toInt() + lp.width = backgroundSize.toInt() + layoutParams = lp + + // Draw background + appPairBackground.draw(canvas) + + // Draw first icon + canvas.save() + // The app icons are placed differently depending on device orientation. + if (isLeftRightSplit) { + canvas.translate(innerPadding, height / 2f - memberIconSize / 2f) + } else { + canvas.translate(width / 2f - memberIconSize / 2f, innerPadding) + } + appIcon1.draw(canvas) + canvas.restore() + + // Draw second icon + canvas.save() + // The app icons are placed differently depending on device orientation. + if (isLeftRightSplit) { + canvas.translate( + width - (innerPadding + memberIconSize), + height / 2f - memberIconSize / 2f + ) + } else { + canvas.translate( + width / 2f - memberIconSize / 2f, + height - (innerPadding + memberIconSize) + ) + } + appIcon2.draw(canvas) + canvas.restore() + } +} diff --git a/src/com/android/launcher3/touch/ItemClickHandler.java b/src/com/android/launcher3/touch/ItemClickHandler.java index 3bce377656..a9c2a2e368 100644 --- a/src/com/android/launcher3/touch/ItemClickHandler.java +++ b/src/com/android/launcher3/touch/ItemClickHandler.java @@ -145,8 +145,8 @@ public class ItemClickHandler { */ private static void onClickAppPairIcon(View v) { Launcher launcher = Launcher.getLauncher(v.getContext()); - FolderInfo folderInfo = ((AppPairIcon) v).getInfo(); - launcher.launchAppPair(folderInfo.contents.get(0), folderInfo.contents.get(1)); + AppPairIcon appPairIcon = (AppPairIcon) v; + launcher.launchAppPair(appPairIcon); } /**