Merge "Add spring animations for neighboring tasks on dismiss cancel" into main
diff --git a/quickstep/res/values/dimens.xml b/quickstep/res/values/dimens.xml
index f2f1ebd..05f0695 100644
--- a/quickstep/res/values/dimens.xml
+++ b/quickstep/res/values/dimens.xml
@@ -88,6 +88,9 @@
     <dimen name="task_thumbnail_header_icon_size">18dp</dimen>
     <dimen name="task_thumbnail_header_round_corner_radius">16dp</dimen>
 
+    <!--  How much a task being dragged for dismissal can undershoot the origin when dragged back to its start position.  -->
+    <dimen name="task_dismiss_max_undershoot">25dp</dimen>
+
     <dimen name="task_icon_cache_default_icon_size">72dp</dimen>
     <item name="overview_modal_max_scale" format="float" type="dimen">1.1</item>
 
diff --git a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
index 99b962b..77a05c1 100644
--- a/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
+++ b/quickstep/src/com/android/launcher3/uioverrides/touchcontrollers/TaskViewDismissTouchController.kt
@@ -20,6 +20,7 @@
 import androidx.dynamicanimation.animation.SpringAnimation
 import com.android.app.animation.Interpolators.DECELERATE
 import com.android.launcher3.AbstractFloatingView
+import com.android.launcher3.R
 import com.android.launcher3.Utilities.EDGE_NAV_BAR
 import com.android.launcher3.Utilities.boundToRange
 import com.android.launcher3.Utilities.isRtl
@@ -144,7 +145,7 @@
                     0f,
                     dismissLength.toFloat(),
                     0f,
-                    DISMISS_MAX_UNDERSHOOT,
+                    container.resources.getDimension(R.dimen.task_dismiss_max_undershoot),
                     DECELERATE,
                 )
         taskBeingDragged.secondaryDismissTranslationProperty.setValue(
@@ -207,6 +208,5 @@
 
     companion object {
         private const val DISMISS_THRESHOLD_FRACTION = 0.5f
-        private const val DISMISS_MAX_UNDERSHOOT = 25f
     }
 }
diff --git a/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java b/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
index ceffbe4..da26622 100644
--- a/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
+++ b/quickstep/src/com/android/quickstep/util/TaskGridNavHelper.java
@@ -103,23 +103,28 @@
             }
             case DIRECTION_RIGHT: {
                 int boundedIndex =
-                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex) : Math.max(
-                                nextIndex, 0);
+                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex)
+                                : Math.max(nextIndex, 0);
                 boolean inOriginalTop = mOriginalTopRowIds.contains(currentPageTaskViewId);
                 return inOriginalTop ? mTopRowIds.get(boundedIndex)
                         : mBottomRowIds.get(boundedIndex);
             }
             case DIRECTION_TAB: {
                 int boundedIndex =
-                        cycle ? nextIndex < 0 ? maxSize - 1 : nextIndex % maxSize : Math.min(
-                                nextIndex, maxSize - 1);
+                        cycle ? (nextIndex < 0 ? maxSize - 1 : nextIndex % maxSize)
+                                : Math.min(nextIndex, maxSize - 1);
                 if (delta >= 0) {
                     return inTop && mTopRowIds.get(index) != mBottomRowIds.get(index)
                             ? mBottomRowIds.get(index)
                             : mTopRowIds.get(boundedIndex);
                 } else {
                     if (mTopRowIds.contains(currentPageTaskViewId)) {
-                        return mBottomRowIds.get(boundedIndex);
+                        if (boundedIndex < 0) {
+                            // If no cycling, always return the first task.
+                            return mTopRowIds.get(0);
+                        } else {
+                            return mBottomRowIds.get(boundedIndex);
+                        }
                     } else {
                         // Go up to top if there is task above
                         return mTopRowIds.get(index) != mBottomRowIds.get(index)
diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java
index a76ebdb..51980f0 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsView.java
+++ b/quickstep/src/com/android/quickstep/views/RecentsView.java
@@ -6379,7 +6379,7 @@
     }
 
     /**
-     * @return true if the task in on the top of the grid
+     * @return true if the task in on the bottom of the grid
      */
     public boolean isOnGridBottomRow(TaskView taskView) {
         return showAsGrid()
@@ -6942,7 +6942,8 @@
      * Creates the spring animations which run as a task settles back into its place in overview.
      *
      * <p>When a task dismiss is cancelled, the task will return to its original position via a
-     * spring animation.
+     * spring animation. As it passes the threshold of its settling state, its neighbors will
+     * spring in response to the perceived impact of the settling task.
      */
     public SpringAnimation createTaskDismissSettlingSpringAnimation(TaskView draggedTaskView,
             float velocity, boolean isDismissing, SingleAxisSwipeDetector detector,
diff --git a/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt b/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
index f610335..d37a3f9 100644
--- a/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
+++ b/quickstep/src/com/android/quickstep/views/RecentsViewUtils.kt
@@ -20,6 +20,7 @@
 import android.view.View
 import androidx.core.view.children
 import androidx.dynamicanimation.animation.FloatPropertyCompat
+import androidx.dynamicanimation.animation.FloatValueHolder
 import androidx.dynamicanimation.animation.SpringAnimation
 import androidx.dynamicanimation.animation.SpringForce
 import com.android.launcher3.Flags.enableLargeDesktopWindowingTile
@@ -29,6 +30,7 @@
 import com.android.launcher3.util.DynamicResource
 import com.android.launcher3.util.IntArray
 import com.android.quickstep.util.GroupTask
+import com.android.quickstep.util.TaskGridNavHelper
 import com.android.quickstep.util.isExternalDisplay
 import com.android.quickstep.views.RecentsView.RUNNING_TASK_ATTACH_ALPHA
 import com.android.systemui.shared.recents.model.ThumbnailData
@@ -305,7 +307,8 @@
      * Creates the spring animations which run when a dragged task view in overview is released.
      *
      * <p>When a task dismiss is cancelled, the task will return to its original position via a
-     * spring animation.
+     * spring animation. As it passes the threshold of its settling state, its neighbors will spring
+     * in response to the perceived impact of the settling task.
      */
     fun createTaskDismissSettlingSpringAnimation(
         draggedTaskView: TaskView?,
@@ -320,37 +323,181 @@
             FloatPropertyCompat.createFloatPropertyCompat(
                 draggedTaskView.secondaryDismissTranslationProperty
             )
-        val rp = DynamicResource.provider(recentsView.mContainer)
-        return SpringAnimation(draggedTaskView, taskDismissFloatProperty)
-            .setSpring(
-                SpringForce()
-                    .setDampingRatio(rp.getFloat(R.dimen.dismiss_task_trans_y_damping_ratio))
-                    .setStiffness(rp.getFloat(R.dimen.dismiss_task_trans_y_stiffness))
-            )
-            .setStartVelocity(if (detector.isFling(velocity)) velocity else 0f)
-            .addUpdateListener { animation, value, _ ->
-                if (isDismissing && abs(value) >= abs(dismissLength)) {
-                    // TODO(b/393553524): Remove 0 alpha, instead animate task fully off screen.
-                    draggedTaskView.alpha = 0f
-                    animation.cancel()
-                } else if (draggedTaskView.isRunningTask && recentsView.enableDrawingLiveTile) {
-                    recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
-                        remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
-                            taskDismissFloatProperty.getValue(draggedTaskView)
+        // Animate dragged task towards dismissal or rest state.
+        val draggedTaskViewSpringAnimation =
+            SpringAnimation(draggedTaskView, taskDismissFloatProperty)
+                .setSpring(createExpressiveDismissSpringForce())
+                .setStartVelocity(if (detector.isFling(velocity)) velocity else 0f)
+                .addUpdateListener { animation, value, _ ->
+                    if (isDismissing && abs(value) >= abs(dismissLength)) {
+                        // TODO(b/393553524): Remove 0 alpha, instead animate task fully off screen.
+                        draggedTaskView.alpha = 0f
+                        animation.cancel()
+                    } else if (draggedTaskView.isRunningTask && recentsView.enableDrawingLiveTile) {
+                        recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
+                            remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
+                                taskDismissFloatProperty.getValue(draggedTaskView)
+                        }
+                        recentsView.redrawLiveTile()
                     }
-                    recentsView.redrawLiveTile()
                 }
+                .addEndListener { _, _, _, _ ->
+                    if (isDismissing) {
+                        recentsView.dismissTask(
+                            draggedTaskView,
+                            /* animateTaskView = */ false,
+                            /* removeTask = */ true,
+                        )
+                    } else {
+                        recentsView.onDismissAnimationEnds()
+                    }
+                    onEndRunnable()
+                }
+        if (!isDismissing) {
+            addNeighboringSpringAnimationsForDismissCancel(
+                draggedTaskView,
+                draggedTaskViewSpringAnimation,
+                recentsView.pageCount,
+            )
+        }
+        return draggedTaskViewSpringAnimation
+    }
+
+    private fun addNeighboringSpringAnimationsForDismissCancel(
+        draggedTaskView: TaskView,
+        draggedTaskViewSpringAnimation: SpringAnimation,
+        taskCount: Int,
+    ) {
+        // Empty spring animation exists for conditional start, and to drive neighboring springs.
+        val neighborsToSettle =
+            SpringAnimation(FloatValueHolder()).setSpring(createExpressiveDismissSpringForce())
+        var lastPosition = 0f
+        var startSettling = false
+        draggedTaskViewSpringAnimation.addUpdateListener { _, value, velocity ->
+            // Start the settling animation the first time the dragged task passes the origin (from
+            // negative displacement to positive displacement). We do not check for an exact value
+            // to compare to, as the update listener does not necessarily hit every value (e.g. a
+            // value of zero). Do not check again once it has started settling, as a spring can
+            // bounce past the origin multiple times depending on the stifness and damping ratio.
+            if (startSettling) return@addUpdateListener
+            if (lastPosition < 0 && value >= 0) {
+                startSettling = true
             }
-            .addEndListener { _, _, _, _ ->
-                if (isDismissing) {
-                    recentsView.dismissTask(
-                        draggedTaskView,
-                        /* animateTaskView = */ false,
-                        /* removeTask = */ true,
+            lastPosition = value
+            if (startSettling) {
+                neighborsToSettle.setStartVelocity(velocity).animateToFinalPosition(0f)
+            }
+        }
+
+        // Add tasks before dragged index, fanning out from the dragged task.
+        // The order they are added matters, as each spring drives the next.
+        var previousNeighbor = neighborsToSettle
+        getTasksAdjacentToDraggedTask(draggedTaskView, towardsStart = true).forEach {
+            previousNeighbor = createNeighboringTaskViewSpringAnimation(it, previousNeighbor)
+        }
+        // Add tasks after dragged index, fanning out from the dragged task.
+        // The order they are added matters, as each spring drives the next.
+        previousNeighbor = neighborsToSettle
+        getTasksAdjacentToDraggedTask(draggedTaskView, towardsStart = false).forEach {
+            previousNeighbor = createNeighboringTaskViewSpringAnimation(it, previousNeighbor)
+        }
+    }
+
+    /** Gets adjacent tasks either before or after the dragged task in visual order. */
+    private fun getTasksAdjacentToDraggedTask(
+        draggedTaskView: TaskView,
+        towardsStart: Boolean,
+    ): Sequence<TaskView> {
+        if (recentsView.showAsGrid()) {
+            return gridTaskViewInTabOrderSequence(draggedTaskView, towardsStart)
+        } else {
+            val taskViewList = taskViews.toList()
+            val draggedTaskViewIndex = taskViewList.indexOf(draggedTaskView)
+
+            return if (towardsStart) {
+                taskViewList.take(draggedTaskViewIndex).reversed().asSequence()
+            } else {
+                taskViewList.takeLast(taskViewList.size - draggedTaskViewIndex - 1).asSequence()
+            }
+        }
+    }
+
+    /**
+     * Returns a sequence of TaskViews in the grid, ordered according to tab navigation, starting
+     * from the dragged TaskView, in the direction of the provided delta.
+     *
+     * <p>A positive delta moves forward in the tab order towards the end of the grid, while a
+     * negative value moves backward towards the beginning.
+     */
+    private fun gridTaskViewInTabOrderSequence(
+        draggedTaskView: TaskView,
+        towardsStart: Boolean,
+    ): Sequence<TaskView> = sequence {
+        val taskGridNavHelper =
+            TaskGridNavHelper(
+                recentsView.topRowIdArray,
+                recentsView.bottomRowIdArray,
+                getLargeTaskViewIds(),
+                /* hasAddDesktopButton= */ false,
+            )
+        var nextTaskView: TaskView? = draggedTaskView
+        var previousTaskView: TaskView? = null
+        while (nextTaskView != previousTaskView && nextTaskView != null) {
+            previousTaskView = nextTaskView
+            nextTaskView =
+                recentsView.getTaskViewFromTaskViewId(
+                    taskGridNavHelper.getNextGridPage(
+                        nextTaskView.taskViewId,
+                        if (towardsStart) -1 else 1,
+                        TaskGridNavHelper.DIRECTION_TAB,
+                        /* cycle = */ false,
                     )
-                }
-                onEndRunnable()
+                )
+            if (nextTaskView != null && nextTaskView != previousTaskView) {
+                yield(nextTaskView)
             }
+        }
+    }
+
+    /** Creates a neighboring task view spring, driven by the spring of its neighbor. */
+    private fun createNeighboringTaskViewSpringAnimation(
+        taskView: TaskView,
+        previousNeighborSpringAnimation: SpringAnimation,
+    ): SpringAnimation {
+        val neighboringTaskViewSpringAnimation =
+            SpringAnimation(
+                    taskView,
+                    FloatPropertyCompat.createFloatPropertyCompat(
+                        taskView.secondaryDismissTranslationProperty
+                    ),
+                )
+                .setSpring(createExpressiveDismissSpringForce())
+        // Update live tile on spring animation.
+        if (taskView.isRunningTask && recentsView.enableDrawingLiveTile) {
+            neighboringTaskViewSpringAnimation.addUpdateListener { _, _, _ ->
+                recentsView.runActionOnRemoteHandles { remoteTargetHandle ->
+                    remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value =
+                        taskView.secondaryDismissTranslationProperty.get(taskView)
+                }
+                recentsView.redrawLiveTile()
+            }
+        }
+        // Drive current neighbor's spring with the previous neighbor's.
+        previousNeighborSpringAnimation.addUpdateListener { _, value, _ ->
+            neighboringTaskViewSpringAnimation.animateToFinalPosition(value)
+        }
+        return neighboringTaskViewSpringAnimation
+    }
+
+    private fun createExpressiveDismissSpringForce(): SpringForce {
+        val resourceProvider = DynamicResource.provider(recentsView.mContainer)
+        return SpringForce()
+            .setDampingRatio(
+                resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_damping_ratio)
+            )
+            .setStiffness(
+                resourceProvider.getFloat(R.dimen.expressive_dismiss_task_trans_y_stiffness)
+            )
     }
 
     companion object {
diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
index 7066d21..f2fa0c5 100644
--- a/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/util/TaskGridNavHelperTest.kt
@@ -447,6 +447,37 @@
     }
 
     /*
+                   5   3  [1]
+        CLEAR_ALL
+                   6   4   2
+    */
+    @Test
+    fun equalLengthRows_noFocused_onTop_pressTabWithShift_noCycle_staysOnTop() {
+        assertThat(
+                getNextGridPage(currentPageTaskViewId = 1, DIRECTION_TAB, delta = -1, cycle = false)
+            )
+            .isEqualTo(1)
+    }
+
+    /*
+                   5   3   1
+       [CLEAR_ALL]
+                   6   4   2
+    */
+    @Test
+    fun equalLengthRows_noFocused_onClearAll_pressTab_noCycle_staysOnClearAll() {
+        assertThat(
+                getNextGridPage(
+                    currentPageTaskViewId = CLEAR_ALL_PLACEHOLDER_ID,
+                    DIRECTION_TAB,
+                    delta = 1,
+                    cycle = false,
+                )
+            )
+            .isEqualTo(CLEAR_ALL_PLACEHOLDER_ID)
+    }
+
+    /*
                         5   3   1
            CLEAR_ALL                FOCUSED_TASK←--DESKTOP
                         6   4   2
@@ -783,10 +814,11 @@
         bottomIds: IntArray = IntArray.wrap(2, 4, 6),
         largeTileIds: List<Int> = emptyList(),
         hasAddDesktopButton: Boolean = false,
+        cycle: Boolean = true,
     ): Int {
         val taskGridNavHelper =
             TaskGridNavHelper(topIds, bottomIds, largeTileIds, hasAddDesktopButton)
-        return taskGridNavHelper.getNextGridPage(currentPageTaskViewId, delta, direction, true)
+        return taskGridNavHelper.getNextGridPage(currentPageTaskViewId, delta, direction, cycle)
     }
 
     private companion object {
diff --git a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
index e0560e2..79d3c19 100644
--- a/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
+++ b/quickstep/tests/src/com/android/quickstep/TaplTestsQuickstep.java
@@ -539,6 +539,24 @@
         }
     }
 
+    @Test
+    @PortraitLandscape
+    public void testDismissCancel() throws Exception {
+        startTestAppsWithCheck();
+        Overview overview = mLauncher.goHome().switchToOverview();
+        assertIsInState("Launcher internal state didn't switch to Overview",
+                ExpectedState.OVERVIEW);
+        final Integer numTasks = getFromRecentsView(RecentsView::getTaskViewCount);
+        OverviewTask task = overview.getCurrentTask();
+        assertNotNull("overview.getCurrentTask() returned null (2)", task);
+
+        task.dismissCancel();
+
+        runOnRecentsView(recentsView -> assertEquals(
+                "Canceling dismissing a task removed a task from Overview",
+                numTasks == null ? 0 : numTasks, recentsView.getTaskViewCount()));
+    }
+
     private void startTestAppsWithCheck() throws Exception {
         startTestApps();
         expectLaunchedAppState();
diff --git a/res/values/config.xml b/res/values/config.xml
index a545f0c..07f97bc 100644
--- a/res/values/config.xml
+++ b/res/values/config.xml
@@ -117,6 +117,10 @@
     <item name="swipe_up_rect_y_damping_ratio" type="dimen" format="float">0.95</item>
     <item name="swipe_up_rect_y_stiffness" type="dimen" format="float">400</item>
 
+    <!-- Expressive Dismiss -->
+    <item name="expressive_dismiss_task_trans_y_damping_ratio" type="dimen" format="float">0.6</item>
+    <item name="expressive_dismiss_task_trans_y_stiffness" type="dimen" format="float">900</item>
+
     <!-- Taskbar -->
     <!-- This is a float because it is converted to dp later in DeviceProfile -->
     <item name="taskbar_icon_size" type="dimen" format="float">0</item>
diff --git a/res/values/dimens.xml b/res/values/dimens.xml
index c48f140..c3cb31d 100644
--- a/res/values/dimens.xml
+++ b/res/values/dimens.xml
@@ -480,6 +480,7 @@
     <dimen name="task_thumbnail_icon_drawable_size_grid">0dp</dimen>
     <dimen name="task_thumbnail_icon_menu_drawable_touch_size">0dp</dimen>
     <dimen name="task_menu_edge_padding">0dp</dimen>
+    <dimen name="task_dismiss_max_undershoot">0dp</dimen>
     <dimen name="overview_task_margin">0dp</dimen>
     <dimen name="overview_actions_height">0dp</dimen>
     <dimen name="overview_actions_button_spacing">0dp</dimen>
diff --git a/tests/tapl/com/android/launcher3/tapl/OverviewTask.java b/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
index 2431ef5..1158521 100644
--- a/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
+++ b/tests/tapl/com/android/launcher3/tapl/OverviewTask.java
@@ -211,6 +211,36 @@
     }
 
     /**
+     * Starts dismissing the task by swiping up, then cancels, and task springs back to start.
+     */
+    public void dismissCancel() {
+        try (LauncherInstrumentation.Closable e = mLauncher.eventsCheck();
+             LauncherInstrumentation.Closable c = mLauncher.addContextLayer(
+                     "want to start dismissing an overview task then cancel")) {
+            verifyActiveContainer();
+            int taskCountBeforeDismiss = mOverview.getTaskCount();
+            mLauncher.assertNotEquals("Unable to find a task", 0, taskCountBeforeDismiss);
+
+            final Rect taskBounds = mLauncher.getVisibleBounds(mTask);
+            final int centerX = taskBounds.centerX();
+            final int centerY = taskBounds.bottom - 1;
+            final int endCenterY = centerY - (taskBounds.height() / 4);
+            mLauncher.executeAndWaitForLauncherEvent(
+                    // Set slowDown to true so we do not fling the task at the end of the drag, as
+                    // we want it to cancel and return back to the origin. We use 30 steps to
+                    // perform the gesture slowly as well, to avoid flinging.
+                    () -> mLauncher.linearGesture(centerX, centerY, centerX, endCenterY,
+                            /* steps= */ 30, /* slowDown= */ true,
+                            LauncherInstrumentation.GestureScope.DONT_EXPECT_PILFER),
+                    event -> TestProtocol.DISMISS_ANIMATION_ENDS_MESSAGE.equals(
+                            event.getClassName()),
+                    () -> "Canceling swipe to dismiss did not end with task at origin.",
+                    "cancel swiping to dismiss");
+
+        }
+    }
+
+    /**
      * Clicks the task.
      */
     public LaunchedAppState open() {