Merge "desktop-exploded-view: Initial implementation" into main
diff --git a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
index f4400fa..f46f9ae 100644
--- a/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
+++ b/quickstep/src/com/android/quickstep/AbsSwipeUpHandler.java
@@ -1709,7 +1709,7 @@
 
             if (mRecentsView != null) {
                 mRecentsView.onPrepareGestureEndAnimation(null, mGestureState.getEndTarget(),
-                        getRemoteTaskViewSimulators());
+                        mRemoteTargetHandles);
             }
         } else {
             AnimatorSet animatorSet = new AnimatorSet();
@@ -1753,7 +1753,7 @@
                 mRecentsView.onPrepareGestureEndAnimation(
                         mGestureState.isHandlingAtomicEvent() ? null : animatorSet,
                         mGestureState.getEndTarget(),
-                        getRemoteTaskViewSimulators());
+                        mRemoteTargetHandles);
             }
             animatorSet.setDuration(duration).setInterpolator(interpolator);
             animatorSet.start();
diff --git a/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java b/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
index 8d010e2..f426bf5 100644
--- a/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
+++ b/quickstep/src/com/android/quickstep/fallback/FallbackRecentsView.java
@@ -44,11 +44,11 @@
 import com.android.quickstep.BaseContainerInterface;
 import com.android.quickstep.FallbackActivityInterface;
 import com.android.quickstep.GestureState;
+import com.android.quickstep.RemoteTargetGluer.RemoteTargetHandle;
 import com.android.quickstep.fallback.window.RecentsDisplayModel;
 import com.android.quickstep.util.GroupTask;
 import com.android.quickstep.util.SingleTask;
 import com.android.quickstep.util.SplitSelectStateController;
-import com.android.quickstep.util.TaskViewSimulator;
 import com.android.quickstep.views.OverviewActionsView;
 import com.android.quickstep.views.RecentsView;
 import com.android.quickstep.views.RecentsViewContainer;
@@ -129,8 +129,8 @@
     @Override
     public void onPrepareGestureEndAnimation(
             @Nullable AnimatorSet animatorSet, GestureState.GestureEndTarget endTarget,
-            TaskViewSimulator[] taskViewSimulators) {
-        super.onPrepareGestureEndAnimation(animatorSet, endTarget, taskViewSimulators);
+            RemoteTargetHandle[] remoteTargetHandles) {
+        super.onPrepareGestureEndAnimation(animatorSet, endTarget, remoteTargetHandles);
         if (mHomeTask != null && endTarget == RECENTS && animatorSet != null) {
             TaskView tv = getTaskViewByTaskId(mHomeTask.key.id);
             if (tv != null) {
diff --git a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
index 02f48e6..1f428f3 100644
--- a/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
+++ b/quickstep/src/com/android/quickstep/recents/di/RecentsDependencies.kt
@@ -28,6 +28,7 @@
 import com.android.quickstep.recents.data.TasksRepository
 import com.android.quickstep.recents.domain.usecase.GetSysUiStatusNavFlagsUseCase
 import com.android.quickstep.recents.domain.usecase.GetTaskUseCase
+import com.android.quickstep.recents.domain.usecase.OrganizeDesktopTasksUseCase
 import com.android.quickstep.recents.usecase.GetThumbnailPositionUseCase
 import com.android.quickstep.recents.usecase.GetThumbnailUseCase
 import com.android.quickstep.recents.viewmodel.RecentsViewData
@@ -201,6 +202,7 @@
                         rotationStateRepository = inject(),
                         tasksRepository = inject(),
                     )
+                OrganizeDesktopTasksUseCase::class.java -> OrganizeDesktopTasksUseCase()
                 SplashAlphaUseCase::class.java ->
                     SplashAlphaUseCase(
                         recentsViewData = inject(),
diff --git a/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt b/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt
new file mode 100644
index 0000000..a7f102c
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/domain/model/DesktopTaskBoundsData.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2025 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.recents.domain.model
+
+import android.graphics.Rect
+
+data class DesktopTaskBoundsData(val taskId: Int, val bounds: Rect)
diff --git a/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt b/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt
new file mode 100644
index 0000000..3e7d142
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/domain/usecase/OrganizeDesktopTasksUseCase.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2025 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.recents.domain.usecase
+
+import android.graphics.Rect
+import android.util.Size
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+import kotlin.math.ceil
+import kotlin.math.sqrt
+
+/**
+ * This usecase is responsible for organizing desktop windows in a non-overlapping way. Note: this
+ * is currently a placeholder implementation.
+ */
+class OrganizeDesktopTasksUseCase {
+    fun run(
+        desktopSize: Size,
+        taskBounds: List<DesktopTaskBoundsData>,
+    ): List<DesktopTaskBoundsData> {
+        return getRects(desktopSize, taskBounds.size).zip(taskBounds) { rect, task ->
+            shrinkRect(rect, 0.8f)
+            DesktopTaskBoundsData(task.taskId, fitRect(task.bounds, rect))
+        }
+    }
+
+    private fun shrinkRect(bounds: Rect, fraction: Float) {
+        val xMargin = (bounds.width() * ((1.0f - fraction) / 2.0f)).toInt()
+        val yMargin = (bounds.height() * ((1.0f - fraction) / 2.0f)).toInt()
+        bounds.inset(xMargin, yMargin, xMargin, yMargin)
+    }
+
+    /** Generates `tasks` number of non-overlapping rects that fit into `desktopSize`. */
+    private fun getRects(desktopSize: Size, tasks: Int): List<Rect> {
+        val (xSlots, ySlots) =
+            when (tasks) {
+                2 -> Pair(2, 1)
+                3,
+                4 -> Pair(2, 2)
+                5,
+                6 -> Pair(3, 2)
+                else -> {
+                    val sides = ceil(sqrt(tasks.toDouble())).toInt()
+                    Pair(sides, sides)
+                }
+            }
+
+        // The width and height of one of the boxes.
+        val boxWidth = desktopSize.width / xSlots
+        val boxHeight = desktopSize.height / ySlots
+
+        return (0 until tasks).map {
+            val x = it % xSlots
+            val y = it / xSlots
+            Rect(x * boxWidth, y * boxHeight, (x + 1) * boxWidth, (y + 1) * boxHeight)
+        }
+    }
+
+    /** Centers and fits `rect` into `bounds`, while preserving the former's aspect ratio. */
+    private fun fitRect(rect: Rect, bounds: Rect): Rect {
+        val boundsAspect = bounds.width().toFloat() / bounds.height()
+        val rectAspect = rect.width().toFloat() / rect.height()
+
+        if (rectAspect > boundsAspect) {
+            // The width is the limiting dimension.
+            val scale = bounds.width().toFloat() / rect.width()
+            val width = bounds.width()
+            val height = (rect.height() * scale).toInt()
+            val top = (bounds.top + bounds.height() / 2.0f - height / 2.0f).toInt()
+            return Rect(bounds.left, top, bounds.left + width, top + height)
+        } else {
+            // The height is the limiting dimension.
+            val scale = bounds.height().toFloat() / rect.height()
+            val width = (rect.width() * scale).toInt()
+            val height = bounds.height()
+            val left = (bounds.left + bounds.width() / 2.0f - width / 2.0f).toInt()
+            return Rect(left, bounds.top, left + width, bounds.top + height)
+        }
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt
new file mode 100644
index 0000000..0a60ee9
--- /dev/null
+++ b/quickstep/src/com/android/quickstep/recents/ui/viewmodel/DesktopTaskViewModel.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) 2025 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.recents.ui.viewmodel
+
+import android.util.Size
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+import com.android.quickstep.recents.domain.usecase.OrganizeDesktopTasksUseCase
+
+/** ViewModel used for [com.android.quickstep.views.DesktopTaskView]. */
+class DesktopTaskViewModel(private val organizeDesktopTasksUseCase: OrganizeDesktopTasksUseCase) {
+    var organizedDesktopTaskPositions = emptyList<DesktopTaskBoundsData>()
+        private set
+
+    fun organizeDesktopTasks(desktopSize: Size, defaultPositions: List<DesktopTaskBoundsData>) {
+        organizedDesktopTaskPositions =
+            organizeDesktopTasksUseCase.run(desktopSize, defaultPositions)
+    }
+}
diff --git a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
index a1e55fb..09e9c8b 100644
--- a/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
+++ b/quickstep/src/com/android/quickstep/util/TaskViewSimulator.java
@@ -120,6 +120,9 @@
     private int mTaskRectTranslationY;
     private int mDesktopTaskIndex = 0;
 
+    @Nullable
+    private Matrix mTaskRectTransform = null;
+
     public TaskViewSimulator(Context context, BaseContainerInterface sizeStrategy,
             boolean isDesktop, int desktopTaskIndex) {
         mContext = context;
@@ -364,6 +367,15 @@
     }
 
     /**
+     * Sets a matrix used to transform the position of tasks. If set, this matrix is applied to
+     * the task rect after the task has been scaled and positioned inside the fulltask, but
+     * before scaling and translation of the whole recents view is performed.
+     */
+    public void setTaskRectTransform(@Nullable Matrix taskRectTransform) {
+        mTaskRectTransform = taskRectTransform;
+    }
+
+    /**
      * Applies the rotation on the matrix to so that it maps from launcher coordinate space to
      * window coordinate space.
      */
@@ -424,8 +436,11 @@
 
         mMatrix.set(mPositionHelper.getMatrix());
 
-        // Apply TaskView matrix: taskRect, translate
+        // Apply TaskView matrix: taskRect, optional transform, translate
         mMatrix.postTranslate(mTaskRect.left, mTaskRect.top);
+        if (mTaskRectTransform != null) {
+            mMatrix.postConcat(mTaskRectTransform);
+        }
         mOrientationState.getOrientationHandler().setPrimary(mMatrix, MATRIX_POST_TRANSLATE,
                 taskPrimaryTranslation.value);
         mOrientationState.getOrientationHandler().setSecondary(mMatrix, MATRIX_POST_TRANSLATE,
diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
index 471313a..bb6829a 100644
--- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
+++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt
@@ -15,33 +15,44 @@
  */
 package com.android.quickstep.views
 
+import android.animation.Animator
 import android.annotation.SuppressLint
 import android.content.Context
-import android.graphics.Point
+import android.graphics.Matrix
 import android.graphics.PointF
 import android.graphics.Rect
+import android.graphics.RectF
 import android.util.AttributeSet
 import android.util.Log
+import android.util.Size
 import android.view.Gravity
 import android.view.View
 import android.view.ViewStub
 import androidx.core.content.res.ResourcesCompat
 import androidx.core.view.updateLayoutParams
+import com.android.launcher3.Flags.enableDesktopExplodedView
 import com.android.launcher3.Flags.enableOverviewIconMenu
 import com.android.launcher3.Flags.enableRefactorTaskThumbnail
 import com.android.launcher3.R
+import com.android.launcher3.anim.AnimatedFloat
 import com.android.launcher3.testing.TestLogging
 import com.android.launcher3.testing.shared.TestProtocol
 import com.android.launcher3.util.RunnableList
 import com.android.launcher3.util.SplitConfigurationOptions
 import com.android.launcher3.util.TransformingTouchDelegate
 import com.android.launcher3.util.ViewPool
+import com.android.launcher3.util.rects.lerpRect
 import com.android.launcher3.util.rects.set
 import com.android.quickstep.BaseContainerInterface
 import com.android.quickstep.DesktopFullscreenDrawParams
 import com.android.quickstep.FullscreenDrawParams
+import com.android.quickstep.RemoteTargetGluer.RemoteTargetHandle
 import com.android.quickstep.TaskOverlayFactory
 import com.android.quickstep.ViewUtils
+import com.android.quickstep.recents.di.RecentsDependencies
+import com.android.quickstep.recents.di.get
+import com.android.quickstep.recents.domain.model.DesktopTaskBoundsData
+import com.android.quickstep.recents.ui.viewmodel.DesktopTaskViewModel
 import com.android.quickstep.task.thumbnail.TaskThumbnailView
 import com.android.quickstep.util.RecentsOrientedState
 import com.android.systemui.shared.recents.model.Task
@@ -79,11 +90,40 @@
         } else null
 
     private val tempPointF = PointF()
-    private val tempRect = Rect()
+    private val lastComputedTaskSize = Rect()
     private lateinit var iconView: TaskViewIcon
     private lateinit var contentView: DesktopTaskContentView
     private lateinit var backgroundView: View
 
+    private var viewModel: DesktopTaskViewModel? = null
+
+    /**
+     * Holds the default (user placed) positions of task windows. This can be moved into the
+     * viewModel once RefactorTaskThumbnail has been launched.
+     */
+    private var defaultTaskPositions: List<DesktopTaskBoundsData> = emptyList()
+
+    /**
+     * When enableDesktopExplodedView is enabled, this controls the gradual transition from the
+     * default positions to the organized non-overlapping positions.
+     */
+    var explodeProgress = 0.0f
+        set(value) {
+            field = value
+            positionTaskWindows()
+        }
+
+    var remoteTargetHandles: Array<RemoteTargetHandle>? = null
+        set(value) {
+            field = value
+            positionTaskWindows()
+        }
+
+    private fun getRemoteTargetHandle(taskId: Int): RemoteTargetHandle? =
+        remoteTargetHandles?.firstOrNull {
+            it.transformParams.targetSet.firstAppTargetTaskId == taskId
+        }
+
     override fun onFinishInflate() {
         super.onFinishInflate()
         iconView =
@@ -121,6 +161,113 @@
             ?.inflate()
     }
 
+    fun startWindowExplodeAnimation(): Animator =
+        AnimatedFloat { progress -> explodeProgress = progress }.animateToValue(0.0f, 1.0f)
+
+    private fun positionTaskWindows() {
+        if (taskContainers.isEmpty()) {
+            return
+        }
+
+        val thumbnailTopMarginPx = container.deviceProfile.overviewTaskThumbnailTopMarginPx
+
+        val containerWidth = layoutParams.width
+        val containerHeight = layoutParams.height - thumbnailTopMarginPx
+
+        BaseContainerInterface.getTaskDimension(mContext, container.deviceProfile, tempPointF)
+
+        val windowWidth = tempPointF.x.toInt()
+        val windowHeight = tempPointF.y.toInt()
+        val scaleWidth = containerWidth / windowWidth.toFloat()
+        val scaleHeight = containerHeight / windowHeight.toFloat()
+
+        taskContainers.forEach {
+            val taskId = it.task.key.id
+            val defaultPosition = defaultTaskPositions.firstOrNull { it.taskId == taskId } ?: return
+            val position =
+                if (enableDesktopExplodedView()) {
+                    viewModel!!
+                        .organizedDesktopTaskPositions
+                        .firstOrNull { it.taskId == taskId }
+                        ?.let { organizedPosition ->
+                            TEMP_RECT.apply {
+                                lerpRect(
+                                    defaultPosition.bounds,
+                                    organizedPosition.bounds,
+                                    explodeProgress,
+                                )
+                            }
+                        } ?: defaultPosition.bounds
+                } else {
+                    defaultPosition.bounds
+                }
+
+            if (enableDesktopExplodedView()) {
+                getRemoteTargetHandle(taskId)?.let { remoteTargetHandle ->
+                    val fromRect =
+                        TEMP_RECTF1.apply {
+                            set(defaultPosition.bounds)
+                            scale(scaleWidth)
+                            offset(
+                                lastComputedTaskSize.left.toFloat(),
+                                lastComputedTaskSize.top.toFloat(),
+                            )
+                        }
+                    val toRect =
+                        TEMP_RECTF2.apply {
+                            set(position)
+                            scale(scaleWidth)
+                            offset(
+                                lastComputedTaskSize.left.toFloat(),
+                                lastComputedTaskSize.top.toFloat(),
+                            )
+                        }
+                    val transform = Matrix()
+                    transform.setRectToRect(fromRect, toRect, Matrix.ScaleToFit.FILL)
+                    remoteTargetHandle.taskViewSimulator.setTaskRectTransform(transform)
+                    remoteTargetHandle.taskViewSimulator.apply(remoteTargetHandle.transformParams)
+                }
+            }
+
+            val taskLeft = position.left * scaleWidth
+            val taskTop = position.top * scaleHeight
+            val taskWidth = position.width() * scaleWidth
+            val taskHeight = position.height() * scaleHeight
+            // TODO(b/394660950): Revisit the choice to update the layout when explodeProgress == 1.
+            // To run the explode animation in reverse, it may be simpler to use translation/scale
+            // for all cases where the progress is non-zero.
+            if (explodeProgress == 0.0f || explodeProgress == 1.0f) {
+                // Reset scaling and translation that may have been applied during animation.
+                it.snapshotView.apply {
+                    scaleX = 1.0f
+                    scaleY = 1.0f
+                    translationX = 0.0f
+                    translationY = 0.0f
+                }
+
+                // Position the task to the same position as it would be on the desktop
+                it.snapshotView.updateLayoutParams<LayoutParams> {
+                    gravity = Gravity.LEFT or Gravity.TOP
+                    width = taskWidth.toInt()
+                    height = taskHeight.toInt()
+                    leftMargin = taskLeft.toInt()
+                    topMargin = taskTop.toInt()
+                }
+            } else {
+                // During the animation, apply translation and scale such that the view is
+                // transformed to where we want, without triggering layout.
+                it.snapshotView.apply {
+                    pivotX = 0.0f
+                    pivotY = 0.0f
+                    translationX = taskLeft - left
+                    translationY = taskTop - top
+                    scaleX = taskWidth / width.toFloat()
+                    scaleY = taskHeight / height.toFloat()
+                }
+            }
+        }
+    }
+
     /** Updates this desktop task to the gives task list defined in `tasks` */
     fun bind(
         tasks: List<Task>,
@@ -133,6 +280,7 @@
             tasks.forEach { sb.append(" key=${it.key}\n") }
             Log.d(TAG, sb.toString())
         }
+
         cancelPendingLoadTasks()
         val backgroundViewIndex = contentView.indexOfChild(backgroundView)
         taskContainers =
@@ -160,8 +308,19 @@
         onBind(orientedState)
     }
 
+    override fun onBind(orientedState: RecentsOrientedState) {
+        super.onBind(orientedState)
+
+        if (enableRefactorTaskThumbnail()) {
+            viewModel =
+                DesktopTaskViewModel(organizeDesktopTasksUseCase = RecentsDependencies.get())
+        }
+    }
+
     override fun onRecycle() {
         super.onRecycle()
+        explodeProgress = 0.0f
+        viewModel = null
         visibility = VISIBLE
         taskContainers.forEach {
             contentView.removeView(it.snapshotView)
@@ -176,61 +335,21 @@
     @SuppressLint("RtlHardcoded")
     override fun updateTaskSize(lastComputedTaskSize: Rect, lastComputedGridTaskSize: Rect) {
         super.updateTaskSize(lastComputedTaskSize, lastComputedGridTaskSize)
-        if (taskContainers.isEmpty()) {
-            return
-        }
-
-        val thumbnailTopMarginPx = container.deviceProfile.overviewTaskThumbnailTopMarginPx
-
-        val containerWidth = layoutParams.width
-        val containerHeight = layoutParams.height - thumbnailTopMarginPx
+        this.lastComputedTaskSize.set(lastComputedTaskSize)
 
         BaseContainerInterface.getTaskDimension(mContext, container.deviceProfile, tempPointF)
+        val desktopSize = Size(tempPointF.x.toInt(), tempPointF.y.toInt())
+        DEFAULT_BOUNDS.set(0, 0, desktopSize.width / 4, desktopSize.height / 4)
 
-        val windowWidth = tempPointF.x.toInt()
-        val windowHeight = tempPointF.y.toInt()
-        val scaleWidth = containerWidth / windowWidth.toFloat()
-        val scaleHeight = containerHeight / windowHeight.toFloat()
-
-        if (DEBUG) {
-            Log.d(
-                TAG,
-                "onMeasure: container=[$containerWidth,$containerHeight]" +
-                    "window=[$windowWidth,$windowHeight] scale=[$scaleWidth,$scaleHeight]",
-            )
-        }
-
-        // Desktop tile is a shrunk down version of launcher and freeform task thumbnails.
-        taskContainers.forEach {
-            // Default to quarter of the desktop if we did not get app bounds.
-            val taskSize =
-                it.task.appBounds
-                    ?: tempRect.apply {
-                        left = 0
-                        top = 0
-                        right = windowWidth / 4
-                        bottom = windowHeight / 4
-                    }
-            val positionInParent = it.task.positionInParent ?: ORIGIN
-
-            // Position the task to the same position as it would be on the desktop
-            it.snapshotView.updateLayoutParams<LayoutParams> {
-                gravity = Gravity.LEFT or Gravity.TOP
-                width = (taskSize.width() * scaleWidth).toInt()
-                height = (taskSize.height() * scaleHeight).toInt()
-                leftMargin = (positionInParent.x * scaleWidth).toInt()
-                topMargin = (positionInParent.y * scaleHeight).toInt()
+        defaultTaskPositions =
+            taskContainers.map {
+                DesktopTaskBoundsData(it.task.key.id, it.task.appBounds ?: DEFAULT_BOUNDS)
             }
-            if (DEBUG) {
-                with(it.snapshotView.layoutParams as LayoutParams) {
-                    Log.d(
-                        TAG,
-                        "onMeasure: task=${it.task.key} size=[$width,$height]" +
-                            " margin=[$leftMargin,$topMargin]",
-                    )
-                }
-            }
+
+        if (enableDesktopExplodedView()) {
+            viewModel?.organizeDesktopTasks(desktopSize, defaultTaskPositions)
         }
+        positionTaskWindows()
     }
 
     override fun onTaskListVisibilityChanged(visible: Boolean, changes: Int) {
@@ -319,6 +438,10 @@
 
         // As DesktopTaskView is inflated in background, use initialSize=0 to avoid initPool.
         private const val VIEW_POOL_INITIAL_SIZE = 0
-        private val ORIGIN = Point(0, 0)
+        private val DEFAULT_BOUNDS = Rect()
+        // Temporaries used for various purposes to avoid allocations.
+        private val TEMP_RECT = Rect()
+        private val TEMP_RECTF1 = RectF()
+        private val TEMP_RECTF2 = RectF()
     }
 }
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index 28ab496..9d3b23a 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -36,6 +36,7 @@
 import static com.android.launcher3.AbstractFloatingView.getTopOpenViewWithType;
 import static com.android.launcher3.BaseActivity.STATE_HANDLER_INVISIBILITY_FLAGS;
 import static com.android.launcher3.Flags.enableAdditionalHomeAnimations;
+import static com.android.launcher3.Flags.enableDesktopExplodedView;
 import static com.android.launcher3.Flags.enableDesktopTaskAlphaAnimation;
 import static com.android.launcher3.Flags.enableGridOnlyOverview;
 import static com.android.launcher3.Flags.enableLargeDesktopWindowingTile;
@@ -2898,7 +2899,7 @@
      */
     public void onPrepareGestureEndAnimation(
             @Nullable AnimatorSet animatorSet, GestureState.GestureEndTarget endTarget,
-            TaskViewSimulator[] taskViewSimulators) {
+            RemoteTargetHandle[] remoteTargetHandles) {
         Log.d(TAG, "onPrepareGestureEndAnimation - endTarget: " + endTarget);
         mCurrentGestureEndTarget = endTarget;
         boolean isOverviewEndTarget = endTarget == GestureState.GestureEndTarget.RECENTS;
@@ -2906,6 +2907,19 @@
             updateGridProperties();
         }
 
+        if (enableDesktopExplodedView()) {
+            for (TaskView taskView : getTaskViews()) {
+                if (taskView instanceof DesktopTaskView desktopTaskView) {
+                    if (animatorSet == null) {
+                        desktopTaskView.setExplodeProgress(1.0f);
+                    } else {
+                        animatorSet.play(desktopTaskView.startWindowExplodeAnimation());
+                    }
+                    desktopTaskView.setRemoteTargetHandles(remoteTargetHandles);
+                }
+            }
+        }
+
         BaseState<?> endState = mSizeStrategy.stateFromGestureEndTarget(endTarget);
         if (endState.displayOverviewTasksAsGrid(mContainer.getDeviceProfile())) {
             TaskView runningTaskView = getRunningTaskView();
@@ -2918,7 +2932,8 @@
                         - runningTaskView.getNonGridTranslationX();
                 runningTaskSecondaryGridTranslation = runningTaskView.getGridTranslationY();
             }
-            for (TaskViewSimulator tvs : taskViewSimulators) {
+            for (RemoteTargetHandle remoteTargetHandle : remoteTargetHandles) {
+                TaskViewSimulator tvs = remoteTargetHandle.getTaskViewSimulator();
                 if (animatorSet == null) {
                     setGridProgress(1);
                     tvs.taskPrimaryTranslation.value = runningTaskPrimaryGridTranslation;
@@ -2966,6 +2981,12 @@
         startIconFadeInOnGestureComplete();
         animateActionsViewIn();
 
+        for (TaskView taskView : getTaskViews()) {
+            if (taskView instanceof DesktopTaskView desktopTaskView) {
+                desktopTaskView.setRemoteTargetHandles(mRemoteTargetHandles);
+            }
+        }
+
         mCurrentGestureEndTarget = null;
     }
 
diff --git a/src/com/android/launcher3/util/rects/Rects.kt b/src/com/android/launcher3/util/rects/Rects.kt
index 1e6d717..2f1942a 100644
--- a/src/com/android/launcher3/util/rects/Rects.kt
+++ b/src/com/android/launcher3/util/rects/Rects.kt
@@ -18,6 +18,24 @@
 
 import android.graphics.Rect
 import android.view.View
+import com.android.launcher3.Utilities
+
+/**
+ * Linearly interpolate between two rectangles. The result is stored in the rect the function is
+ * called on.
+ *
+ * @param start the starting rectangle
+ * @param end the ending rectangle
+ * @param t the interpolation factor, where 0 is the start and 1 is the end
+ */
+fun Rect.lerpRect(start: Rect, end: Rect, t: Float) {
+    set(
+        Utilities.mapRange(t, start.left.toFloat(), end.left.toFloat()).toInt(),
+        Utilities.mapRange(t, start.top.toFloat(), end.top.toFloat()).toInt(),
+        Utilities.mapRange(t, start.right.toFloat(), end.right.toFloat()).toInt(),
+        Utilities.mapRange(t, start.bottom.toFloat(), end.bottom.toFloat()).toInt(),
+    )
+}
 
 /** Copy the coordinates of the [view] relative to its parent into this rectangle. */
 fun Rect.set(view: View) {