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