From 6aca9bedd532c258cb5ed8f049451735d312f85d Mon Sep 17 00:00:00 2001 From: Xiaoqian Dai Date: Sat, 1 Feb 2025 03:06:08 +0000 Subject: [PATCH] desktop-exploded-view: Implement the close button and its behavior Flag: com.android.launcher3.enable_desktop_exploded_view Test: Manual + newly added tapl tests in a following CL Bug: 353965691 Change-Id: Iba4603b84928885b1d50f5551adbacbd31e98cc3 --- .../res/layout/task_thumbnail_view_header.xml | 2 + .../quickstep/TaskShortcutFactory.java | 2 +- .../recents/ui/mapper/TaskUiStateMapper.kt | 43 ++++++++--- .../task/thumbnail/TaskThumbnailUiState.kt | 7 +- .../quickstep/views/DesktopTaskView.kt | 73 +++++++++++++------ .../quickstep/views/RecentsDismissUtils.kt | 2 +- .../android/quickstep/views/RecentsView.java | 22 ++++-- .../android/quickstep/views/TaskContainer.kt | 15 +++- .../views/TaskThumbnailViewHeader.kt | 3 + .../com/android/quickstep/views/TaskView.kt | 21 +++++- .../ui/mapper/TaskUiStateMapperTest.kt | 21 +++++- 11 files changed, 164 insertions(+), 47 deletions(-) diff --git a/quickstep/res/layout/task_thumbnail_view_header.xml b/quickstep/res/layout/task_thumbnail_view_header.xml index ecc1559c10..70e4a42b41 100644 --- a/quickstep/res/layout/task_thumbnail_view_header.xml +++ b/quickstep/res/layout/task_thumbnail_view_header.xml @@ -18,6 +18,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" + android:id="@+id/task_thumbnail_view_header" android:background="@drawable/task_thumbnail_header_bg"> recentsView = taskView.getRecentsView(); if (recentsView != null) { dismissTaskMenuView(); - recentsView.dismissTask(taskView, true, true); + recentsView.dismissTaskView(taskView, true, true); mTarget.getStatsLogManager().logger().withItemInfo(mTaskContainer.getItemInfo()) .log(LAUNCHER_SYSTEM_SHORTCUT_CLOSE_APP_TAP); } diff --git a/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt index fb622686dc..619075ff83 100644 --- a/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt +++ b/quickstep/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapper.kt @@ -16,6 +16,7 @@ package com.android.quickstep.recents.ui.mapper +import android.view.View.OnClickListener import com.android.quickstep.recents.ui.viewmodel.TaskData import com.android.quickstep.task.thumbnail.TaskThumbnailUiState import com.android.quickstep.task.thumbnail.TaskThumbnailUiState.BackgroundOnly @@ -36,29 +37,38 @@ object TaskUiStateMapper { * @param taskData The [TaskData] to convert. Can be null or a specific subclass. * @param isLiveTile A flag indicating whether the task data represents live tile. * @param hasHeader A flag indicating whether the UI should display a header. + * @param clickCloseListener A callback when the close button in the UI is clicked. * @return A [TaskThumbnailUiState] representing the UI state for the given task data. */ fun toTaskThumbnailUiState( taskData: TaskData?, isLiveTile: Boolean, hasHeader: Boolean, + clickCloseListener: OnClickListener?, ): TaskThumbnailUiState = when { taskData !is TaskData.Data -> Uninitialized - isLiveTile -> createLiveTileState(taskData, hasHeader) + isLiveTile -> createLiveTileState(taskData, hasHeader, clickCloseListener) isBackgroundOnly(taskData) -> BackgroundOnly(taskData.backgroundColor) isSnapshotSplash(taskData) -> - SnapshotSplash(createSnapshotState(taskData, hasHeader), taskData.icon) + SnapshotSplash( + createSnapshotState(taskData, hasHeader, clickCloseListener), + taskData.icon, + ) else -> Uninitialized } - private fun createSnapshotState(taskData: TaskData.Data, hasHeader: Boolean): Snapshot = - if (canHeaderBeCreated(taskData, hasHeader)) { + private fun createSnapshotState( + taskData: TaskData.Data, + hasHeader: Boolean, + clickCloseListener: OnClickListener?, + ): Snapshot = + if (canHeaderBeCreated(taskData, hasHeader, clickCloseListener)) { Snapshot.WithHeader( taskData.thumbnailData?.thumbnail!!, taskData.thumbnailData.rotation, taskData.backgroundColor, - ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!), + ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!, clickCloseListener!!), ) } else { Snapshot.WithoutHeader( @@ -74,13 +84,26 @@ object TaskUiStateMapper { private fun isSnapshotSplash(taskData: TaskData.Data) = taskData.thumbnailData?.thumbnail != null && !taskData.isLocked - private fun canHeaderBeCreated(taskData: TaskData.Data, hasHeader: Boolean) = - hasHeader && taskData.icon != null && taskData.titleDescription != null + private fun canHeaderBeCreated( + taskData: TaskData.Data, + hasHeader: Boolean, + clickCloseListener: OnClickListener?, + ) = + hasHeader && + taskData.icon != null && + taskData.titleDescription != null && + clickCloseListener != null - private fun createLiveTileState(taskData: TaskData.Data, hasHeader: Boolean) = - if (canHeaderBeCreated(taskData, hasHeader)) { + private fun createLiveTileState( + taskData: TaskData.Data, + hasHeader: Boolean, + clickCloseListener: OnClickListener?, + ) = + if (canHeaderBeCreated(taskData, hasHeader, clickCloseListener)) { // TODO(http://b/353965691): figure out what to do when `icon` or `titleDescription` is // null. - LiveTile.WithHeader(ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!)) + LiveTile.WithHeader( + ThumbnailHeader(taskData.icon!!, taskData.titleDescription!!, clickCloseListener!!) + ) } else LiveTile.WithoutHeader } diff --git a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailUiState.kt b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailUiState.kt index 61185448be..db593d34d3 100644 --- a/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailUiState.kt +++ b/quickstep/src/com/android/quickstep/task/thumbnail/TaskThumbnailUiState.kt @@ -19,6 +19,7 @@ package com.android.quickstep.task.thumbnail import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.view.Surface +import android.view.View.OnClickListener import androidx.annotation.ColorInt sealed class TaskThumbnailUiState { @@ -54,5 +55,9 @@ sealed class TaskThumbnailUiState { ) : Snapshot() } - data class ThumbnailHeader(val icon: Drawable, val title: String) + data class ThumbnailHeader( + val icon: Drawable, + val title: String, + val clickCloseListener: OnClickListener, + ) } diff --git a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt index 9bb8f3dd07..75f3b69140 100644 --- a/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt +++ b/quickstep/src/com/android/quickstep/views/DesktopTaskView.kt @@ -345,14 +345,7 @@ class DesktopTaskView @JvmOverloads constructor(context: Context, attrs: Attribu explodeProgress = 0.0f viewModel = null visibility = VISIBLE - taskContainers.forEach { - contentView.removeView(it.snapshotView) - if (enableRefactorTaskThumbnail()) { - taskThumbnailViewPool!!.recycle(it.thumbnailView) - } else { - taskThumbnailViewDeprecatedPool!!.recycle(it.thumbnailViewDeprecated) - } - } + taskContainers.forEach { removeAndRecycleThumbnailView(it) } } @SuppressLint("RtlHardcoded") @@ -360,19 +353,7 @@ class DesktopTaskView @JvmOverloads constructor(context: Context, attrs: Attribu super.updateTaskSize(lastComputedTaskSize, lastComputedGridTaskSize) 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) - - fullscreenTaskPositions = - taskContainers.map { - DesktopTaskBoundsData(it.task.key.id, it.task.appBounds ?: DEFAULT_BOUNDS) - } - - if (enableDesktopExplodedView()) { - viewModel?.organizeDesktopTasks(desktopSize, fullscreenTaskPositions) - } - positionTaskWindows() + updateTaskPositions() } override fun onTaskListVisibilityChanged(visible: Boolean, changes: Int) { @@ -458,6 +439,56 @@ class DesktopTaskView @JvmOverloads constructor(context: Context, attrs: Attribu ViewUtils.addAccessibleChildToList(backgroundView, outChildren) } + fun removeTaskFromExplodedView(taskId: Int, animate: Boolean) { + if (!enableDesktopExplodedView()) { + Log.e( + TAG, + "removeTaskFromExplodedView called when enableDesktopExplodedView flag is false", + ) + return + } + + // Remove the task's [taskContainer] and its associated Views. + val taskContainer = getTaskContainerById(taskId) ?: return + removeAndRecycleThumbnailView(taskContainer) + taskContainer.destroy() + taskContainers = taskContainers.filterNot { it == taskContainer } + + // Dismiss the current DesktopTaskView if all its windows are closed. + if (taskContainers.isEmpty()) { + recentsView?.dismissTaskView(this, animate, /* removeTask= */ true) + } else { + // Otherwise, re-position the remaining task windows. + // TODO(b/353949276): Implement the re-layout animations. + updateTaskPositions() + } + } + + private fun removeAndRecycleThumbnailView(taskContainer: TaskContainer) { + contentView.removeView(taskContainer.snapshotView) + if (enableRefactorTaskThumbnail()) { + taskThumbnailViewPool!!.recycle(taskContainer.thumbnailView) + } else { + taskThumbnailViewDeprecatedPool!!.recycle(taskContainer.thumbnailViewDeprecated) + } + } + + private fun updateTaskPositions() { + 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) + + fullscreenTaskPositions = + taskContainers.map { + DesktopTaskBoundsData(it.task.key.id, it.task.appBounds ?: DEFAULT_BOUNDS) + } + + if (enableDesktopExplodedView()) { + viewModel?.organizeDesktopTasks(desktopSize, fullscreenTaskPositions) + } + positionTaskWindows() + } + companion object { private const val TAG = "DesktopTaskView" private const val DEBUG = false diff --git a/quickstep/src/com/android/quickstep/views/RecentsDismissUtils.kt b/quickstep/src/com/android/quickstep/views/RecentsDismissUtils.kt index 96eed87a5f..3430b39ac1 100644 --- a/quickstep/src/com/android/quickstep/views/RecentsDismissUtils.kt +++ b/quickstep/src/com/android/quickstep/views/RecentsDismissUtils.kt @@ -76,7 +76,7 @@ class RecentsDismissUtils(private val recentsView: RecentsView<*, *>) { } .addEndListener { _, _, _, _ -> if (isDismissing) { - recentsView.dismissTask( + recentsView.dismissTaskView( draggedTaskView, /* animateTaskView = */ false, /* removeTask = */ true, diff --git a/quickstep/src/com/android/quickstep/views/RecentsView.java b/quickstep/src/com/android/quickstep/views/RecentsView.java index 9e53f11679..edcffb86d4 100644 --- a/quickstep/src/com/android/quickstep/views/RecentsView.java +++ b/quickstep/src/com/android/quickstep/views/RecentsView.java @@ -676,11 +676,11 @@ public abstract class RecentsView< MAIN_EXECUTOR, apkRemoved -> { if (apkRemoved) { - dismissTask(taskId); + dismissTask(taskId, /*animate=*/true, /*removeTask=*/false); } else { mModel.isTaskRemoved(taskKey.id, taskRemoved -> { if (taskRemoved) { - dismissTask(taskId); + dismissTask(taskId, /*animate=*/true, /*removeTask=*/false); } }, RecentsFilterState.getFilter(mFilterState.getPackageNameToFilter())); } @@ -4627,17 +4627,27 @@ public abstract class RecentsView< } @UiThread - private void dismissTask(int taskId) { + public void dismissTask(int taskId, boolean animate, boolean removeTask) { TaskView taskView = getTaskViewByTaskId(taskId); if (taskView == null) { Log.d(TAG, "dismissTask: " + taskId + ", no associated TaskView"); return; } Log.d(TAG, "dismissTask: " + taskId); - dismissTask(taskView, true /* animate */, false /* removeTask */); + + if (enableDesktopExplodedView() && taskView instanceof DesktopTaskView desktopTaskView) { + desktopTaskView.removeTaskFromExplodedView(taskId, animate); + + if (removeTask) { + ActivityManagerWrapper.getInstance().removeTask(taskId); + } + } else { + dismissTaskView(taskView, animate, removeTask); + } } - public void dismissTask(TaskView taskView, boolean animateTaskView, boolean removeTask) { + /** Dismisses the entire [taskView]. */ + public void dismissTaskView(TaskView taskView, boolean animateTaskView, boolean removeTask) { PendingAnimation pa = new PendingAnimation(DISMISS_TASK_DURATION); createTaskDismissAnimation(pa, taskView, animateTaskView, removeTask, DISMISS_TASK_DURATION, false /* dismissingForSplitSelection*/); @@ -4653,7 +4663,7 @@ public abstract class RecentsView< private void dismissCurrentTask() { TaskView taskView = getNextPageTaskView(); if (taskView != null) { - dismissTask(taskView, true /*animateTaskView*/, true /*removeTask*/); + dismissTaskView(taskView, true /*animateTaskView*/, true /*removeTask*/); } } diff --git a/quickstep/src/com/android/quickstep/views/TaskContainer.kt b/quickstep/src/com/android/quickstep/views/TaskContainer.kt index 7301cfc3ca..6339c5e1d3 100644 --- a/quickstep/src/com/android/quickstep/views/TaskContainer.kt +++ b/quickstep/src/com/android/quickstep/views/TaskContainer.kt @@ -19,6 +19,7 @@ package com.android.quickstep.views import android.graphics.Bitmap import android.graphics.Matrix import android.view.View +import android.view.View.OnClickListener import com.android.launcher3.Flags.enableRefactorTaskThumbnail import com.android.launcher3.model.data.TaskViewItemInfo import com.android.launcher3.util.SplitConfigurationOptions @@ -127,9 +128,19 @@ class TaskContainer( overlay.addChildForAccessibility(outChildren) } - fun setState(state: TaskData?, liveTile: Boolean, hasHeader: Boolean) { + fun setState( + state: TaskData?, + liveTile: Boolean, + hasHeader: Boolean, + clickCloseListener: OnClickListener?, + ) { thumbnailView.setState( - TaskUiStateMapper.toTaskThumbnailUiState(state, liveTile, hasHeader), + TaskUiStateMapper.toTaskThumbnailUiState( + state, + liveTile, + hasHeader, + clickCloseListener, + ), state?.taskId, ) thumbnailData = if (state is TaskData.Data) state.thumbnailData else null diff --git a/quickstep/src/com/android/quickstep/views/TaskThumbnailViewHeader.kt b/quickstep/src/com/android/quickstep/views/TaskThumbnailViewHeader.kt index 9eb294ac76..9a8805bf0f 100644 --- a/quickstep/src/com/android/quickstep/views/TaskThumbnailViewHeader.kt +++ b/quickstep/src/com/android/quickstep/views/TaskThumbnailViewHeader.kt @@ -19,6 +19,7 @@ package com.android.quickstep.views import android.content.Context import android.util.AttributeSet import android.widget.FrameLayout +import android.widget.ImageButton import android.widget.ImageView import android.widget.TextView import com.android.launcher3.R @@ -30,9 +31,11 @@ constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context private val headerTitleView: TextView by lazy { findViewById(R.id.header_app_title) } private val headerIconView: ImageView by lazy { findViewById(R.id.header_app_icon) } + private val headerCloseButton: ImageButton by lazy { findViewById(R.id.header_close_button) } fun setHeader(header: ThumbnailHeader) { headerTitleView.setText(header.title) headerIconView.setImageDrawable(header.icon) + headerCloseButton.setOnClickListener(header.clickCloseListener) } } diff --git a/quickstep/src/com/android/quickstep/views/TaskView.kt b/quickstep/src/com/android/quickstep/views/TaskView.kt index ba5423217e..0e5382a235 100644 --- a/quickstep/src/com/android/quickstep/views/TaskView.kt +++ b/quickstep/src/com/android/quickstep/views/TaskView.kt @@ -44,6 +44,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.view.updateLayoutParams import com.android.app.animation.Interpolators import com.android.launcher3.Flags.enableCursorHoverStates +import com.android.launcher3.Flags.enableDesktopExplodedView import com.android.launcher3.Flags.enableGridOnlyOverview import com.android.launcher3.Flags.enableHoverOfChildElementsInTaskview import com.android.launcher3.Flags.enableLargeDesktopWindowingTile @@ -768,11 +769,27 @@ constructor( // Updating containers val mapOfTasks = state.tasks.associateBy { it.taskId } taskContainers.forEach { container -> - val containerState = mapOfTasks[container.task.key.id] + val taskId = container.task.key.id + val containerState = mapOfTasks[taskId] + val shouldHaveHeader = (type == TaskViewType.DESKTOP) && enableDesktopExplodedView() container.setState( state = containerState, liveTile = state.isLiveTile, - hasHeader = state.hasHeader, + hasHeader = shouldHaveHeader, + clickCloseListener = + if (shouldHaveHeader) { + { + // Update the layout UI to remove this task from the layout grid, and + // remove the task from ActivityManager afterwards. + recentsView?.dismissTask( + taskId, + /* animate= */ true, + /* removeTask= */ true, + ) + } + } else { + null + }, ) updateThumbnailValidity(container) updateThumbnailMatrix( diff --git a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt index 124045fd69..7ca194afcf 100644 --- a/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt +++ b/quickstep/tests/multivalentTests/src/com/android/quickstep/recents/ui/mapper/TaskUiStateMapperTest.kt @@ -21,6 +21,7 @@ import android.graphics.Color import android.graphics.drawable.ShapeDrawable import android.platform.test.annotations.EnableFlags import android.view.Surface +import android.view.View import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.launcher3.Flags import com.android.quickstep.recents.ui.viewmodel.TaskData @@ -43,6 +44,7 @@ class TaskUiStateMapperTest { taskData = null, isLiveTile = false, hasHeader = false, + clickCloseListener = null, ) assertThat(result).isEqualTo(TaskThumbnailUiState.Uninitialized) } @@ -57,6 +59,7 @@ class TaskUiStateMapperTest { taskData = input, isLiveTile = true, hasHeader = false, + clickCloseListener = null, ) assertThat(result).isEqualTo(LiveTile.WithoutHeader) } @@ -72,14 +75,18 @@ class TaskUiStateMapperTest { TASK_DATA.copy(isLocked = true), TASK_DATA.copy(title = null), ) + val closeCallback = View.OnClickListener {} val expected = - LiveTile.WithHeader(header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION)) + LiveTile.WithHeader( + header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION, closeCallback) + ) inputs.forEach { taskData -> val result = TaskUiStateMapper.toTaskThumbnailUiState( taskData = taskData, isLiveTile = true, hasHeader = true, + clickCloseListener = closeCallback, ) assertThat(result).isEqualTo(expected) } @@ -101,6 +108,7 @@ class TaskUiStateMapperTest { taskData = taskData, isLiveTile = true, hasHeader = true, + clickCloseListener = {}, ) assertThat(result).isEqualTo(LiveTile.WithoutHeader) } @@ -113,6 +121,7 @@ class TaskUiStateMapperTest { taskData = TASK_DATA, isLiveTile = false, hasHeader = false, + clickCloseListener = null, ) val expected = @@ -133,6 +142,7 @@ class TaskUiStateMapperTest { @Test fun taskData_isStaticTile_withHeader_returns_SnapshotSplashWithHeader() { val inputs = listOf(TASK_DATA, TASK_DATA.copy(title = null)) + val closeCallback = View.OnClickListener {} val expected = TaskThumbnailUiState.SnapshotSplash( snapshot = @@ -140,7 +150,7 @@ class TaskUiStateMapperTest { backgroundColor = TASK_BACKGROUND_COLOR, bitmap = TASK_THUMBNAIL, thumbnailRotation = Surface.ROTATION_0, - header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION), + header = ThumbnailHeader(TASK_ICON, TASK_TITLE_DESCRIPTION, closeCallback), ), splash = TASK_ICON, ) @@ -150,6 +160,7 @@ class TaskUiStateMapperTest { taskData = taskData, isLiveTile = false, hasHeader = true, + clickCloseListener = closeCallback, ) assertThat(result).isEqualTo(expected) } @@ -176,6 +187,7 @@ class TaskUiStateMapperTest { taskData = taskData, isLiveTile = false, hasHeader = true, + clickCloseListener = {}, ) assertThat(result).isInstanceOf(TaskThumbnailUiState.SnapshotSplash::class.java) @@ -191,6 +203,7 @@ class TaskUiStateMapperTest { taskData = TASK_DATA.copy(thumbnailData = null), isLiveTile = false, hasHeader = false, + clickCloseListener = null, ) val expected = TaskThumbnailUiState.BackgroundOnly(TASK_BACKGROUND_COLOR) @@ -204,6 +217,7 @@ class TaskUiStateMapperTest { taskData = TASK_DATA.copy(isLocked = true), isLiveTile = false, hasHeader = false, + clickCloseListener = null, ) val expected = TaskThumbnailUiState.BackgroundOnly(TASK_BACKGROUND_COLOR) @@ -212,6 +226,7 @@ class TaskUiStateMapperTest { private companion object { const val TASK_TITLE_DESCRIPTION = "Title Description 1" + var TASK_ID = 1 val TASK_ICON = ShapeDrawable() val TASK_THUMBNAIL = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) val TASK_THUMBNAIL_DATA = @@ -219,7 +234,7 @@ class TaskUiStateMapperTest { val TASK_BACKGROUND_COLOR = Color.rgb(1, 2, 3) val TASK_DATA = TaskData.Data( - 1, + TASK_ID, title = "Task 1", titleDescription = TASK_TITLE_DESCRIPTION, icon = TASK_ICON,