Add spring animations for neighboring tasks on dismiss cancel

Fix: 389083088
Test: TaplTestsQuickstep#testDismissCancel
Test: TaskGridNavHelperTest
Flag: com.android.launcher3.enable_expressive_dismiss_task_motion
Change-Id: I1240519eea499b5839d64e537588bde3cb0a424b
This commit is contained in:
Pat Manning
2025-01-29 15:02:25 +00:00
parent 4498f095fe
commit a82868d7df
10 changed files with 278 additions and 37 deletions
+3
View File
@@ -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>
@@ -20,6 +20,7 @@ import android.view.MotionEvent
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 @@ CONTAINER : RecentsViewContainer {
0f,
dismissLength.toFloat(),
0f,
DISMISS_MAX_UNDERSHOOT,
container.resources.getDimension(R.dimen.task_dismiss_max_undershoot),
DECELERATE,
)
taskBeingDragged.secondaryDismissTranslationProperty.setValue(
@@ -207,6 +208,5 @@ CONTAINER : RecentsViewContainer {
companion object {
private const val DISMISS_THRESHOLD_FRACTION = 0.5f
private const val DISMISS_MAX_UNDERSHOOT = 25f
}
}
@@ -103,23 +103,28 @@ public class TaskGridNavHelper {
}
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)
@@ -6379,7 +6379,7 @@ public abstract class RecentsView<
}
/**
* @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 @@ public abstract class RecentsView<
* 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,
@@ -20,6 +20,7 @@ import android.graphics.Rect
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.touch.SingleAxisSwipeDetector
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 @@ class RecentsViewUtils(private val recentsView: RecentsView<*, *>) {
* 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 @@ class RecentsViewUtils(private val recentsView: RecentsView<*, *>) {
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 {
@@ -446,6 +446,37 @@ class TaskGridNavHelperTest {
.isEqualTo(1)
}
/*
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
@@ -783,10 +814,11 @@ class TaskGridNavHelperTest {
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 {
@@ -539,6 +539,24 @@ public class TaplTestsQuickstep extends AbstractQuickStepTest {
}
}
@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();
+4
View File
@@ -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>
+1
View File
@@ -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>
@@ -210,6 +210,36 @@ public final class OverviewTask {
: List.of(mOverview.getCurrentTask().getTaskCenterX());
}
/**
* 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.
*/