Loading packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropStateTest.kt 0 → 100644 +107 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.runtime.mutableStateOf import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.shared.model.Text import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) class DragAndDropStateTest : SysuiTestCase() { private val listState = EditTileListState(TestEditTiles) private val underTest = DragAndDropState(mutableStateOf(null), listState) @Test fun isMoving_returnsCorrectValue() { // Asserts no tiles is moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isFalse() } // Start the drag movement val movingTileSpec = TestEditTiles[0].tileSpec underTest.onStarted(movingTileSpec) // Assert that the correct tile is marked as moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isEqualTo(movingTileSpec == it.tileSpec) } } @Test fun onMoved_updatesList() { val movingTileSpec = TestEditTiles[0].tileSpec // Start the drag movement underTest.onStarted(movingTileSpec) // Move the tile to the end of the list underTest.onMoved(listState.tiles[5].tileSpec) assertThat(underTest.currentPosition()).isEqualTo(5) // Move the tile to the middle of the list underTest.onMoved(listState.tiles[2].tileSpec) assertThat(underTest.currentPosition()).isEqualTo(2) } @Test fun onDrop_resetsMovingTile() { val movingTileSpec = TestEditTiles[0].tileSpec // Start the drag movement underTest.onStarted(movingTileSpec) // Move the tile to the end of the list underTest.onMoved(listState.tiles[5].tileSpec) // Drop the tile underTest.onDrop() // Asserts no tiles is moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isFalse() } } companion object { private fun createEditTile(tileSpec: String): EditTileViewModel { return EditTileViewModel( tileSpec = TileSpec.create(tileSpec), icon = Icon.Resource(0, null), label = Text.Loaded("unused"), appName = null, isCurrent = true, availableEditActions = emptySet(), ) } private val TestEditTiles = listOf( createEditTile("tileA"), createEditTile("tileB"), createEditTile("tileC"), createEditTile("tileD"), createEditTile("tileE"), createEditTile("tileF"), ) } } packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/EditTileListStateTest.kt 0 → 100644 +110 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.shared.model.Text import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) class EditTileListStateTest : SysuiTestCase() { val underTest = EditTileListState(TestEditTiles) @Test fun movingNonExistentTile_listUnchanged() { underTest.move(TileSpec.create("other_tile"), TestEditTiles[0].tileSpec) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToNonExistentTarget_listUnchanged() { underTest.move(TestEditTiles[0].tileSpec, TileSpec.create("other_tile")) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToItself_listUnchanged() { underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[0].tileSpec) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToSameSection_listUpdates() { // Move tile at index 0 to index 1. Tile 0 should remain current. underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[1].tileSpec) // Assert the tiles 0 and 1 have changed places. assertThat(underTest.tiles[0]).isEqualTo(TestEditTiles[1]) assertThat(underTest.tiles[1]).isEqualTo(TestEditTiles[0]) // Assert the rest of the list is unchanged assertThat(underTest.tiles.subList(2, 5)) .containsExactly(*TestEditTiles.subList(2, 5).toTypedArray()) } @Test fun movingTileToDifferentSection_listAndTileUpdates() { // Move tile at index 0 to index 3. Tile 0 should no longer be current. underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[3].tileSpec) // Assert tile 0 is now at index 3 and is no longer current. assertThat(underTest.tiles[3]).isEqualTo(TestEditTiles[0].copy(isCurrent = false)) // Assert previous tiles have shifted places assertThat(underTest.tiles[0]).isEqualTo(TestEditTiles[1]) assertThat(underTest.tiles[1]).isEqualTo(TestEditTiles[2]) assertThat(underTest.tiles[2]).isEqualTo(TestEditTiles[3]) // Assert the rest of the list is unchanged assertThat(underTest.tiles.subList(4, 5)) .containsExactly(*TestEditTiles.subList(4, 5).toTypedArray()) } companion object { private fun createEditTile(tileSpec: String, isCurrent: Boolean): EditTileViewModel { return EditTileViewModel( tileSpec = TileSpec.create(tileSpec), icon = Icon.Resource(0, null), label = Text.Loaded("unused"), appName = null, isCurrent = isCurrent, availableEditActions = emptySet(), ) } private val TestEditTiles = listOf( createEditTile("tileA", true), createEditTile("tileB", true), createEditTile("tileC", true), createEditTile("tileD", false), createEditTile("tileE", false), createEditTile("tileF", false), ) } } packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/DragAndDropState.kt 0 → 100644 +180 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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. */ @file:OptIn(ExperimentalFoundationApi::class) package com.android.systemui.qs.panels.ui.compose import android.content.ClipData import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes import com.android.systemui.qs.pipeline.shared.TileSpec @Composable fun rememberDragAndDropState(listState: EditTileListState): DragAndDropState { val sourceSpec: MutableState<TileSpec?> = remember { mutableStateOf(null) } return remember(listState) { DragAndDropState(sourceSpec, listState) } } /** * Holds the [TileSpec] of the tile being moved and modify the [EditTileListState] based on drag and * drop events. */ class DragAndDropState( val sourceSpec: MutableState<TileSpec?>, private val listState: EditTileListState ) { /** Returns index of the dragged tile if it's present in the list. Returns -1 if not. */ fun currentPosition(): Int { return sourceSpec.value?.let { listState.indexOf(it) } ?: -1 } fun isMoving(tileSpec: TileSpec): Boolean { return sourceSpec.value?.let { it == tileSpec } ?: false } fun onStarted(spec: TileSpec) { sourceSpec.value = spec } fun onMoved(targetSpec: TileSpec) { sourceSpec.value?.let { listState.move(it, targetSpec) } } fun onDrop() { sourceSpec.value = null } } /** * Registers a tile as a [DragAndDropTarget] to receive drag events and update the * [DragAndDropState] with the tile's position, which can be used to insert a temporary placeholder. * * @param dragAndDropState The [DragAndDropState] using the tiles list * @param tileSpec The [TileSpec] of the tile * @param acceptDrops Whether the tile should accept a drop based on a given [TileSpec] * @param onDrop Action to be executed when a [TileSpec] is dropped on the tile */ @Composable fun Modifier.dragAndDropTile( dragAndDropState: DragAndDropState, tileSpec: TileSpec, acceptDrops: (TileSpec) -> Boolean, onDrop: (TileSpec, Int) -> Unit, ): Modifier { val target = remember(dragAndDropState) { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { return dragAndDropState.sourceSpec.value?.let { onDrop(it, dragAndDropState.currentPosition()) dragAndDropState.onDrop() true } ?: false } override fun onEntered(event: DragAndDropEvent) { dragAndDropState.onMoved(tileSpec) } } } return dragAndDropTarget( shouldStartDragAndDrop = { event -> event.mimeTypes().contains(QsDragAndDrop.TILESPEC_MIME_TYPE) && dragAndDropState.sourceSpec.value?.let { acceptDrops(it) } ?: false }, target = target, ) } /** * Registers a tile list as a [DragAndDropTarget] to receive drop events. Use this on list * containers to catch drops outside of tiles. * * @param dragAndDropState The [DragAndDropState] using the tiles list * @param acceptDrops Whether the tile should accept a drop based on a given [TileSpec] * @param onDrop Action to be executed when a [TileSpec] is dropped on the tile */ @Composable fun Modifier.dragAndDropTileList( dragAndDropState: DragAndDropState, acceptDrops: (TileSpec) -> Boolean, onDrop: (TileSpec, Int) -> Unit, ): Modifier { val target = remember(dragAndDropState) { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { return dragAndDropState.sourceSpec.value?.let { onDrop(it, dragAndDropState.currentPosition()) dragAndDropState.onDrop() true } ?: false } } } return dragAndDropTarget( target = target, shouldStartDragAndDrop = { event -> event.mimeTypes().contains(QsDragAndDrop.TILESPEC_MIME_TYPE) && dragAndDropState.sourceSpec.value?.let { acceptDrops(it) } ?: false }, ) } fun Modifier.dragAndDropTileSource( tileSpec: TileSpec, onTap: (TileSpec) -> Unit, dragAndDropState: DragAndDropState ): Modifier { return dragAndDropSource { detectTapGestures( onTap = { onTap(tileSpec) }, onLongPress = { dragAndDropState.onStarted(tileSpec) // The tilespec from the ClipData transferred isn't actually needed as we're moving // a tile within the same application. We're using a custom MIME type to limit the // drag event to QS. startTransfer( DragAndDropTransferData( ClipData( QsDragAndDrop.CLIPDATA_LABEL, arrayOf(QsDragAndDrop.TILESPEC_MIME_TYPE), ClipData.Item(tileSpec.spec) ) ) ) } ) } } private object QsDragAndDrop { const val CLIPDATA_LABEL = "tilespec" const val TILESPEC_MIME_TYPE = "qstile/tilespec" } packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/EditTileListState.kt 0 → 100644 +52 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec @Composable fun rememberEditListState( tiles: List<EditTileViewModel>, ): EditTileListState { return remember(tiles) { EditTileListState(tiles) } } /** Holds the temporary state of the tile list during a drag movement where we move tiles around. */ class EditTileListState(tiles: List<EditTileViewModel>) { val tiles: SnapshotStateList<EditTileViewModel> = tiles.toMutableStateList() fun move(tileSpec: TileSpec, target: TileSpec) { val fromIndex = indexOf(tileSpec) val toIndex = indexOf(target) if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) { return } val isMovingToCurrent = tiles[toIndex].isCurrent tiles.apply { add(toIndex, removeAt(fromIndex).copy(isCurrent = isMovingToCurrent)) } } fun indexOf(tileSpec: TileSpec): Int { return tiles.indexOfFirst { it.tileSpec == tileSpec } } } packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt +63 −21 Original line number Original line Diff line number Diff line Loading @@ -110,12 +110,15 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition tiles: List<EditTileViewModel>, tiles: List<EditTileViewModel>, modifier: Modifier, modifier: Modifier, onAddTile: (TileSpec, Int) -> Unit, onAddTile: (TileSpec, Int) -> Unit, onRemoveTile: (TileSpec) -> Unit onRemoveTile: (TileSpec) -> Unit, ) { ) { val columns by viewModel.columns.collectAsStateWithLifecycle() val columns by viewModel.columns.collectAsStateWithLifecycle() val showLabels by viewModel.showLabels.collectAsStateWithLifecycle() val showLabels by viewModel.showLabels.collectAsStateWithLifecycle() val (currentTiles, otherTiles) = tiles.partition { it.isCurrent } val listState = rememberEditListState(tiles) val dragAndDropState = rememberDragAndDropState(listState) val (currentTiles, otherTiles) = listState.tiles.partition { it.isCurrent } val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { onAddTile(it, CurrentTilesInteractor.POSITION_AT_END) onAddTile(it, CurrentTilesInteractor.POSITION_AT_END) } } Loading Loading @@ -156,20 +159,24 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition largeTileHeight = largeTileHeight, largeTileHeight = largeTileHeight, iconTileHeight = iconTileHeight, iconTileHeight = iconTileHeight, tilePadding = tilePadding, tilePadding = tilePadding, onRemoveTile = onRemoveTile, onAdd = onAddTile, onRemove = onRemoveTile, isIconOnly = viewModel::isIconTile, isIconOnly = viewModel::isIconTile, columns = columns, columns = columns, showLabels = showLabels, showLabels = showLabels, dragAndDropState = dragAndDropState, ) ) AvailableTiles( AvailableTiles( tiles = otherTiles, tiles = otherTiles.filter { !dragAndDropState.isMoving(it.tileSpec) }, largeTileHeight = largeTileHeight, largeTileHeight = largeTileHeight, iconTileHeight = iconTileHeight, iconTileHeight = iconTileHeight, tilePadding = tilePadding, tilePadding = tilePadding, addTileToEnd = addTileToEnd, addTileToEnd = addTileToEnd, onRemove = onRemoveTile, isIconOnly = viewModel::isIconTile, isIconOnly = viewModel::isIconTile, showLabels = showLabels, showLabels = showLabels, columns = columns, columns = columns, dragAndDropState = dragAndDropState, ) ) } } } } Loading @@ -194,10 +201,12 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition largeTileHeight: Dp, largeTileHeight: Dp, iconTileHeight: Dp, iconTileHeight: Dp, tilePadding: Dp, tilePadding: Dp, onRemoveTile: (TileSpec) -> Unit, onAdd: (TileSpec, Int) -> Unit, onRemove: (TileSpec) -> Unit, isIconOnly: (TileSpec) -> Boolean, isIconOnly: (TileSpec) -> Boolean, showLabels: Boolean, showLabels: Boolean, columns: Int, columns: Int, dragAndDropState: DragAndDropState, ) { ) { val (smallTiles, largeTiles) = tiles.partition { isIconOnly(it.tileSpec) } val (smallTiles, largeTiles) = tiles.partition { isIconOnly(it.tileSpec) } Loading @@ -207,29 +216,40 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition CurrentTilesContainer { CurrentTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(largeGridHeight), modifier = Modifier.height(largeGridHeight) .dragAndDropTileList(dragAndDropState, { !isIconOnly(it) }, onAdd) ) { ) { editTiles( editTiles( largeTiles, tiles = largeTiles, ClickAction.REMOVE, clickAction = ClickAction.REMOVE, onRemoveTile, onClick = onRemove, { false }, isIconOnly = { false }, indicatePosition = true dragAndDropState = dragAndDropState, acceptDrops = { !isIconOnly(it) }, onDrop = onAdd, indicatePosition = true, ) ) } } } } CurrentTilesContainer { CurrentTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(smallGridHeight), modifier = Modifier.height(smallGridHeight) .dragAndDropTileList(dragAndDropState, { isIconOnly(it) }, onAdd) ) { ) { editTiles( editTiles( smallTiles, tiles = smallTiles, ClickAction.REMOVE, clickAction = ClickAction.REMOVE, onRemoveTile, onClick = onRemove, { true }, isIconOnly = { true }, showLabels = showLabels, showLabels = showLabels, indicatePosition = true dragAndDropState = dragAndDropState, acceptDrops = { isIconOnly(it) }, onDrop = onAdd, indicatePosition = true, ) ) } } } } Loading @@ -242,9 +262,11 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition iconTileHeight: Dp, iconTileHeight: Dp, tilePadding: Dp, tilePadding: Dp, addTileToEnd: (TileSpec) -> Unit, addTileToEnd: (TileSpec) -> Unit, onRemove: (TileSpec) -> Unit, isIconOnly: (TileSpec) -> Boolean, isIconOnly: (TileSpec) -> Boolean, showLabels: Boolean, showLabels: Boolean, columns: Int, columns: Int, dragAndDropState: DragAndDropState, ) { ) { val (tilesStock, tilesCustom) = tiles.partition { it.appName == null } val (tilesStock, tilesCustom) = tiles.partition { it.appName == null } val (smallTiles, largeTiles) = tilesStock.partition { isIconOnly(it.tileSpec) } val (smallTiles, largeTiles) = tilesStock.partition { isIconOnly(it.tileSpec) } Loading @@ -258,13 +280,27 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition val gridHeight = val gridHeight = largeGridHeight + smallGridHeight + largeGridHeightCustom + (tilePadding * 2) largeGridHeight + smallGridHeight + largeGridHeightCustom + (tilePadding * 2) val onDrop: (TileSpec, Int) -> Unit by rememberUpdatedState { tileSpec, _ -> onRemove(tileSpec) } AvailableTilesContainer { AvailableTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(gridHeight), modifier = Modifier.height(gridHeight) .dragAndDropTileList(dragAndDropState, { true }, onDrop) ) { ) { // Large tiles // Large tiles editTiles(largeTiles, ClickAction.ADD, addTileToEnd, isIconOnly) editTiles( largeTiles, ClickAction.ADD, addTileToEnd, isIconOnly, dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) fillUpRow(nTiles = largeTiles.size, columns = columns / 2) fillUpRow(nTiles = largeTiles.size, columns = columns / 2) // Small tiles // Small tiles Loading @@ -273,7 +309,10 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition ClickAction.ADD, ClickAction.ADD, addTileToEnd, addTileToEnd, isIconOnly, isIconOnly, showLabels = showLabels showLabels = showLabels, dragAndDropState = dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) ) fillUpRow(nTiles = smallTiles.size, columns = columns) fillUpRow(nTiles = smallTiles.size, columns = columns) Loading @@ -283,7 +322,10 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition ClickAction.ADD, ClickAction.ADD, addTileToEnd, addTileToEnd, isIconOnly, isIconOnly, showLabels = showLabels showLabels = showLabels, dragAndDropState = dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) ) } } } } Loading Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/DragAndDropStateTest.kt 0 → 100644 +107 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.runtime.mutableStateOf import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.shared.model.Text import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) class DragAndDropStateTest : SysuiTestCase() { private val listState = EditTileListState(TestEditTiles) private val underTest = DragAndDropState(mutableStateOf(null), listState) @Test fun isMoving_returnsCorrectValue() { // Asserts no tiles is moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isFalse() } // Start the drag movement val movingTileSpec = TestEditTiles[0].tileSpec underTest.onStarted(movingTileSpec) // Assert that the correct tile is marked as moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isEqualTo(movingTileSpec == it.tileSpec) } } @Test fun onMoved_updatesList() { val movingTileSpec = TestEditTiles[0].tileSpec // Start the drag movement underTest.onStarted(movingTileSpec) // Move the tile to the end of the list underTest.onMoved(listState.tiles[5].tileSpec) assertThat(underTest.currentPosition()).isEqualTo(5) // Move the tile to the middle of the list underTest.onMoved(listState.tiles[2].tileSpec) assertThat(underTest.currentPosition()).isEqualTo(2) } @Test fun onDrop_resetsMovingTile() { val movingTileSpec = TestEditTiles[0].tileSpec // Start the drag movement underTest.onStarted(movingTileSpec) // Move the tile to the end of the list underTest.onMoved(listState.tiles[5].tileSpec) // Drop the tile underTest.onDrop() // Asserts no tiles is moving TestEditTiles.forEach { assertThat(underTest.isMoving(it.tileSpec)).isFalse() } } companion object { private fun createEditTile(tileSpec: String): EditTileViewModel { return EditTileViewModel( tileSpec = TileSpec.create(tileSpec), icon = Icon.Resource(0, null), label = Text.Loaded("unused"), appName = null, isCurrent = true, availableEditActions = emptySet(), ) } private val TestEditTiles = listOf( createEditTile("tileA"), createEditTile("tileB"), createEditTile("tileC"), createEditTile("tileD"), createEditTile("tileE"), createEditTile("tileF"), ) } }
packages/SystemUI/multivalentTests/src/com/android/systemui/qs/panels/ui/compose/EditTileListStateTest.kt 0 → 100644 +110 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.shared.model.Text import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith @SmallTest @RunWith(AndroidJUnit4::class) class EditTileListStateTest : SysuiTestCase() { val underTest = EditTileListState(TestEditTiles) @Test fun movingNonExistentTile_listUnchanged() { underTest.move(TileSpec.create("other_tile"), TestEditTiles[0].tileSpec) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToNonExistentTarget_listUnchanged() { underTest.move(TestEditTiles[0].tileSpec, TileSpec.create("other_tile")) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToItself_listUnchanged() { underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[0].tileSpec) assertThat(underTest.tiles).containsExactly(*TestEditTiles.toTypedArray()) } @Test fun movingTileToSameSection_listUpdates() { // Move tile at index 0 to index 1. Tile 0 should remain current. underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[1].tileSpec) // Assert the tiles 0 and 1 have changed places. assertThat(underTest.tiles[0]).isEqualTo(TestEditTiles[1]) assertThat(underTest.tiles[1]).isEqualTo(TestEditTiles[0]) // Assert the rest of the list is unchanged assertThat(underTest.tiles.subList(2, 5)) .containsExactly(*TestEditTiles.subList(2, 5).toTypedArray()) } @Test fun movingTileToDifferentSection_listAndTileUpdates() { // Move tile at index 0 to index 3. Tile 0 should no longer be current. underTest.move(TestEditTiles[0].tileSpec, TestEditTiles[3].tileSpec) // Assert tile 0 is now at index 3 and is no longer current. assertThat(underTest.tiles[3]).isEqualTo(TestEditTiles[0].copy(isCurrent = false)) // Assert previous tiles have shifted places assertThat(underTest.tiles[0]).isEqualTo(TestEditTiles[1]) assertThat(underTest.tiles[1]).isEqualTo(TestEditTiles[2]) assertThat(underTest.tiles[2]).isEqualTo(TestEditTiles[3]) // Assert the rest of the list is unchanged assertThat(underTest.tiles.subList(4, 5)) .containsExactly(*TestEditTiles.subList(4, 5).toTypedArray()) } companion object { private fun createEditTile(tileSpec: String, isCurrent: Boolean): EditTileViewModel { return EditTileViewModel( tileSpec = TileSpec.create(tileSpec), icon = Icon.Resource(0, null), label = Text.Loaded("unused"), appName = null, isCurrent = isCurrent, availableEditActions = emptySet(), ) } private val TestEditTiles = listOf( createEditTile("tileA", true), createEditTile("tileB", true), createEditTile("tileC", true), createEditTile("tileD", false), createEditTile("tileE", false), createEditTile("tileF", false), ) } }
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/DragAndDropState.kt 0 → 100644 +180 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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. */ @file:OptIn(ExperimentalFoundationApi::class) package com.android.systemui.qs.panels.ui.compose import android.content.ClipData import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draganddrop.mimeTypes import com.android.systemui.qs.pipeline.shared.TileSpec @Composable fun rememberDragAndDropState(listState: EditTileListState): DragAndDropState { val sourceSpec: MutableState<TileSpec?> = remember { mutableStateOf(null) } return remember(listState) { DragAndDropState(sourceSpec, listState) } } /** * Holds the [TileSpec] of the tile being moved and modify the [EditTileListState] based on drag and * drop events. */ class DragAndDropState( val sourceSpec: MutableState<TileSpec?>, private val listState: EditTileListState ) { /** Returns index of the dragged tile if it's present in the list. Returns -1 if not. */ fun currentPosition(): Int { return sourceSpec.value?.let { listState.indexOf(it) } ?: -1 } fun isMoving(tileSpec: TileSpec): Boolean { return sourceSpec.value?.let { it == tileSpec } ?: false } fun onStarted(spec: TileSpec) { sourceSpec.value = spec } fun onMoved(targetSpec: TileSpec) { sourceSpec.value?.let { listState.move(it, targetSpec) } } fun onDrop() { sourceSpec.value = null } } /** * Registers a tile as a [DragAndDropTarget] to receive drag events and update the * [DragAndDropState] with the tile's position, which can be used to insert a temporary placeholder. * * @param dragAndDropState The [DragAndDropState] using the tiles list * @param tileSpec The [TileSpec] of the tile * @param acceptDrops Whether the tile should accept a drop based on a given [TileSpec] * @param onDrop Action to be executed when a [TileSpec] is dropped on the tile */ @Composable fun Modifier.dragAndDropTile( dragAndDropState: DragAndDropState, tileSpec: TileSpec, acceptDrops: (TileSpec) -> Boolean, onDrop: (TileSpec, Int) -> Unit, ): Modifier { val target = remember(dragAndDropState) { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { return dragAndDropState.sourceSpec.value?.let { onDrop(it, dragAndDropState.currentPosition()) dragAndDropState.onDrop() true } ?: false } override fun onEntered(event: DragAndDropEvent) { dragAndDropState.onMoved(tileSpec) } } } return dragAndDropTarget( shouldStartDragAndDrop = { event -> event.mimeTypes().contains(QsDragAndDrop.TILESPEC_MIME_TYPE) && dragAndDropState.sourceSpec.value?.let { acceptDrops(it) } ?: false }, target = target, ) } /** * Registers a tile list as a [DragAndDropTarget] to receive drop events. Use this on list * containers to catch drops outside of tiles. * * @param dragAndDropState The [DragAndDropState] using the tiles list * @param acceptDrops Whether the tile should accept a drop based on a given [TileSpec] * @param onDrop Action to be executed when a [TileSpec] is dropped on the tile */ @Composable fun Modifier.dragAndDropTileList( dragAndDropState: DragAndDropState, acceptDrops: (TileSpec) -> Boolean, onDrop: (TileSpec, Int) -> Unit, ): Modifier { val target = remember(dragAndDropState) { object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { return dragAndDropState.sourceSpec.value?.let { onDrop(it, dragAndDropState.currentPosition()) dragAndDropState.onDrop() true } ?: false } } } return dragAndDropTarget( target = target, shouldStartDragAndDrop = { event -> event.mimeTypes().contains(QsDragAndDrop.TILESPEC_MIME_TYPE) && dragAndDropState.sourceSpec.value?.let { acceptDrops(it) } ?: false }, ) } fun Modifier.dragAndDropTileSource( tileSpec: TileSpec, onTap: (TileSpec) -> Unit, dragAndDropState: DragAndDropState ): Modifier { return dragAndDropSource { detectTapGestures( onTap = { onTap(tileSpec) }, onLongPress = { dragAndDropState.onStarted(tileSpec) // The tilespec from the ClipData transferred isn't actually needed as we're moving // a tile within the same application. We're using a custom MIME type to limit the // drag event to QS. startTransfer( DragAndDropTransferData( ClipData( QsDragAndDrop.CLIPDATA_LABEL, arrayOf(QsDragAndDrop.TILESPEC_MIME_TYPE), ClipData.Item(tileSpec.spec) ) ) ) } ) } } private object QsDragAndDrop { const val CLIPDATA_LABEL = "tilespec" const val TILESPEC_MIME_TYPE = "qstile/tilespec" }
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/EditTileListState.kt 0 → 100644 +52 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 2024 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.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel import com.android.systemui.qs.pipeline.shared.TileSpec @Composable fun rememberEditListState( tiles: List<EditTileViewModel>, ): EditTileListState { return remember(tiles) { EditTileListState(tiles) } } /** Holds the temporary state of the tile list during a drag movement where we move tiles around. */ class EditTileListState(tiles: List<EditTileViewModel>) { val tiles: SnapshotStateList<EditTileViewModel> = tiles.toMutableStateList() fun move(tileSpec: TileSpec, target: TileSpec) { val fromIndex = indexOf(tileSpec) val toIndex = indexOf(target) if (fromIndex == -1 || toIndex == -1 || fromIndex == toIndex) { return } val isMovingToCurrent = tiles[toIndex].isCurrent tiles.apply { add(toIndex, removeAt(fromIndex).copy(isCurrent = isMovingToCurrent)) } } fun indexOf(tileSpec: TileSpec): Int { return tiles.indexOfFirst { it.tileSpec == tileSpec } } }
packages/SystemUI/src/com/android/systemui/qs/panels/ui/compose/PartitionedGridLayout.kt +63 −21 Original line number Original line Diff line number Diff line Loading @@ -110,12 +110,15 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition tiles: List<EditTileViewModel>, tiles: List<EditTileViewModel>, modifier: Modifier, modifier: Modifier, onAddTile: (TileSpec, Int) -> Unit, onAddTile: (TileSpec, Int) -> Unit, onRemoveTile: (TileSpec) -> Unit onRemoveTile: (TileSpec) -> Unit, ) { ) { val columns by viewModel.columns.collectAsStateWithLifecycle() val columns by viewModel.columns.collectAsStateWithLifecycle() val showLabels by viewModel.showLabels.collectAsStateWithLifecycle() val showLabels by viewModel.showLabels.collectAsStateWithLifecycle() val (currentTiles, otherTiles) = tiles.partition { it.isCurrent } val listState = rememberEditListState(tiles) val dragAndDropState = rememberDragAndDropState(listState) val (currentTiles, otherTiles) = listState.tiles.partition { it.isCurrent } val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { val addTileToEnd: (TileSpec) -> Unit by rememberUpdatedState { onAddTile(it, CurrentTilesInteractor.POSITION_AT_END) onAddTile(it, CurrentTilesInteractor.POSITION_AT_END) } } Loading Loading @@ -156,20 +159,24 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition largeTileHeight = largeTileHeight, largeTileHeight = largeTileHeight, iconTileHeight = iconTileHeight, iconTileHeight = iconTileHeight, tilePadding = tilePadding, tilePadding = tilePadding, onRemoveTile = onRemoveTile, onAdd = onAddTile, onRemove = onRemoveTile, isIconOnly = viewModel::isIconTile, isIconOnly = viewModel::isIconTile, columns = columns, columns = columns, showLabels = showLabels, showLabels = showLabels, dragAndDropState = dragAndDropState, ) ) AvailableTiles( AvailableTiles( tiles = otherTiles, tiles = otherTiles.filter { !dragAndDropState.isMoving(it.tileSpec) }, largeTileHeight = largeTileHeight, largeTileHeight = largeTileHeight, iconTileHeight = iconTileHeight, iconTileHeight = iconTileHeight, tilePadding = tilePadding, tilePadding = tilePadding, addTileToEnd = addTileToEnd, addTileToEnd = addTileToEnd, onRemove = onRemoveTile, isIconOnly = viewModel::isIconTile, isIconOnly = viewModel::isIconTile, showLabels = showLabels, showLabels = showLabels, columns = columns, columns = columns, dragAndDropState = dragAndDropState, ) ) } } } } Loading @@ -194,10 +201,12 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition largeTileHeight: Dp, largeTileHeight: Dp, iconTileHeight: Dp, iconTileHeight: Dp, tilePadding: Dp, tilePadding: Dp, onRemoveTile: (TileSpec) -> Unit, onAdd: (TileSpec, Int) -> Unit, onRemove: (TileSpec) -> Unit, isIconOnly: (TileSpec) -> Boolean, isIconOnly: (TileSpec) -> Boolean, showLabels: Boolean, showLabels: Boolean, columns: Int, columns: Int, dragAndDropState: DragAndDropState, ) { ) { val (smallTiles, largeTiles) = tiles.partition { isIconOnly(it.tileSpec) } val (smallTiles, largeTiles) = tiles.partition { isIconOnly(it.tileSpec) } Loading @@ -207,29 +216,40 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition CurrentTilesContainer { CurrentTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(largeGridHeight), modifier = Modifier.height(largeGridHeight) .dragAndDropTileList(dragAndDropState, { !isIconOnly(it) }, onAdd) ) { ) { editTiles( editTiles( largeTiles, tiles = largeTiles, ClickAction.REMOVE, clickAction = ClickAction.REMOVE, onRemoveTile, onClick = onRemove, { false }, isIconOnly = { false }, indicatePosition = true dragAndDropState = dragAndDropState, acceptDrops = { !isIconOnly(it) }, onDrop = onAdd, indicatePosition = true, ) ) } } } } CurrentTilesContainer { CurrentTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(smallGridHeight), modifier = Modifier.height(smallGridHeight) .dragAndDropTileList(dragAndDropState, { isIconOnly(it) }, onAdd) ) { ) { editTiles( editTiles( smallTiles, tiles = smallTiles, ClickAction.REMOVE, clickAction = ClickAction.REMOVE, onRemoveTile, onClick = onRemove, { true }, isIconOnly = { true }, showLabels = showLabels, showLabels = showLabels, indicatePosition = true dragAndDropState = dragAndDropState, acceptDrops = { isIconOnly(it) }, onDrop = onAdd, indicatePosition = true, ) ) } } } } Loading @@ -242,9 +262,11 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition iconTileHeight: Dp, iconTileHeight: Dp, tilePadding: Dp, tilePadding: Dp, addTileToEnd: (TileSpec) -> Unit, addTileToEnd: (TileSpec) -> Unit, onRemove: (TileSpec) -> Unit, isIconOnly: (TileSpec) -> Boolean, isIconOnly: (TileSpec) -> Boolean, showLabels: Boolean, showLabels: Boolean, columns: Int, columns: Int, dragAndDropState: DragAndDropState, ) { ) { val (tilesStock, tilesCustom) = tiles.partition { it.appName == null } val (tilesStock, tilesCustom) = tiles.partition { it.appName == null } val (smallTiles, largeTiles) = tilesStock.partition { isIconOnly(it.tileSpec) } val (smallTiles, largeTiles) = tilesStock.partition { isIconOnly(it.tileSpec) } Loading @@ -258,13 +280,27 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition val gridHeight = val gridHeight = largeGridHeight + smallGridHeight + largeGridHeightCustom + (tilePadding * 2) largeGridHeight + smallGridHeight + largeGridHeightCustom + (tilePadding * 2) val onDrop: (TileSpec, Int) -> Unit by rememberUpdatedState { tileSpec, _ -> onRemove(tileSpec) } AvailableTilesContainer { AvailableTilesContainer { TileLazyGrid( TileLazyGrid( columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns), modifier = Modifier.height(gridHeight), modifier = Modifier.height(gridHeight) .dragAndDropTileList(dragAndDropState, { true }, onDrop) ) { ) { // Large tiles // Large tiles editTiles(largeTiles, ClickAction.ADD, addTileToEnd, isIconOnly) editTiles( largeTiles, ClickAction.ADD, addTileToEnd, isIconOnly, dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) fillUpRow(nTiles = largeTiles.size, columns = columns / 2) fillUpRow(nTiles = largeTiles.size, columns = columns / 2) // Small tiles // Small tiles Loading @@ -273,7 +309,10 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition ClickAction.ADD, ClickAction.ADD, addTileToEnd, addTileToEnd, isIconOnly, isIconOnly, showLabels = showLabels showLabels = showLabels, dragAndDropState = dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) ) fillUpRow(nTiles = smallTiles.size, columns = columns) fillUpRow(nTiles = smallTiles.size, columns = columns) Loading @@ -283,7 +322,10 @@ class PartitionedGridLayout @Inject constructor(private val viewModel: Partition ClickAction.ADD, ClickAction.ADD, addTileToEnd, addTileToEnd, isIconOnly, isIconOnly, showLabels = showLabels showLabels = showLabels, dragAndDropState = dragAndDropState, acceptDrops = { true }, onDrop = onDrop, ) ) } } } } Loading