Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 8fc9816a authored by Olivier St-Onge's avatar Olivier St-Onge
Browse files

Auto scroll edit mode grid when dragging to edges

Also adds a drag type to differentiate between dragging a current tile vs a new tile.
This is used to automatically scroll the grid to the top only when dragging a new tile.

Flag: com.android.systemui.qs_ui_refactor_compose_fragment
Fixes: 380847714
Test: manually dragging tiles in edit mode
Change-Id: I8b78cb4b72d1ce3a9e82c4cd2170b297e9f17ad4
parent e324c92e
Loading
Loading
Loading
Loading
+9 −9
Original line number Diff line number Diff line
@@ -39,7 +39,7 @@ class EditTileListStateTest : SysuiTestCase() {

    @Test
    fun startDrag_listHasSpacers() {
        underTest.onStarted(TestEditTiles[0])
        underTest.onStarted(TestEditTiles[0], DragType.Add)

        // [ a ] [ b ] [ c ] [ X ]
        // [ Large D ] [ e ] [ X ]
@@ -51,8 +51,8 @@ class EditTileListStateTest : SysuiTestCase() {

    @Test
    fun moveDrag_listChanges() {
        underTest.onStarted(TestEditTiles[4])
        underTest.onMoved(3, false)
        underTest.onStarted(TestEditTiles[4], DragType.Add)
        underTest.onTargeting(3, false)

        // Tile E goes to index 3
        // [ a ] [ b ] [ c ] [ e ]
@@ -65,8 +65,8 @@ class EditTileListStateTest : SysuiTestCase() {
    fun moveDragOnSidesOfLargeTile_listChanges() {
        val draggedCell = TestEditTiles[4]

        underTest.onStarted(draggedCell)
        underTest.onMoved(4, true)
        underTest.onStarted(draggedCell, DragType.Add)
        underTest.onTargeting(4, true)

        // Tile E goes to the right side of tile D, list is unchanged
        // [ a ] [ b ] [ c ] [ X ]
@@ -74,7 +74,7 @@ class EditTileListStateTest : SysuiTestCase() {
        assertThat(underTest.tiles.toStrings())
            .isEqualTo(listOf("a", "b", "c", "spacer", "d", "e", "spacer"))

        underTest.onMoved(4, false)
        underTest.onTargeting(4, false)

        // Tile E goes to the left side of tile D, they swap positions
        // [ a ] [ b ] [ c ] [ e ]
@@ -87,8 +87,8 @@ class EditTileListStateTest : SysuiTestCase() {
    fun moveNewTile_tileIsAdded() {
        val newTile = createEditTile("newTile", 2)

        underTest.onStarted(newTile)
        underTest.onMoved(5, false)
        underTest.onStarted(newTile, DragType.Add)
        underTest.onTargeting(5, false)

        // New tile goes to index 5
        // [ a ] [ b ] [ c ] [ X ]
@@ -102,7 +102,7 @@ class EditTileListStateTest : SysuiTestCase() {

    @Test
    fun movedTileOutOfBounds_tileDisappears() {
        underTest.onStarted(TestEditTiles[0])
        underTest.onStarted(TestEditTiles[0], DragType.Add)
        underTest.movedOutOfBounds()

        assertThat(underTest.tiles.toStrings()).doesNotContain(TestEditTiles[0].tile.tileSpec.spec)
+24 −7
Original line number Diff line number Diff line
@@ -44,19 +44,28 @@ import com.android.systemui.qs.pipeline.shared.TileSpec
/** Holds the [TileSpec] of the tile being moved and receives drag and drop events. */
interface DragAndDropState {
    val draggedCell: SizedTile<EditTileViewModel>?
    val draggedPosition: Offset
    val dragInProgress: Boolean
    val dragType: DragType?

    fun isMoving(tileSpec: TileSpec): Boolean

    fun onStarted(cell: SizedTile<EditTileViewModel>)
    fun onStarted(cell: SizedTile<EditTileViewModel>, dragType: DragType)

    fun onMoved(target: Int, insertAfter: Boolean)
    fun onTargeting(target: Int, insertAfter: Boolean)

    fun onMoved(offset: Offset)

    fun movedOutOfBounds()

    fun onDrop()
}

enum class DragType {
    Add,
    Move,
}

/**
 * Registers a composable as a [DragAndDropTarget] to receive drop events. Use this outside the tile
 * grid to catch out of bounds drops.
@@ -72,6 +81,10 @@ fun Modifier.dragAndDropRemoveZone(
    val target =
        remember(dragAndDropState) {
            object : DragAndDropTarget {
                override fun onMoved(event: DragAndDropEvent) {
                    dragAndDropState.onMoved(event.toOffset())
                }

                override fun onDrop(event: DragAndDropEvent): Boolean {
                    return dragAndDropState.draggedCell?.let {
                        onDrop(it.tile.tileSpec)
@@ -117,8 +130,11 @@ fun Modifier.dragAndDropTileList(
                }

                override fun onMoved(event: DragAndDropEvent) {
                    val offset = event.toOffset()
                    dragAndDropState.onMoved(offset)

                    // Drag offset relative to the list's top left corner
                    val relativeDragOffset = event.dragOffsetRelativeTo(contentOffset())
                    val relativeDragOffset = offset - contentOffset()
                    val targetItem =
                        gridState.layoutInfo.visibleItemsInfo.firstOrNull { item ->
                            // Check if the drag is on this item
@@ -126,7 +142,7 @@ fun Modifier.dragAndDropTileList(
                        }

                    targetItem?.let {
                        dragAndDropState.onMoved(it.index, insertAfter(it, relativeDragOffset))
                        dragAndDropState.onTargeting(it.index, insertAfter(it, relativeDragOffset))
                    }
                }

@@ -147,8 +163,8 @@ fun Modifier.dragAndDropTileList(
    )
}

private fun DragAndDropEvent.dragOffsetRelativeTo(offset: Offset): Offset {
    return toAndroidDragEvent().run { Offset(x, y) } - offset
private fun DragAndDropEvent.toOffset(): Offset {
    return toAndroidDragEvent().run { Offset(x, y) }
}

private fun insertAfter(item: LazyGridItemInfo, offset: Offset): Boolean {
@@ -163,6 +179,7 @@ private fun insertAfter(item: LazyGridItemInfo, offset: Offset): Boolean {
fun Modifier.dragAndDropTileSource(
    sizedTile: SizedTile<EditTileViewModel>,
    dragAndDropState: DragAndDropState,
    dragType: DragType,
    onDragStart: () -> Unit,
): Modifier {
    val dragState by rememberUpdatedState(dragAndDropState)
@@ -172,7 +189,7 @@ fun Modifier.dragAndDropTileSource(
            detectDragGesturesAfterLongPress(
                onDrag = { _, _ -> },
                onDragStart = {
                    dragState.onStarted(sizedTile)
                    dragState.onStarted(sizedTile, dragType)
                    onDragStart()

                    // The tilespec from the ClipData transferred isn't actually needed as we're
+27 −11
Original line number Diff line number Diff line
@@ -17,10 +17,13 @@
package com.android.systemui.qs.panels.ui.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.geometry.Offset
import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.ui.model.GridCell
import com.android.systemui.qs.panels.ui.model.TileGridCell
@@ -48,12 +51,17 @@ class EditTileListState(
    private val columns: Int,
    private val largeTilesSpan: Int,
) : DragAndDropState {
    private val _draggedCell = mutableStateOf<SizedTile<EditTileViewModel>?>(null)
    override val draggedCell
        get() = _draggedCell.value
    override var draggedCell by mutableStateOf<SizedTile<EditTileViewModel>?>(null)
        private set

    override var draggedPosition by mutableStateOf(Offset.Unspecified)
        private set

    override var dragType by mutableStateOf<DragType?>(null)
        private set

    override val dragInProgress: Boolean
        get() = _draggedCell.value != null
        get() = draggedCell != null

    private val _tiles: SnapshotStateList<GridCell> =
        tiles.toGridCells(columns).toMutableStateList()
@@ -83,18 +91,19 @@ class EditTileListState(
    }

    override fun isMoving(tileSpec: TileSpec): Boolean {
        return _draggedCell.value?.let { it.tile.tileSpec == tileSpec } ?: false
        return draggedCell?.let { it.tile.tileSpec == tileSpec } ?: false
    }

    override fun onStarted(cell: SizedTile<EditTileViewModel>) {
        _draggedCell.value = cell
    override fun onStarted(cell: SizedTile<EditTileViewModel>, dragType: DragType) {
        draggedCell = cell
        this.dragType = dragType

        // Add spacers to the grid to indicate where the user can move a tile
        regenerateGrid()
    }

    override fun onMoved(target: Int, insertAfter: Boolean) {
        val draggedTile = _draggedCell.value ?: return
    override fun onTargeting(target: Int, insertAfter: Boolean) {
        val draggedTile = draggedCell ?: return

        val fromIndex = indexOf(draggedTile.tile.tileSpec)
        if (fromIndex == target) {
@@ -115,16 +124,23 @@ class EditTileListState(
        regenerateGrid()
    }

    override fun onMoved(offset: Offset) {
        draggedPosition = offset
    }

    override fun movedOutOfBounds() {
        val draggedTile = _draggedCell.value ?: return
        val draggedTile = draggedCell ?: return

        _tiles.removeIf { cell ->
            cell is TileGridCell && cell.tile.tileSpec == draggedTile.tile.tileSpec
        }
        draggedPosition = Offset.Unspecified
    }

    override fun onDrop() {
        _draggedCell.value = null
        draggedCell = null
        draggedPosition = Offset.Unspecified
        dragType = null

        // Remove the spacers
        regenerateGrid()
+100 −25
Original line number Diff line number Diff line
@@ -20,12 +20,16 @@ package com.android.systemui.qs.panels.ui.compose.infinitegrid

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalOverscrollFactory
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clipScrollableContainer
@@ -43,6 +47,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
@@ -69,6 +74,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -80,6 +86,7 @@ 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.isSpecified
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.MeasureScope
@@ -111,6 +118,7 @@ import com.android.systemui.qs.panels.shared.model.SizedTile
import com.android.systemui.qs.panels.shared.model.SizedTileImpl
import com.android.systemui.qs.panels.ui.compose.BounceableInfo
import com.android.systemui.qs.panels.ui.compose.DragAndDropState
import com.android.systemui.qs.panels.ui.compose.DragType
import com.android.systemui.qs.panels.ui.compose.EditTileListState
import com.android.systemui.qs.panels.ui.compose.bounceableInfo
import com.android.systemui.qs.panels.ui.compose.dragAndDropRemoveZone
@@ -120,6 +128,9 @@ import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults
import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.TileArrangementPadding
import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.TileHeight
import com.android.systemui.qs.panels.ui.compose.infinitegrid.CommonTileDefaults.ToggleTargetSize
import com.android.systemui.qs.panels.ui.compose.infinitegrid.EditModeTileDefaults.AUTO_SCROLL_DISTANCE
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.selection.MutableSelectionState
import com.android.systemui.qs.panels.ui.compose.selection.ResizableTileContainer
@@ -139,8 +150,10 @@ import com.android.systemui.qs.panels.ui.viewmodel.EditTileViewModel
import com.android.systemui.qs.pipeline.shared.TileSpec
import com.android.systemui.qs.shared.model.groupAndSort
import com.android.systemui.res.R
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay

object TileType
@@ -201,8 +214,12 @@ fun DefaultEditTileGrid(
    ) { innerPadding ->
        CompositionLocalProvider(LocalOverscrollFactory provides null) {
            val scrollState = rememberScrollState()
            LaunchedEffect(listState.dragInProgress) {
                if (listState.dragInProgress) {

            AutoScrollGrid(listState, scrollState, innerPadding)

            LaunchedEffect(listState.dragType) {
                // Only scroll to the top when adding a new tile, not when reordering existing ones
                if (listState.dragInProgress && listState.dragType == DragType.Add) {
                    scrollState.animateScrollTo(0)
                }
            }
@@ -223,7 +240,7 @@ fun DefaultEditTileGrid(
                AnimatedContent(
                    targetState = listState.dragInProgress,
                    modifier = Modifier.wrapContentSize(),
                    label = "",
                    label = "QSEditHeader",
                ) { dragIsInProgress ->
                    EditGridHeader(Modifier.dragAndDropRemoveZone(listState, onRemoveTile)) {
                        if (dragIsInProgress) {
@@ -243,12 +260,21 @@ fun DefaultEditTileGrid(
                    onSetTiles,
                )

                // Hide available tiles when dragging
                AnimatedVisibility(
                // Sets a minimum height to be used when available tiles are hidden
                Box(
                    Modifier.fillMaxWidth()
                        .requiredHeightIn(AvailableTilesGridMinHeight)
                        .animateContentSize()
                        .dragAndDropRemoveZone(listState, onRemoveTile)
                ) {
                    // Using the fully qualified name here as a workaround for AnimatedVisibility
                    // not being available from a Box
                    androidx.compose.animation.AnimatedVisibility(
                        visible = !listState.dragInProgress,
                        enter = fadeIn(),
                        exit = fadeOut(),
                    ) {
                        // Hide available tiles when dragging
                        Column(
                            verticalArrangement =
                                spacedBy(dimensionResource(id = R.dimen.qs_label_container_margin)),
@@ -261,15 +287,56 @@ fun DefaultEditTileGrid(
                            AvailableTileGrid(otherTiles, selectionState, columns, listState)
                        }
                    }
                }
            }
        }
    }
}

                // Drop zone to remove tiles dragged out of the tile grid
                Spacer(
                    modifier =
                        Modifier.fillMaxWidth()
                            .weight(1f)
                            .dragAndDropRemoveZone(listState, onRemoveTile)
                )
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun AutoScrollGrid(
    listState: EditTileListState,
    scrollState: ScrollState,
    padding: PaddingValues,
) {
    val density = LocalDensity.current
    val (top, bottom) =
        remember(density) {
            with(density) {
                padding.calculateTopPadding().roundToPx() to
                    padding.calculateBottomPadding().roundToPx()
            }
        }
    val scrollTarget by
        remember(listState, scrollState, top, bottom) {
            derivedStateOf {
                val position = listState.draggedPosition
                if (position.isSpecified) {
                    // Return the scroll target needed based on the position of the drag movement,
                    // or null if we don't need to scroll
                    val y = position.y.roundToInt()
                    when {
                        y < AUTO_SCROLL_DISTANCE + top -> 0
                        y > scrollState.viewportSize - bottom - AUTO_SCROLL_DISTANCE ->
                            scrollState.maxValue
                        else -> null
                    }
                } else {
                    null
                }
            }
        }
    LaunchedEffect(scrollTarget) {
        scrollTarget?.let {
            // Change the duration of the animation based on the distance to maintain the
            // same scrolling speed
            val distance = abs(it - scrollState.value)
            scrollState.animateScrollTo(
                it,
                animationSpec =
                    tween(durationMillis = distance * AUTO_SCROLL_SPEED, easing = LinearEasing),
            )
        }
    }
}
@@ -423,7 +490,7 @@ private fun AvailableTileGrid(
}

fun gridHeight(rows: Int, tileHeight: Dp, tilePadding: Dp, gridPadding: Dp): Dp {
    return ((tileHeight + tilePadding) * rows) - tilePadding + gridPadding * 2
    return ((tileHeight + tilePadding) * rows) + gridPadding * 2
}

private fun GridCell.key(index: Int, dragAndDropState: DragAndDropState): Any {
@@ -596,6 +663,7 @@ private fun TileGridCell(
                .dragAndDropTileSource(
                    SizedTileImpl(cell.tile, cell.width),
                    dragAndDropState,
                    DragType.Move,
                    selectionState::unSelect,
                )
                .tileBackground(colors.background)
@@ -631,7 +699,11 @@ private fun AvailableTileGridCell(
                    onClick(onClickActionName) { false }
                    this.stateDescription = stateDescription
                }
                .dragAndDropTileSource(SizedTileImpl(cell.tile, cell.width), dragAndDropState) {
                .dragAndDropTileSource(
                    SizedTileImpl(cell.tile, cell.width),
                    dragAndDropState,
                    DragType.Add,
                ) {
                    selectionState.unSelect()
                }
                .tileBackground(colors.background)
@@ -739,7 +811,10 @@ private fun Modifier.tileBackground(color: Color): Modifier {

private object EditModeTileDefaults {
    const val PLACEHOLDER_ALPHA = .3f
    const val AUTO_SCROLL_DISTANCE = 100
    const val AUTO_SCROLL_SPEED = 2 // 2ms per pixel
    val CurrentTilesGridPadding = 8.dp
    val AvailableTilesGridMinHeight = 200.dp

    @Composable
    fun editTileColors(): TileColors =
+6 −6
Original line number Diff line number Diff line
@@ -87,7 +87,7 @@ class DragAndDropTest : SysuiTestCase() {
        }
        composeRule.waitForIdle()

        listState.onStarted(TestEditTiles[0])
        listState.onStarted(TestEditTiles[0], DragType.Add)

        // Tile is being dragged, it should be replaced with a placeholder
        composeRule.onNodeWithContentDescription("tileA").assertDoesNotExist()
@@ -113,8 +113,8 @@ class DragAndDropTest : SysuiTestCase() {
        }
        composeRule.waitForIdle()

        listState.onStarted(TestEditTiles[0])
        listState.onMoved(1, false)
        listState.onStarted(TestEditTiles[0], DragType.Add)
        listState.onTargeting(1, false)
        listState.onDrop()

        // Available tiles should re-appear
@@ -140,7 +140,7 @@ class DragAndDropTest : SysuiTestCase() {
        }
        composeRule.waitForIdle()

        listState.onStarted(TestEditTiles[0])
        listState.onStarted(TestEditTiles[0], DragType.Add)
        listState.movedOutOfBounds()
        listState.onDrop()

@@ -165,11 +165,11 @@ class DragAndDropTest : SysuiTestCase() {
        }
        composeRule.waitForIdle()

        listState.onStarted(createEditTile("newTile"))
        listState.onStarted(createEditTile("newTile"), DragType.Add)
        // Insert after tileD, which is at index 4
        // [ a ] [ b ] [ c ] [ empty ]
        // [ tile d ] [ e ]
        listState.onMoved(4, insertAfter = true)
        listState.onTargeting(4, insertAfter = true)
        listState.onDrop()

        // Available tiles should re-appear