diff options
| author | 2025-02-03 17:37:55 -0500 | |
|---|---|---|
| committer | 2025-02-11 09:15:02 -0500 | |
| commit | f57800bcacbc6a6c00b8e47ccdaeb56b7312ada5 (patch) | |
| tree | 32ee9d53956c8676eac7c5f5e058aaf95d3cd057 | |
| parent | 6b8460c4a6150dd83a484e834069c0db27a0116a (diff) | |
Add badges to edit tiles
This allows for quick add/remove actions
Test: manually
Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Bug: 379116386
Change-Id: I44bb85a1177491836a46b6b56e31aacb4c7ba72b
5 files changed, 171 insertions, 71 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt index 7701b9087e23..ed98d0cfaa26 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.clipScrollableContainer import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -56,14 +57,18 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -86,9 +91,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.isSpecified import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned @@ -105,10 +113,13 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.toSize import androidx.compose.ui.util.fastMap +import androidx.compose.ui.zIndex import com.android.app.tracing.coroutines.launchTraced as launch import com.android.compose.animation.bounceable import com.android.compose.modifiers.height @@ -131,6 +142,7 @@ import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaul import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AUTO_SCROLL_SPEED import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AvailableTilesGridMinHeight import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.CurrentTilesGridPadding +import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.TileBadgeSize import com.android.systemui.qs.panels.ui.compose.selection.MutableSelectionState import com.android.systemui.qs.panels.ui.compose.selection.ResizableTileContainer import com.android.systemui.qs.panels.ui.compose.selection.ResizingState @@ -143,6 +155,7 @@ import com.android.systemui.qs.panels.ui.compose.selection.selectableTile import com.android.systemui.qs.panels.ui.model.GridCell import com.android.systemui.qs.panels.ui.model.SpacerGridCell import com.android.systemui.qs.panels.ui.model.TileGridCell +import com.android.systemui.qs.panels.ui.viewmodel.AvailableEditActions import com.android.systemui.qs.panels.ui.viewmodel.BounceableTileViewModel import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec @@ -152,6 +165,7 @@ import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.launch object TileType @@ -261,6 +275,7 @@ fun DefaultEditTileGrid( columns, largeTilesSpan, onResize, + onRemoveTile, onSetTiles, ) @@ -385,6 +400,7 @@ private fun CurrentTilesGrid( columns: Int, largeTilesSpan: Int, onResize: (TileSpec, toIcon: Boolean) -> Unit, + onRemoveTile: (TileSpec) -> Unit, onSetTiles: (List<TileSpec>) -> Unit, ) { val currentListState by rememberUpdatedState(listState) @@ -424,8 +440,15 @@ private fun CurrentTilesGrid( } .testTag(CURRENT_TILES_GRID_TEST_TAG), ) { - EditTiles(cells, columns, listState, selectionState, coroutineScope, largeTilesSpan) { - resizingOperation -> + EditTiles( + cells, + columns, + listState, + selectionState, + coroutineScope, + largeTilesSpan, + onRemoveTile, + ) { resizingOperation -> when (resizingOperation) { is TemporaryResizeOperation -> { currentListState.resizeTile(resizingOperation.spec, resizingOperation.toIcon) @@ -530,6 +553,7 @@ fun LazyGridScope.EditTiles( selectionState: MutableSelectionState, coroutineScope: CoroutineScope, largeTilesSpan: Int, + onRemoveTile: (TileSpec) -> Unit, onResize: (operation: ResizeOperation) -> Unit, ) { items( @@ -558,6 +582,7 @@ fun LazyGridScope.EditTiles( dragAndDropState = dragAndDropState, selectionState = selectionState, onResize = onResize, + onRemoveTile = onRemoveTile, coroutineScope = coroutineScope, bounceableInfo = cells.bounceableInfo(index, columns), largeTilesSpan = largeTilesSpan, @@ -576,6 +601,7 @@ private fun TileGridCell( dragAndDropState: DragAndDropState, selectionState: MutableSelectionState, onResize: (operation: ResizeOperation) -> Unit, + onRemoveTile: (TileSpec) -> Unit, coroutineScope: CoroutineScope, largeTilesSpan: Int, bounceableInfo: BounceableInfo, @@ -583,6 +609,8 @@ private fun TileGridCell( ) { val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1) var selected by remember { mutableStateOf(false) } + val showRemovalBadge = + !selected && cell.tile.availableEditActions.contains(AvailableEditActions.REMOVE) val selectionAlpha by animateFloatAsState( targetValue = if (selected) 1f else 0f, @@ -682,6 +710,15 @@ private fun TileGridCell( ) { EditTile(tile = cell.tile, state = state, progress = progress) } + + if (showRemovalBadge) { + TileBadge( + icon = Icons.Default.Remove, + contentDescription = stringResource(R.string.qs_customize_remove), + ) { + onRemoveTile(cell.tile.tileSpec) + } + } } } @@ -708,27 +745,35 @@ private fun AvailableTileGridCell( verticalArrangement = spacedBy(CommonTileDefaults.TilePadding, Alignment.Top), modifier = modifier, ) { - Box( - Modifier.fillMaxWidth() - .height(TileHeight) - .clickable(onClick = onClick, onClickLabel = onClickActionName) - .semantics(mergeDescendants = true) { this.stateDescription = stateDescription } - .dragAndDropTileSource( - SizedTileImpl(cell.tile, cell.width), - dragAndDropState, - DragType.Add, - ) { - selectionState.unSelect() - } - .tileBackground(colors.background) - .tilePadding() - ) { - // Icon - SmallTileContent( - iconProvider = { cell.tile.icon }, - color = colors.icon, - animateToEnd = true, - modifier = Modifier.align(Alignment.Center), + Box { + Box( + Modifier.fillMaxWidth() + .height(TileHeight) + .clickable(onClick = onClick, onClickLabel = onClickActionName) + .semantics(mergeDescendants = true) { this.stateDescription = stateDescription } + .dragAndDropTileSource( + SizedTileImpl(cell.tile, cell.width), + dragAndDropState, + DragType.Add, + ) { + selectionState.unSelect() + } + .tileBackground(colors.background) + .tilePadding() + ) { + // Icon + SmallTileContent( + iconProvider = { cell.tile.icon }, + color = colors.icon, + animateToEnd = true, + modifier = Modifier.align(Alignment.Center), + ) + } + + TileBadge( + icon = Icons.Default.Add, + contentDescription = onClickActionName, + onClick = onClick, ) } Box(Modifier.fillMaxSize()) { @@ -745,6 +790,39 @@ private fun AvailableTileGridCell( } @Composable +private fun TileBadge(icon: ImageVector, contentDescription: String?, onClick: () -> Unit) { + // Use a higher zIndex than the tile to draw over it, and manually create the touch target as + // we're drawing over neighbor tiles as well. + val minTouchTargetSize = LocalMinimumInteractiveComponentSize.current + + Box( + Modifier.zIndex(2f) + .layout { measurable, constraints -> + val size = minTouchTargetSize.roundToPx() + val placeable = measurable.measure(Constraints(size)) + layout(placeable.width, placeable.height) { + val iconRadius = TileBadgeSize.roundToPx() / 2 + val x = constraints.maxWidth - placeable.width / 2 - iconRadius + val y = 0 - placeable.height / 2 + iconRadius + placeable.place(x, y) + } + } + .systemGestureExclusion { Rect(Offset.Zero, it.size.toSize()) } + .pointerInput(Unit) { detectTapGestures { onClick() } } + ) { + val secondaryColor = MaterialTheme.colorScheme.secondary + Icon( + icon, + contentDescription = contentDescription, + modifier = + Modifier.size(TileBadgeSize).align(Alignment.Center).drawBehind { + drawCircle(secondaryColor) + }, + ) + } +} + +@Composable private fun SpacerGridCell(modifier: Modifier = Modifier) { // By default, spacers are invisible and exist purely to catch drag movements Box(modifier.height(TileHeight).fillMaxWidth()) @@ -829,6 +907,7 @@ private object EditModeTileDefaults { const val AUTO_SCROLL_SPEED = 2 // 2ms per pixel val CurrentTilesGridPadding = 8.dp val AvailableTilesGridMinHeight = 200.dp + val TileBadgeSize = 20.dp @Composable fun editTileColors(): TileColors = diff --git a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt index c1545e1263db..7c472638da63 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt @@ -81,7 +81,7 @@ fun ResizableTileContainer( state = state, modifier = // Higher zIndex to make sure the handle is drawn above the content - Modifier.zIndex(2f), + Modifier.zIndex(if (selected) 2f else 1f), ) } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt index 26cf4a261289..92b26ea3a8ef 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt @@ -22,14 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasContentDescription -import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -100,7 +93,10 @@ class DragAndDropTest : SysuiTestCase() { composeRule.onNodeWithText("Remove").assertExists() // Every other tile should still be in the same order - composeRule.assertTileGridContainsExactly(listOf("tileB", "tileC", "tileD_large", "tileE")) + composeRule.assertGridContainsExactly( + CURRENT_TILES_GRID_TEST_TAG, + listOf("tileB", "tileC", "tileD_large", "tileE"), + ) } @Test @@ -125,8 +121,9 @@ class DragAndDropTest : SysuiTestCase() { composeRule.onNodeWithText("Remove").assertDoesNotExist() // Tile A and B should swap places - composeRule.assertTileGridContainsExactly( - listOf("tileB", "tileA", "tileC", "tileD_large", "tileE") + composeRule.assertGridContainsExactly( + CURRENT_TILES_GRID_TEST_TAG, + listOf("tileB", "tileA", "tileC", "tileD_large", "tileE"), ) } @@ -152,7 +149,10 @@ class DragAndDropTest : SysuiTestCase() { composeRule.onNodeWithText("Remove").assertDoesNotExist() // Tile A is gone - composeRule.assertTileGridContainsExactly(listOf("tileB", "tileC", "tileD_large", "tileE")) + composeRule.assertGridContainsExactly( + CURRENT_TILES_GRID_TEST_TAG, + listOf("tileB", "tileC", "tileD_large", "tileE"), + ) } @Test @@ -166,7 +166,7 @@ class DragAndDropTest : SysuiTestCase() { } composeRule.waitForIdle() - listState.onStarted(createEditTile("newTile"), DragType.Add) + listState.onStarted(createEditTile("tile_new"), DragType.Add) // Insert after tileD, which is at index 4 // [ a ] [ b ] [ c ] [ empty ] // [ tile d ] [ e ] @@ -179,23 +179,13 @@ class DragAndDropTest : SysuiTestCase() { // Remove drop zone should disappear composeRule.onNodeWithText("Remove").assertDoesNotExist() - // newTile is added after tileD - composeRule.assertTileGridContainsExactly( - listOf("tileA", "tileB", "tileC", "tileD_large", "newTile", "tileE") + // tile_new is added after tileD + composeRule.assertGridContainsExactly( + CURRENT_TILES_GRID_TEST_TAG, + listOf("tileA", "tileB", "tileC", "tileD_large", "tile_new", "tileE"), ) } - private fun ComposeContentTestRule.assertTileGridContainsExactly(specs: List<String>) { - onNodeWithTag(CURRENT_TILES_GRID_TEST_TAG) - .onChildren() - .filter(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDescription)) - .apply { - fetchSemanticsNodes().forEachIndexed { index, _ -> - get(index).assert(hasContentDescription(specs[index])) - } - } - } - companion object { private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid" private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid" diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt index 4e8b0bcd374c..8c09b81744d7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt @@ -23,16 +23,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.filter -import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.text.AnnotatedString @@ -113,20 +106,6 @@ class EditModeTest : SysuiTestCase() { specs: List<String> ) = assertGridContainsExactly(AVAILABLE_TILES_GRID_TEST_TAG, specs) - private fun ComposeContentTestRule.assertGridContainsExactly( - testTag: String, - specs: List<String>, - ) { - onNodeWithTag(testTag) - .onChildren() - .filter(SemanticsMatcher.keyIsDefined(SemanticsProperties.ContentDescription)) - .apply { - fetchSemanticsNodes().forEachIndexed { index, _ -> - get(index).assert(hasContentDescription(specs[index])) - } - } - } - companion object { private const val CURRENT_TILES_GRID_TEST_TAG = "CurrentTilesGrid" private const val AVAILABLE_TILES_GRID_TEST_TAG = "AvailableTilesGrid" diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt new file mode 100644 index 000000000000..dbccf864fc26 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt @@ -0,0 +1,52 @@ +/* + * 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.systemui.qs.panels.ui.compose + +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.filter +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithTag + +/** Asserts that the tile grid with [testTag] contains exactly [specs] */ +fun ComposeContentTestRule.assertGridContainsExactly(testTag: String, specs: List<String>) { + onNodeWithTag(testTag) + .onChildren() + .filter(SemanticsMatcher.contentDescriptionStartsWith("tile")) + .apply { + fetchSemanticsNodes().forEachIndexed { index, _ -> + get(index).assert(hasContentDescription(specs[index])) + } + } +} + +/** + * A [SemanticsMatcher] that matches anything with a content description starting with the given + * [prefix] + */ +fun SemanticsMatcher.Companion.contentDescriptionStartsWith(prefix: String): SemanticsMatcher { + return SemanticsMatcher("${SemanticsProperties.ContentDescription.name} starts with $prefix") { + semanticsNode -> + semanticsNode.config.getOrNull(SemanticsProperties.ContentDescription)?.any { + it.startsWith(prefix) + } ?: false + } +} |