Loading packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt +81 −47 Original line number Diff line number Diff line Loading @@ -336,7 +336,13 @@ fun DefaultEditTileGrid( onEditAction = onEditAction, editModeTabViewModel = editModeTabViewModel, ) { CurrentTilesGrid(listState, selectionState, onEditAction) CurrentTilesGrid( listState = listState, selectionState = selectionState, canRemoveTiles = editModeTabViewModel.selectedTab.isTilesEditingAllowed, canLayoutTiles = editModeTabViewModel.selectedTab.isTilesLayoutAllowed, onEditAction = onEditAction, ) AnimatedAvailableTilesGrid( allTiles = allTiles, Loading @@ -360,7 +366,13 @@ fun DefaultEditTileGrid( modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), ) CurrentTilesGrid(listState, selectionState, onEditAction) CurrentTilesGrid( listState = listState, selectionState = selectionState, canRemoveTiles = true, canLayoutTiles = true, onEditAction = onEditAction, ) // Only show available tiles when a drag or placement isn't in progress, OR the // drag is within the current tiles grid Loading Loading @@ -604,6 +616,8 @@ private fun EditGridCenteredText(text: String, modifier: Modifier = Modifier) { private fun CurrentTilesGrid( listState: EditTileListState, selectionState: MutableSelectionState, canRemoveTiles: Boolean, canLayoutTiles: Boolean, onEditAction: (EditAction) -> Unit, ) { val currentListState by rememberUpdatedState(listState) Loading Loading @@ -650,6 +664,8 @@ private fun CurrentTilesGrid( listState = listState, selectionState = selectionState, gridState = gridState, canRemoveTiles = canRemoveTiles, canLayoutTiles = canLayoutTiles, coroutineScope = coroutineScope, onRemoveTile = { onEditAction(EditAction.RemoveTile(it)) }, ) { resizingOperation -> Loading Loading @@ -825,6 +841,8 @@ private fun GridCell.key(index: Int): Any { * @param listState the [EditTileListState] for this grid * @param selectionState the [MutableSelectionState] for this grid * @param gridState the [LazyGridState] for this grid * @param canRemoveTiles whether tiles can be removed from this grid * @param canLayoutTiles whether tiles can be reordered/resized * @param coroutineScope the [CoroutineScope] to be used for the tiles * @param onRemoveTile the callback when a tile is removed from this grid * @param onResize the callback when a tile has a new [ResizeOperation] Loading @@ -833,6 +851,8 @@ fun LazyGridScope.EditTiles( listState: EditTileListState, selectionState: MutableSelectionState, gridState: LazyGridState, canRemoveTiles: Boolean, canLayoutTiles: Boolean, coroutineScope: CoroutineScope, onRemoveTile: (TileSpec) -> Unit, onResize: (operation: ResizeOperation) -> Unit, Loading Loading @@ -863,6 +883,8 @@ fun LazyGridScope.EditTiles( dragAndDropState = listState, selectionState = selectionState, gridState = gridState, canRemoveTile = canRemoveTiles, canLayoutTile = canLayoutTiles, onResize = onResize, onRemoveTile = onRemoveTile, coroutineScope = coroutineScope, Loading @@ -883,9 +905,10 @@ fun LazyGridScope.EditTiles( private fun rememberTileState( tile: EditTileViewModel, selectionState: MutableSelectionState, canRemoveTile: Boolean, ): State<TileState> { val tileState = remember { mutableStateOf(TileState.None) } val canShowRemovalBadge = tile.isRemovable val canShowRemovalBadge = canRemoveTile && tile.isRemovable LaunchedEffect(selectionState.selection, selectionState.placementEnabled, canShowRemovalBadge) { tileState.value = Loading @@ -902,6 +925,8 @@ private fun LazyGridItemScope.TileGridCell( dragAndDropState: DragAndDropState, selectionState: MutableSelectionState, gridState: LazyGridState, canRemoveTile: Boolean, canLayoutTile: Boolean, onResize: (operation: ResizeOperation) -> Unit, onRemoveTile: (TileSpec) -> Unit, coroutineScope: CoroutineScope, Loading @@ -909,7 +934,7 @@ private fun LazyGridItemScope.TileGridCell( modifier: Modifier = Modifier, ) { val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1) val tileState by rememberTileState(cell.tile, selectionState) val tileState by rememberTileState(cell.tile, selectionState, canRemoveTile) val resizingState = rememberResizingState(cell.tile.tileSpec, cell.isIcon) val progress: () -> Float = { if (tileState == TileState.Selected) { Loading Loading @@ -969,6 +994,10 @@ private fun LazyGridItemScope.TileGridCell( // the resizing animation. The selection can't change positions without selecting a // different tile, so this isn't needed regardless. val placementSpec = if (tileState != TileState.Selected) TilePlacementSpec else null val removeTile = { selectionState.unSelect() onRemoveTile(cell.tile.tileSpec) } InteractiveTileContainer( tileState = tileState, resizingState = resizingState, Loading @@ -976,8 +1005,7 @@ private fun LazyGridItemScope.TileGridCell( modifier.height(TileHeight).fillMaxWidth().animateItem(placementSpec = placementSpec), onClick = { if (tileState == TileState.Removable) { selectionState.unSelect() onRemoveTile(cell.tile.tileSpec) removeTile() } else if (tileState == TileState.Selected) { coroutineScope.launch { resizingState.toggleCurrentValue() } } Loading @@ -993,10 +1021,11 @@ private fun LazyGridItemScope.TileGridCell( // Rapidly composing elements with the draggable modifier can cause visual jank. This // usually happens when resizing a tile multiple times. We can fix this by applying the // draggable modifier after the first frame var isReadyToDrag by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isReadyToDrag = true } val draggableModifier = Modifier.dragAndDropTileSource( var isSelectable by remember { mutableStateOf(false) } LaunchedEffect(canLayoutTile) { isSelectable = canLayoutTile } val selectableModifier = Modifier.selectableTile(cell.tile.tileSpec, selectionState) .dragAndDropTileSource( SizedTileImpl(cell.tile, cell.width), dragAndDropState, DragType.Move, Loading @@ -1011,6 +1040,7 @@ private fun LazyGridItemScope.TileGridCell( this.stateDescription = stateDescription contentDescription = cell.tile.label.text if (isSelectable) { val actions = mutableListOf( CustomAccessibilityAction(togglePlacementModeLabel) { Loading Loading @@ -1044,8 +1074,12 @@ private fun LazyGridItemScope.TileGridCell( customActions = actions } .selectableTile(cell.tile.tileSpec, selectionState) .thenIf(isReadyToDrag) { draggableModifier } } .thenIf(isSelectable) { selectableModifier } .thenIf(!isSelectable && canRemoveTile && cell.tile.isRemovable && !canLayoutTile) { // Set the remove click on the entire tile if reordering is disabled Modifier.clickable(onClick = removeTile) } .tileBackground { backgroundColor } ) { EditTile( Loading packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt +103 −71 Original line number Diff line number Diff line Loading @@ -16,7 +16,6 @@ package com.android.systemui.qs.panels.ui.compose.selection import androidx.compose.animation.animateColor import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat Loading @@ -39,12 +38,12 @@ import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind Loading Loading @@ -76,10 +75,8 @@ import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.Bad import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeSize import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeXOffset import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeYOffset import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.RESIZING_PILL_ANGLE_RAD import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillHeight import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillWidth import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.SelectedBorderWidth import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.decoration import com.android.systemui.qs.panels.ui.compose.selection.TileState.GreyedOut import com.android.systemui.qs.panels.ui.compose.selection.TileState.None import com.android.systemui.qs.panels.ui.compose.selection.TileState.Placeable Loading Loading @@ -111,16 +108,14 @@ fun InteractiveTileContainer( contentDescription: String? = null, content: @Composable BoxScope.() -> Unit = {}, ) { val transition: Transition<TileState> = updateTransition(tileState) val transition: Transition<Decoration> = updateTransition(tileState.decoration()) val decorationColor by transition.animateColor() val decorationAngle by animateAngle(tileState) val decorationSize by transition.animateSize() val decorationOffset by transition.animateOffset() val decorationAlpha by transition.animateFloat { state -> if (state == Removable || state == Selected) 1f else 0f } val badgeIconAlpha by transition.animateFloat { state -> if (state == Removable) 1f else 0f } val selectionBorderAlpha by transition.animateFloat { state -> if (state == Selected) 1f else 0f } val decorationAngle by transition.animateAngle() val decorationSize by transition.animateSize { it.size } val decorationOffset by transition.animateOffset { it.offset } val decorationAlpha by transition.animateFloat { it.alpha } val badgeIconAlpha by transition.animateFloat { it.iconAlpha } val selectionBorderAlpha by transition.animateFloat { it.borderAlpha } val isIdle = transition.currentState == transition.targetState val isDraggable = tileState == Selected Loading Loading @@ -307,19 +302,6 @@ enum class TileState { GreyedOut, } @Composable private fun Transition<TileState>.animateColor(): State<Color> { return animateColor { state -> when (state) { None, GreyedOut -> Color.Transparent Removable -> MaterialTheme.colorScheme.primaryContainer Selected, Placeable -> MaterialTheme.colorScheme.primary } } } /** * Animate the angle of the tile decoration based on the previous state * Loading @@ -327,66 +309,74 @@ private fun Transition<TileState>.animateColor(): State<Color> { * between visible states. */ @Composable private fun animateAngle(tileState: TileState): State<Float> { private fun Transition<Decoration>.animateAngle(): State<Float> { val animatable = remember { Animatable(0f) } var animate by remember { mutableStateOf(false) } LaunchedEffect(tileState) { val targetAngle = tileState.decorationAngle() if (targetAngle == null) { animate = false } else { if (animate) animatable.animateTo(targetAngle) else animatable.snapTo(targetAngle) animate = true val target = targetState LaunchedEffect(target) { if (target is VisibleDecoration) { // Snap to the target angle when the current decoration isn't visible when (currentState) { is NoDecoration -> animatable.snapTo(target.angle) is VisibleDecoration -> animatable.animateTo(target.angle) } } } return animatable.asState() } /** * Animate the color of the tile decoration based on the previous state * * Some [TileState] don't have a visible decoration, and the color should only animate when going * between visible states. */ @Composable private fun Transition<TileState>.animateSize(): State<Size> { return animateSize { state -> with(LocalDensity.current) { when (state) { None, Placeable, GreyedOut -> Size.Zero Removable -> Size(BadgeSize.toPx()) Selected -> Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx()) private fun Transition<Decoration>.animateColor(): State<Color> { val animatable = remember { androidx.compose.animation.Animatable(Color.Transparent) } val target = targetState LaunchedEffect(target) { if (target is VisibleDecoration) { // Snap to the target color when the current decoration isn't visible when (currentState) { is NoDecoration -> animatable.snapTo(target.color) is VisibleDecoration -> animatable.animateTo(target.color) } } } return animatable.asState() } @Composable private fun Transition<TileState>.animateOffset(): State<Offset> { return animateOffset { state -> with(LocalDensity.current) { when (state) { None, Placeable, GreyedOut -> Offset.Zero Removable -> Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx()) Selected -> Offset(-SelectedBorderWidth.toPx(), 0f) } } } } private fun Size(size: Float) = Size(size, size) private fun TileState.decorationAngle(): Float? { return when (this) { Removable -> BADGE_ANGLE_RAD Selected -> RESIZING_PILL_ANGLE_RAD None, Placeable, GreyedOut -> null // No visible decoration private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset { return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y) } @Stable sealed interface Decoration { val alpha: Float val iconAlpha: Float val borderAlpha: Float val size: Size val offset: Offset } private fun Size(size: Float) = Size(size, size) private data class VisibleDecoration( override val alpha: Float = 1f, override val iconAlpha: Float, override val borderAlpha: Float, override val size: Size, override val offset: Offset, val color: Color, val angle: Float, ) : Decoration private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset { return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y) private data object NoDecoration : Decoration { override val alpha: Float = 0f override val iconAlpha: Float = 0f override val borderAlpha: Float = 0f override val size: Size = Size.Zero override val offset: Offset = Offset.Zero } private object SelectionDefaults { Loading @@ -399,4 +389,46 @@ private object SelectionDefaults { val ResizingPillHeight = 16.dp const val BADGE_ANGLE_RAD = -.8f const val RESIZING_PILL_ANGLE_RAD = 0f @Composable @ReadOnlyComposable fun TileState.decoration(): Decoration { return when (this) { Removable -> removalBadge() Selected -> resizingHandle() None, Placeable, GreyedOut -> NoDecoration } } @Composable @ReadOnlyComposable fun removalBadge(): VisibleDecoration { return with(LocalDensity.current) { VisibleDecoration( iconAlpha = 1f, borderAlpha = 0f, color = MaterialTheme.colorScheme.primaryContainer, size = Size(BadgeSize.toPx()), angle = BADGE_ANGLE_RAD, offset = Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx()), ) } } @Composable @ReadOnlyComposable fun resizingHandle(): VisibleDecoration { return with(LocalDensity.current) { VisibleDecoration( iconAlpha = 0f, borderAlpha = 1f, color = MaterialTheme.colorScheme.primary, size = Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx()), angle = RESIZING_PILL_ANGLE_RAD, offset = Offset(-SelectedBorderWidth.toPx(), 0f), ) } } } packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt +160 −1 Original line number Diff line number Diff line Loading @@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsActions.CustomActions import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick Loading @@ -50,6 +53,7 @@ import com.android.systemui.qs.pipeline.shared.TileSpec import com.android.systemui.qs.shared.model.TileCategory import com.android.systemui.res.R import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test Loading @@ -69,8 +73,9 @@ class EditModeTest : SysuiTestCase() { val allTiles = remember { TestEditTiles.toMutableStateList() } val largeTiles = remember { TestLargeTilesSpecs.toMutableStateList() } val currentTiles = allTiles.filter { it.isCurrent } val listState = val listState = remember { EditTileListState(currentTiles, TestLargeTilesSpecs, columns = 4, largeTilesSpan = 2) } LaunchedEffect(currentTiles, largeTiles) { listState.updateTiles(currentTiles, largeTiles.toSet()) } Loading Loading @@ -130,6 +135,63 @@ class EditModeTest : SysuiTestCase() { } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_shouldRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule .onAllNodesWithContentDescription( context.getString(R.string.accessibility_qs_edit_remove_tile_action) ) .onFirst() .performClick() composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE") ) composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec }) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_inAddTab_shouldRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule .onAllNodesWithContentDescription( context.getString(R.string.accessibility_qs_edit_remove_tile_action) ) .onFirst() .performClick() // Assert tileA is missing composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE") ) composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec }) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_inLayoutTab_shouldNotRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule.onNodeWithText("tileA").performClick() // Assert tileA is not missing composeRule.assertCurrentTilesGridContainsExactly( listOf("tileA", "tileB", "tileC", "tileD_large", "tileE") ) } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun placementMode_shouldRepositionTile() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -147,6 +209,28 @@ class EditModeTest : SysuiTestCase() { } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun placementMode_inLayoutTab_shouldRepositionTile() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Double tap first "tileA", i.e. the one in the current grid composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Tap on tileE to position tileA in its spot composeRule.onNodeWithContentDescription("tileE").performClick() // Assert tileA moved to tileE's position composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE", "tileA") ) } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun resizingAction_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -172,6 +256,52 @@ class EditModeTest : SysuiTestCase() { } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun resizingAction_inLayoutTab_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Use the toggle size action composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action) ) // Double tap "tileA" to enable placement mode composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Assert the toggle size action is missing assertThrows(AssertionError::class.java) { composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action) ) } } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun layoutActions_inAddTab_doNotExist() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Assert the toggle size action is missing from the "Add" tab val hasCustomActions = composeRule .onNodeWithContentDescription("tileE") .fetchSemanticsNode() .config .contains(CustomActions) assertThat(hasCustomActions).isFalse() } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun placementAction_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -196,6 +326,35 @@ class EditModeTest : SysuiTestCase() { ) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun placementAction_inLayoutTab_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Assert the placement action is missing assertThrows(AssertionError::class.java) { composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_place_tile_action) ) } // Double tap "tileA" to enable placement mode composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Use the placement action composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_place_tile_action) ) } @Test fun performAction_undoAppears() { composeRule.setContent { EditTileGridUnderTest() } Loading Loading
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/infinitegrid/EditTile.kt +81 −47 Original line number Diff line number Diff line Loading @@ -336,7 +336,13 @@ fun DefaultEditTileGrid( onEditAction = onEditAction, editModeTabViewModel = editModeTabViewModel, ) { CurrentTilesGrid(listState, selectionState, onEditAction) CurrentTilesGrid( listState = listState, selectionState = selectionState, canRemoveTiles = editModeTabViewModel.selectedTab.isTilesEditingAllowed, canLayoutTiles = editModeTabViewModel.selectedTab.isTilesLayoutAllowed, onEditAction = onEditAction, ) AnimatedAvailableTilesGrid( allTiles = allTiles, Loading @@ -360,7 +366,13 @@ fun DefaultEditTileGrid( modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), ) CurrentTilesGrid(listState, selectionState, onEditAction) CurrentTilesGrid( listState = listState, selectionState = selectionState, canRemoveTiles = true, canLayoutTiles = true, onEditAction = onEditAction, ) // Only show available tiles when a drag or placement isn't in progress, OR the // drag is within the current tiles grid Loading Loading @@ -604,6 +616,8 @@ private fun EditGridCenteredText(text: String, modifier: Modifier = Modifier) { private fun CurrentTilesGrid( listState: EditTileListState, selectionState: MutableSelectionState, canRemoveTiles: Boolean, canLayoutTiles: Boolean, onEditAction: (EditAction) -> Unit, ) { val currentListState by rememberUpdatedState(listState) Loading Loading @@ -650,6 +664,8 @@ private fun CurrentTilesGrid( listState = listState, selectionState = selectionState, gridState = gridState, canRemoveTiles = canRemoveTiles, canLayoutTiles = canLayoutTiles, coroutineScope = coroutineScope, onRemoveTile = { onEditAction(EditAction.RemoveTile(it)) }, ) { resizingOperation -> Loading Loading @@ -825,6 +841,8 @@ private fun GridCell.key(index: Int): Any { * @param listState the [EditTileListState] for this grid * @param selectionState the [MutableSelectionState] for this grid * @param gridState the [LazyGridState] for this grid * @param canRemoveTiles whether tiles can be removed from this grid * @param canLayoutTiles whether tiles can be reordered/resized * @param coroutineScope the [CoroutineScope] to be used for the tiles * @param onRemoveTile the callback when a tile is removed from this grid * @param onResize the callback when a tile has a new [ResizeOperation] Loading @@ -833,6 +851,8 @@ fun LazyGridScope.EditTiles( listState: EditTileListState, selectionState: MutableSelectionState, gridState: LazyGridState, canRemoveTiles: Boolean, canLayoutTiles: Boolean, coroutineScope: CoroutineScope, onRemoveTile: (TileSpec) -> Unit, onResize: (operation: ResizeOperation) -> Unit, Loading Loading @@ -863,6 +883,8 @@ fun LazyGridScope.EditTiles( dragAndDropState = listState, selectionState = selectionState, gridState = gridState, canRemoveTile = canRemoveTiles, canLayoutTile = canLayoutTiles, onResize = onResize, onRemoveTile = onRemoveTile, coroutineScope = coroutineScope, Loading @@ -883,9 +905,10 @@ fun LazyGridScope.EditTiles( private fun rememberTileState( tile: EditTileViewModel, selectionState: MutableSelectionState, canRemoveTile: Boolean, ): State<TileState> { val tileState = remember { mutableStateOf(TileState.None) } val canShowRemovalBadge = tile.isRemovable val canShowRemovalBadge = canRemoveTile && tile.isRemovable LaunchedEffect(selectionState.selection, selectionState.placementEnabled, canShowRemovalBadge) { tileState.value = Loading @@ -902,6 +925,8 @@ private fun LazyGridItemScope.TileGridCell( dragAndDropState: DragAndDropState, selectionState: MutableSelectionState, gridState: LazyGridState, canRemoveTile: Boolean, canLayoutTile: Boolean, onResize: (operation: ResizeOperation) -> Unit, onRemoveTile: (TileSpec) -> Unit, coroutineScope: CoroutineScope, Loading @@ -909,7 +934,7 @@ private fun LazyGridItemScope.TileGridCell( modifier: Modifier = Modifier, ) { val stateDescription = stringResource(id = R.string.accessibility_qs_edit_position, index + 1) val tileState by rememberTileState(cell.tile, selectionState) val tileState by rememberTileState(cell.tile, selectionState, canRemoveTile) val resizingState = rememberResizingState(cell.tile.tileSpec, cell.isIcon) val progress: () -> Float = { if (tileState == TileState.Selected) { Loading Loading @@ -969,6 +994,10 @@ private fun LazyGridItemScope.TileGridCell( // the resizing animation. The selection can't change positions without selecting a // different tile, so this isn't needed regardless. val placementSpec = if (tileState != TileState.Selected) TilePlacementSpec else null val removeTile = { selectionState.unSelect() onRemoveTile(cell.tile.tileSpec) } InteractiveTileContainer( tileState = tileState, resizingState = resizingState, Loading @@ -976,8 +1005,7 @@ private fun LazyGridItemScope.TileGridCell( modifier.height(TileHeight).fillMaxWidth().animateItem(placementSpec = placementSpec), onClick = { if (tileState == TileState.Removable) { selectionState.unSelect() onRemoveTile(cell.tile.tileSpec) removeTile() } else if (tileState == TileState.Selected) { coroutineScope.launch { resizingState.toggleCurrentValue() } } Loading @@ -993,10 +1021,11 @@ private fun LazyGridItemScope.TileGridCell( // Rapidly composing elements with the draggable modifier can cause visual jank. This // usually happens when resizing a tile multiple times. We can fix this by applying the // draggable modifier after the first frame var isReadyToDrag by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isReadyToDrag = true } val draggableModifier = Modifier.dragAndDropTileSource( var isSelectable by remember { mutableStateOf(false) } LaunchedEffect(canLayoutTile) { isSelectable = canLayoutTile } val selectableModifier = Modifier.selectableTile(cell.tile.tileSpec, selectionState) .dragAndDropTileSource( SizedTileImpl(cell.tile, cell.width), dragAndDropState, DragType.Move, Loading @@ -1011,6 +1040,7 @@ private fun LazyGridItemScope.TileGridCell( this.stateDescription = stateDescription contentDescription = cell.tile.label.text if (isSelectable) { val actions = mutableListOf( CustomAccessibilityAction(togglePlacementModeLabel) { Loading Loading @@ -1044,8 +1074,12 @@ private fun LazyGridItemScope.TileGridCell( customActions = actions } .selectableTile(cell.tile.tileSpec, selectionState) .thenIf(isReadyToDrag) { draggableModifier } } .thenIf(isSelectable) { selectableModifier } .thenIf(!isSelectable && canRemoveTile && cell.tile.isRemovable && !canLayoutTile) { // Set the remove click on the entire tile if reordering is disabled Modifier.clickable(onClick = removeTile) } .tileBackground { backgroundColor } ) { EditTile( Loading
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/selection/Selection.kt +103 −71 Original line number Diff line number Diff line Loading @@ -16,7 +16,6 @@ package com.android.systemui.qs.panels.ui.compose.selection import androidx.compose.animation.animateColor import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat Loading @@ -39,12 +38,12 @@ import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind Loading Loading @@ -76,10 +75,8 @@ import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.Bad import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeSize import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeXOffset import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.BadgeYOffset import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.RESIZING_PILL_ANGLE_RAD import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillHeight import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.ResizingPillWidth import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.SelectedBorderWidth import com.android.systemui.qs.panels.ui.compose.selection.SelectionDefaults.decoration import com.android.systemui.qs.panels.ui.compose.selection.TileState.GreyedOut import com.android.systemui.qs.panels.ui.compose.selection.TileState.None import com.android.systemui.qs.panels.ui.compose.selection.TileState.Placeable Loading Loading @@ -111,16 +108,14 @@ fun InteractiveTileContainer( contentDescription: String? = null, content: @Composable BoxScope.() -> Unit = {}, ) { val transition: Transition<TileState> = updateTransition(tileState) val transition: Transition<Decoration> = updateTransition(tileState.decoration()) val decorationColor by transition.animateColor() val decorationAngle by animateAngle(tileState) val decorationSize by transition.animateSize() val decorationOffset by transition.animateOffset() val decorationAlpha by transition.animateFloat { state -> if (state == Removable || state == Selected) 1f else 0f } val badgeIconAlpha by transition.animateFloat { state -> if (state == Removable) 1f else 0f } val selectionBorderAlpha by transition.animateFloat { state -> if (state == Selected) 1f else 0f } val decorationAngle by transition.animateAngle() val decorationSize by transition.animateSize { it.size } val decorationOffset by transition.animateOffset { it.offset } val decorationAlpha by transition.animateFloat { it.alpha } val badgeIconAlpha by transition.animateFloat { it.iconAlpha } val selectionBorderAlpha by transition.animateFloat { it.borderAlpha } val isIdle = transition.currentState == transition.targetState val isDraggable = tileState == Selected Loading Loading @@ -307,19 +302,6 @@ enum class TileState { GreyedOut, } @Composable private fun Transition<TileState>.animateColor(): State<Color> { return animateColor { state -> when (state) { None, GreyedOut -> Color.Transparent Removable -> MaterialTheme.colorScheme.primaryContainer Selected, Placeable -> MaterialTheme.colorScheme.primary } } } /** * Animate the angle of the tile decoration based on the previous state * Loading @@ -327,66 +309,74 @@ private fun Transition<TileState>.animateColor(): State<Color> { * between visible states. */ @Composable private fun animateAngle(tileState: TileState): State<Float> { private fun Transition<Decoration>.animateAngle(): State<Float> { val animatable = remember { Animatable(0f) } var animate by remember { mutableStateOf(false) } LaunchedEffect(tileState) { val targetAngle = tileState.decorationAngle() if (targetAngle == null) { animate = false } else { if (animate) animatable.animateTo(targetAngle) else animatable.snapTo(targetAngle) animate = true val target = targetState LaunchedEffect(target) { if (target is VisibleDecoration) { // Snap to the target angle when the current decoration isn't visible when (currentState) { is NoDecoration -> animatable.snapTo(target.angle) is VisibleDecoration -> animatable.animateTo(target.angle) } } } return animatable.asState() } /** * Animate the color of the tile decoration based on the previous state * * Some [TileState] don't have a visible decoration, and the color should only animate when going * between visible states. */ @Composable private fun Transition<TileState>.animateSize(): State<Size> { return animateSize { state -> with(LocalDensity.current) { when (state) { None, Placeable, GreyedOut -> Size.Zero Removable -> Size(BadgeSize.toPx()) Selected -> Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx()) private fun Transition<Decoration>.animateColor(): State<Color> { val animatable = remember { androidx.compose.animation.Animatable(Color.Transparent) } val target = targetState LaunchedEffect(target) { if (target is VisibleDecoration) { // Snap to the target color when the current decoration isn't visible when (currentState) { is NoDecoration -> animatable.snapTo(target.color) is VisibleDecoration -> animatable.animateTo(target.color) } } } return animatable.asState() } @Composable private fun Transition<TileState>.animateOffset(): State<Offset> { return animateOffset { state -> with(LocalDensity.current) { when (state) { None, Placeable, GreyedOut -> Offset.Zero Removable -> Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx()) Selected -> Offset(-SelectedBorderWidth.toPx(), 0f) } } } } private fun Size(size: Float) = Size(size, size) private fun TileState.decorationAngle(): Float? { return when (this) { Removable -> BADGE_ANGLE_RAD Selected -> RESIZING_PILL_ANGLE_RAD None, Placeable, GreyedOut -> null // No visible decoration private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset { return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y) } @Stable sealed interface Decoration { val alpha: Float val iconAlpha: Float val borderAlpha: Float val size: Size val offset: Offset } private fun Size(size: Float) = Size(size, size) private data class VisibleDecoration( override val alpha: Float = 1f, override val iconAlpha: Float, override val borderAlpha: Float, override val size: Size, override val offset: Offset, val color: Color, val angle: Float, ) : Decoration private fun offsetForAngle(angle: Float, radius: Float, center: Offset): Offset { return Offset(x = radius * cos(angle) + center.x, y = radius * sin(angle) + center.y) private data object NoDecoration : Decoration { override val alpha: Float = 0f override val iconAlpha: Float = 0f override val borderAlpha: Float = 0f override val size: Size = Size.Zero override val offset: Offset = Offset.Zero } private object SelectionDefaults { Loading @@ -399,4 +389,46 @@ private object SelectionDefaults { val ResizingPillHeight = 16.dp const val BADGE_ANGLE_RAD = -.8f const val RESIZING_PILL_ANGLE_RAD = 0f @Composable @ReadOnlyComposable fun TileState.decoration(): Decoration { return when (this) { Removable -> removalBadge() Selected -> resizingHandle() None, Placeable, GreyedOut -> NoDecoration } } @Composable @ReadOnlyComposable fun removalBadge(): VisibleDecoration { return with(LocalDensity.current) { VisibleDecoration( iconAlpha = 1f, borderAlpha = 0f, color = MaterialTheme.colorScheme.primaryContainer, size = Size(BadgeSize.toPx()), angle = BADGE_ANGLE_RAD, offset = Offset(BadgeXOffset.toPx(), BadgeYOffset.toPx()), ) } } @Composable @ReadOnlyComposable fun resizingHandle(): VisibleDecoration { return with(LocalDensity.current) { VisibleDecoration( iconAlpha = 0f, borderAlpha = 1f, color = MaterialTheme.colorScheme.primary, size = Size(ResizingPillWidth.toPx(), ResizingPillHeight.toPx()), angle = RESIZING_PILL_ANGLE_RAD, offset = Offset(-SelectedBorderWidth.toPx(), 0f), ) } } }
packages/SystemUI/tests/src/com/android/systemui/qs/panels/ui/compose/EditModeTest.kt +160 −1 Original line number Diff line number Diff line Loading @@ -24,10 +24,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.SemanticsActions.CustomActions import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.doubleClick import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick Loading @@ -50,6 +53,7 @@ import com.android.systemui.qs.pipeline.shared.TileSpec import com.android.systemui.qs.shared.model.TileCategory import com.android.systemui.res.R import com.android.systemui.testKosmos import com.google.common.truth.Truth.assertThat import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test Loading @@ -69,8 +73,9 @@ class EditModeTest : SysuiTestCase() { val allTiles = remember { TestEditTiles.toMutableStateList() } val largeTiles = remember { TestLargeTilesSpecs.toMutableStateList() } val currentTiles = allTiles.filter { it.isCurrent } val listState = val listState = remember { EditTileListState(currentTiles, TestLargeTilesSpecs, columns = 4, largeTilesSpan = 2) } LaunchedEffect(currentTiles, largeTiles) { listState.updateTiles(currentTiles, largeTiles.toSet()) } Loading Loading @@ -130,6 +135,63 @@ class EditModeTest : SysuiTestCase() { } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_shouldRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule .onAllNodesWithContentDescription( context.getString(R.string.accessibility_qs_edit_remove_tile_action) ) .onFirst() .performClick() composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE") ) composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec }) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_inAddTab_shouldRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule .onAllNodesWithContentDescription( context.getString(R.string.accessibility_qs_edit_remove_tile_action) ) .onFirst() .performClick() // Assert tileA is missing composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE") ) composeRule.assertAvailableTilesGridContainsExactly(TestEditTiles.map { it.tileSpec.spec }) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun clickCurrentTile_inLayoutTab_shouldNotRemove() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap to remove composeRule.onNodeWithText("tileA").performClick() // Assert tileA is not missing composeRule.assertCurrentTilesGridContainsExactly( listOf("tileA", "tileB", "tileC", "tileD_large", "tileE") ) } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun placementMode_shouldRepositionTile() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -147,6 +209,28 @@ class EditModeTest : SysuiTestCase() { } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun placementMode_inLayoutTab_shouldRepositionTile() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Double tap first "tileA", i.e. the one in the current grid composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Tap on tileE to position tileA in its spot composeRule.onNodeWithContentDescription("tileE").performClick() // Assert tileA moved to tileE's position composeRule.assertCurrentTilesGridContainsExactly( listOf("tileB", "tileC", "tileD_large", "tileE", "tileA") ) } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun resizingAction_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -172,6 +256,52 @@ class EditModeTest : SysuiTestCase() { } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun resizingAction_inLayoutTab_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Use the toggle size action composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action) ) // Double tap "tileA" to enable placement mode composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Assert the toggle size action is missing assertThrows(AssertionError::class.java) { composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_toggle_tile_size_action) ) } } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun layoutActions_inAddTab_doNotExist() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Assert the toggle size action is missing from the "Add" tab val hasCustomActions = composeRule .onNodeWithContentDescription("tileE") .fetchSemanticsNode() .config .contains(CustomActions) assertThat(hasCustomActions).isFalse() } @Test @DisableFlags(QsEditModeTabs.FLAG_NAME) fun placementAction_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() Loading @@ -196,6 +326,35 @@ class EditModeTest : SysuiTestCase() { ) } @Test @EnableFlags(QsEditModeTabs.FLAG_NAME) fun placementAction_inLayoutTab_dependsOnPlacementMode() { composeRule.setContent { EditTileGridUnderTest() } composeRule.waitForIdle() // Tap on Layout tab to select composeRule.onNodeWithText("Layout").performClick() // Assert the placement action is missing assertThrows(AssertionError::class.java) { composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_place_tile_action) ) } // Double tap "tileA" to enable placement mode composeRule.onNodeWithContentDescription("tileA").performTouchInput { doubleClick() } // Use the placement action composeRule .onNodeWithContentDescription("tileE") .performCustomAccessibilityActionWithLabel( context.getString(R.string.accessibility_qs_edit_place_tile_action) ) } @Test fun performAction_undoAppears() { composeRule.setContent { EditTileGridUnderTest() } Loading