Loading packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt +102 −23 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -261,6 +275,7 @@ fun DefaultEditTileGrid( columns, largeTilesSpan, onResize, onRemoveTile, onSetTiles, ) Loading Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -530,6 +553,7 @@ fun LazyGridScope.EditTiles( selectionState: MutableSelectionState, coroutineScope: CoroutineScope, largeTilesSpan: Int, onRemoveTile: (TileSpec) -> Unit, onResize: (operation: ResizeOperation) -> Unit, ) { items( Loading Loading @@ -558,6 +582,7 @@ fun LazyGridScope.EditTiles( dragAndDropState = dragAndDropState, selectionState = selectionState, onResize = onResize, onRemoveTile = onRemoveTile, coroutineScope = coroutineScope, bounceableInfo = cells.bounceableInfo(index, columns), largeTilesSpan = largeTilesSpan, Loading @@ -576,6 +601,7 @@ private fun TileGridCell( dragAndDropState: DragAndDropState, selectionState: MutableSelectionState, onResize: (operation: ResizeOperation) -> Unit, onRemoveTile: (TileSpec) -> Unit, coroutineScope: CoroutineScope, largeTilesSpan: Int, bounceableInfo: BounceableInfo, Loading @@ -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, Loading Loading @@ -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) } } } } Loading @@ -708,6 +745,7 @@ private fun AvailableTileGridCell( verticalArrangement = spacedBy(CommonTileDefaults.TilePadding, Alignment.Top), modifier = modifier, ) { Box { Box( Modifier.fillMaxWidth() .height(TileHeight) Loading @@ -731,6 +769,13 @@ private fun AvailableTileGridCell( modifier = Modifier.align(Alignment.Center), ) } TileBadge( icon = Icons.Default.Add, contentDescription = onClickActionName, onClick = onClick, ) } Box(Modifier.fillMaxSize()) { Text( cell.tile.label.text, Loading @@ -744,6 +789,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 Loading Loading @@ -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 = Loading packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt +1 −1 Original line number Diff line number Diff line Loading @@ -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), ) } } Loading packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt +16 −26 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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"), ) } Loading @@ -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 Loading @@ -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 ] Loading @@ -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" Loading packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt +0 −21 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt 0 → 100644 +52 −0 Original line number Diff line number Diff line /* * 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 } } Loading
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt +102 −23 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -261,6 +275,7 @@ fun DefaultEditTileGrid( columns, largeTilesSpan, onResize, onRemoveTile, onSetTiles, ) Loading Loading @@ -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) Loading Loading @@ -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) Loading Loading @@ -530,6 +553,7 @@ fun LazyGridScope.EditTiles( selectionState: MutableSelectionState, coroutineScope: CoroutineScope, largeTilesSpan: Int, onRemoveTile: (TileSpec) -> Unit, onResize: (operation: ResizeOperation) -> Unit, ) { items( Loading Loading @@ -558,6 +582,7 @@ fun LazyGridScope.EditTiles( dragAndDropState = dragAndDropState, selectionState = selectionState, onResize = onResize, onRemoveTile = onRemoveTile, coroutineScope = coroutineScope, bounceableInfo = cells.bounceableInfo(index, columns), largeTilesSpan = largeTilesSpan, Loading @@ -576,6 +601,7 @@ private fun TileGridCell( dragAndDropState: DragAndDropState, selectionState: MutableSelectionState, onResize: (operation: ResizeOperation) -> Unit, onRemoveTile: (TileSpec) -> Unit, coroutineScope: CoroutineScope, largeTilesSpan: Int, bounceableInfo: BounceableInfo, Loading @@ -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, Loading Loading @@ -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) } } } } Loading @@ -708,6 +745,7 @@ private fun AvailableTileGridCell( verticalArrangement = spacedBy(CommonTileDefaults.TilePadding, Alignment.Top), modifier = modifier, ) { Box { Box( Modifier.fillMaxWidth() .height(TileHeight) Loading @@ -731,6 +769,13 @@ private fun AvailableTileGridCell( modifier = Modifier.align(Alignment.Center), ) } TileBadge( icon = Icons.Default.Add, contentDescription = onClickActionName, onClick = onClick, ) } Box(Modifier.fillMaxSize()) { Text( cell.tile.label.text, Loading @@ -744,6 +789,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 Loading Loading @@ -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 = Loading
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt +1 −1 Original line number Diff line number Diff line Loading @@ -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), ) } } Loading
packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropTest.kt +16 −26 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 Loading @@ -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"), ) } Loading @@ -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 Loading @@ -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 ] Loading @@ -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" Loading
packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt +0 −21 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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" Loading
packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/TestMatchers.kt 0 → 100644 +52 −0 Original line number Diff line number Diff line /* * 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 } }