App Pairs: Launch animation
[App Pairs 7/?]
This patch implements the app pair launch animation from icon. Adds a new function, composeFadeInSplitLaunchAnimator(), in SplitAnimationController, that builds the combined launcher + shell animation.
Bug: 309618233
Flag: ACONFIG com.android.wm.shell.enable_app_pairs DEVELOPMENT
Test: Manual
Change-Id: I8e95f629ae2a71f1bd6cbb356f5e33233e5c2906
diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java
index e106506..41635b8 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.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 @@
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 @@
.startShortcut(packageName, id, null, null, info.user);
} else {
launchFromTaskbarPreservingSplitIfVisible(
- recents, Collections.singletonList(info));
+ recents, view, Collections.singletonList(info));
}
} catch (NullPointerException
@@ -1072,7 +1073,8 @@
// 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 @@
* (potentially breaking a split pair).
*/
private void launchFromTaskbarPreservingSplitIfVisible(@Nullable RecentsView recents,
- List<? extends ItemInfo> itemInfos) {
+ @Nullable View launchingIconView, List<? extends ItemInfo> itemInfos) {
if (recents == null) {
return;
}
@@ -1122,8 +1124,7 @@
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 b685d3c..14e258b 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.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.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 @@
/**
* 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 ddddc89..11c5ab4 100644
--- a/quickstep/src/com/android/quickstep/TaskViewUtils.java
+++ b/quickstep/src/com/android/quickstep/TaskViewUtils.java
@@ -18,8 +18,6 @@
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 @@
* 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<SurfaceControl> 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 b7b7825..7fbbb6e 100644
--- a/quickstep/src/com/android/quickstep/util/AnimUtils.java
+++ b/quickstep/src/com/android/quickstep/util/AnimUtils.java
@@ -39,4 +39,13 @@
? 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 0000000..086c8af
--- /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 b6a8797..3ca2531 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.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 @@
* 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 @@
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 0000000..beab90f
--- /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 dfbd32c..ade8074 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.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 @@
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<RemoteAnimationTarget>?,
+ wallpapers: Array<RemoteAnimationTarget>?,
+ nonApps: Array<RemoteAnimationTarget>?,
+ 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<RemoteAnimationTarget>,
+ wallpapers: Array<RemoteAnimationTarget>,
+ nonApps: Array<RemoteAnimationTarget>,
+ 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<SurfaceControl>()
+ 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 93f2255..b618546 100644
--- a/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java
+++ b/quickstep/src/com/android/quickstep/util/SplitAnimationTimings.java
@@ -21,31 +21,44 @@
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 @@
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 @@
// 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 596bb47..38bbe60 100644
--- a/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
+++ b/quickstep/src/com/android/quickstep/util/SplitScreenUtils.kt
@@ -56,4 +56,4 @@
}
}
}
-}
\ 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 24d6d27..d5899e4 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.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.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 @@
/** 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 @@
// 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 @@
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 @@
dispatchOnSplitSelectionExit();
mRecentsAnimationRunning = false;
mLaunchingTaskView = null;
+ mLaunchingIconView = null;
mAnimateCurrentTaskDismissal = false;
mDismissingFromSplitPair = false;
mFirstFloatingTaskView = null;
@@ -817,6 +829,10 @@
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 0000000..fb2d63f
--- /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 0000000..3a5873b
--- /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 0000000..e90aa13
--- /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 50803fe..86018b1 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 @@
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.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 @@
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 @@
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 2b9a98b..4e2dd58 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" >
+ <com.android.launcher3.apppairs.AppPairIconGraphic
+ android:id="@+id/app_pair_icon_graphic"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:focusable="false" />
<com.android.launcher3.views.DoubleShadowBubbleTextView
style="@style/BaseIcon.Workspace"
android:id="@+id/app_pair_icon_name"
diff --git a/res/layout/floating_app_pair_view.xml b/res/layout/floating_app_pair_view.xml
new file mode 100644
index 0000000..88ec655
--- /dev/null
+++ b/res/layout/floating_app_pair_view.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<com.android.quickstep.views.FloatingAppPairView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+</com.android.quickstep.views.FloatingAppPairView>
\ No newline at end of file
diff --git a/src/com/android/launcher3/Launcher.java b/src/com/android/launcher3/Launcher.java
index e301bdb..031ca5b 100644
--- a/src/com/android/launcher3/Launcher.java
+++ b/src/com/android/launcher3/Launcher.java
@@ -3212,7 +3212,7 @@
* 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 46932fb..d201b8f 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.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 @@
* 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 @@
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 @@
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 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 735c82f..4e60ece 100644
--- a/src/com/android/launcher3/apppairs/AppPairIconBackground.java
+++ b/src/com/android/launcher3/apppairs/AppPairIconBackground.java
@@ -32,8 +32,8 @@
* 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 @@
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 @@
@Override
public void draw(Canvas canvas) {
- if (icon.mIsLandscape) {
+ if (icon.isLeftRightSplit()) {
drawLeftRightSplit(canvas);
} else {
drawTopBottomSplit(canvas);
@@ -73,29 +73,29 @@
// 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 @@
// 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 @@
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 0000000..34467ec
--- /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 3bce377..a9c2a2e 100644
--- a/src/com/android/launcher3/touch/ItemClickHandler.java
+++ b/src/com/android/launcher3/touch/ItemClickHandler.java
@@ -145,8 +145,8 @@
*/
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);
}
/**