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

Commit 0fd4649f authored by Lucas Silva's avatar Lucas Silva Committed by Android (Google) Code Review
Browse files

Merge "Implement resizeable frame for widgets" into main

parents f5534e84 a27716a4
Loading
Loading
Loading
Loading
+242 −0
Original line number 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.communal.ui.compose

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastIsFinite
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.communal.ui.viewmodel.DragHandle
import com.android.systemui.communal.ui.viewmodel.ResizeInfo
import com.android.systemui.communal.ui.viewmodel.ResizeableItemFrameViewModel
import com.android.systemui.lifecycle.rememberViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull

@Composable
private fun UpdateGridLayoutInfo(
    viewModel: ResizeableItemFrameViewModel,
    index: Int,
    gridState: LazyGridState,
    minItemSpan: Int,
    gridContentPadding: PaddingValues,
    verticalArrangement: Arrangement.Vertical,
) {
    val density = LocalDensity.current
    LaunchedEffect(
        density,
        viewModel,
        index,
        gridState,
        minItemSpan,
        gridContentPadding,
        verticalArrangement,
    ) {
        val verticalItemSpacingPx = with(density) { verticalArrangement.spacing.toPx() }
        val verticalContentPaddingPx =
            with(density) {
                (gridContentPadding.calculateTopPadding() +
                        gridContentPadding.calculateBottomPadding())
                    .toPx()
            }

        combine(
                snapshotFlow { gridState.layoutInfo.maxSpan },
                snapshotFlow { gridState.layoutInfo.viewportSize.height },
                snapshotFlow {
                        gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }
                    }
                    .filterNotNull(),
                ::Triple,
            )
            .collectLatest { (maxItemSpan, viewportHeightPx, itemInfo) ->
                viewModel.setGridLayoutInfo(
                    verticalItemSpacingPx,
                    verticalContentPaddingPx,
                    viewportHeightPx,
                    maxItemSpan,
                    minItemSpan,
                    itemInfo.row,
                    itemInfo.span,
                )
            }
    }
}

@Composable
private fun BoxScope.DragHandle(
    handle: DragHandle,
    dragState: AnchoredDraggableState<Int>,
    outlinePadding: Dp,
    brush: Brush,
    alpha: () -> Float,
    modifier: Modifier = Modifier,
) {
    val directionalModifier = if (handle == DragHandle.TOP) -1 else 1
    val alignment = if (handle == DragHandle.TOP) Alignment.TopCenter else Alignment.BottomCenter
    Box(
        modifier
            .align(alignment)
            .graphicsLayer {
                translationY =
                    directionalModifier * (size.height / 2 + outlinePadding.toPx()) +
                        (dragState.offset.takeIf { it.fastIsFinite() } ?: 0f)
            }
            .anchoredDraggable(dragState, Orientation.Vertical)
    ) {
        Canvas(modifier = Modifier.fillMaxSize()) {
            if (dragState.anchors.size > 1) {
                drawCircle(
                    brush = brush,
                    radius = outlinePadding.toPx(),
                    center = Offset(size.width / 2, size.height / 2),
                    alpha = alpha(),
                )
            }
        }
    }
}

/**
 * Draws a frame around the content with drag handles on the top and bottom of the content.
 *
 * @param index The index of this item in the [LazyGridState].
 * @param gridState The [LazyGridState] for the grid containing this item.
 * @param minItemSpan The minimum span that an item may occupy. Items are resized in multiples of
 *   this span.
 * @param gridContentPadding The content padding used for the grid, needed for determining offsets.
 * @param verticalArrangement The vertical arrangement of the grid items.
 * @param modifier Optional modifier to apply to the frame.
 * @param enabled Whether resizing is enabled.
 * @param outlinePadding The padding to apply around the entire frame, in [Dp]
 * @param outlineColor Optional color to make the outline around the content.
 * @param cornerRadius Optional radius to give to the outline around the content.
 * @param strokeWidth Optional stroke width to draw the outline with.
 * @param alpha Optional function to provide an alpha value for the outline. Can be used to fade the
 *   outline in and out. This is wrapped in a function for performance, as the value is only
 *   accessed during the draw phase.
 * @param onResize Optional callback which gets executed when the item is resized to a new span.
 * @param content The content to draw inside the frame.
 */
@Composable
fun ResizableItemFrame(
    index: Int,
    gridState: LazyGridState,
    minItemSpan: Int,
    gridContentPadding: PaddingValues,
    verticalArrangement: Arrangement.Vertical,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    outlinePadding: Dp = 8.dp,
    outlineColor: Color = LocalAndroidColorScheme.current.primary,
    cornerRadius: Dp = 37.dp,
    strokeWidth: Dp = 3.dp,
    alpha: () -> Float = { 1f },
    onResize: (info: ResizeInfo) -> Unit = {},
    content: @Composable () -> Unit,
) {
    val brush = SolidColor(outlineColor)
    val viewModel =
        rememberViewModel(traceName = "ResizeableItemFrame.viewModel") {
            ResizeableItemFrameViewModel()
        }

    val dragHandleHeight = verticalArrangement.spacing - outlinePadding * 2

    // Draw content surrounded by drag handles at top and bottom. Allow drag handles
    // to overlap content.
    Box(modifier) {
        content()

        if (enabled) {
            DragHandle(
                handle = DragHandle.TOP,
                dragState = viewModel.topDragState,
                outlinePadding = outlinePadding,
                brush = brush,
                alpha = alpha,
                modifier = Modifier.fillMaxWidth().height(dragHandleHeight),
            )

            DragHandle(
                handle = DragHandle.BOTTOM,
                dragState = viewModel.bottomDragState,
                outlinePadding = outlinePadding,
                brush = brush,
                alpha = alpha,
                modifier = Modifier.fillMaxWidth().height(dragHandleHeight),
            )

            // Draw outline around the element.
            Canvas(modifier = Modifier.matchParentSize()) {
                val paddingPx = outlinePadding.toPx()
                val topOffset = viewModel.topDragState.offset.takeIf { it.fastIsFinite() } ?: 0f
                val bottomOffset =
                    viewModel.bottomDragState.offset.takeIf { it.fastIsFinite() } ?: 0f
                drawRoundRect(
                    brush,
                    alpha = alpha(),
                    topLeft = Offset(-paddingPx, topOffset + -paddingPx),
                    size =
                        Size(
                            width = size.width + paddingPx * 2,
                            height = -topOffset + bottomOffset + size.height + paddingPx * 2,
                        ),
                    cornerRadius = CornerRadius(cornerRadius.toPx()),
                    style = Stroke(width = strokeWidth.toPx()),
                )
            }

            UpdateGridLayoutInfo(
                viewModel,
                index,
                gridState,
                minItemSpan,
                gridContentPadding,
                verticalArrangement,
            )
            LaunchedEffect(viewModel) { viewModel.resizeInfo.collectLatest(onResize) }
        }
    }
}
+323 −0
Original line number 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.communal.ui.viewmodel

import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.runtime.snapshots.Snapshot
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlin.time.Duration
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class ResizeableItemFrameViewModelTest : SysuiTestCase() {
    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val underTest = kosmos.resizeableItemFrameViewModel

    /** Total viewport height of the entire grid */
    private val viewportHeightPx = 100
    /** Total amount of vertical padding around the viewport */
    private val verticalContentPaddingPx = 20f

    private val singleSpanGrid =
        GridLayout(
            verticalItemSpacingPx = 10f,
            verticalContentPaddingPx = verticalContentPaddingPx,
            viewportHeightPx = viewportHeightPx,
            maxItemSpan = 1,
            minItemSpan = 1,
            currentSpan = 1,
            currentRow = 0,
        )

    @Before
    fun setUp() {
        underTest.activateIn(testScope)
    }

    @Test
    fun testDefaultState() {
        val topState = underTest.topDragState
        assertThat(topState.currentValue).isEqualTo(0)
        assertThat(topState.offset).isEqualTo(0f)
        assertThat(topState.anchors.toList()).containsExactly(0 to 0f)

        val bottomState = underTest.bottomDragState
        assertThat(bottomState.currentValue).isEqualTo(0)
        assertThat(bottomState.offset).isEqualTo(0f)
        assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f)
    }

    @Test
    fun testSingleSpanGrid() =
        testScope.runTest(timeout = Duration.INFINITE) {
            updateGridLayout(singleSpanGrid)

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f)
        }

    /**
     * Verifies element in first row which is already at the minimum size can only be expanded
     * downwards.
     */
    @Test
    fun testTwoSpanGrid_elementInFirstRow_sizeSingleSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2))

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f, 1 to 45f)
        }

    /**
     * Verifies element in second row which is already at the minimum size can only be expanded
     * upwards.
     */
    @Test
    fun testTwoSpanGrid_elementInSecondRow_sizeSingleSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, currentRow = 1))

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f, -1 to -45f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f)
        }

    /**
     * Verifies element in first row which is already at full size (2 span) can only be shrunk from
     * the bottom.
     */
    @Test
    fun testTwoSpanGrid_elementInFirstRow_sizeTwoSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, currentSpan = 2))

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f, -1 to -45f)
        }

    /**
     * Verifies element in a middle row at minimum size can be expanded from either top or bottom.
     */
    @Test
    fun testThreeSpanGrid_elementInMiddleRow_sizeOneSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 3, currentRow = 1))

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f, -1 to -30f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f, 1 to 30f)
        }

    @Test
    fun testThreeSpanGrid_elementInTopRow_sizeOneSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 3))

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f, 1 to 30f, 2 to 60f)
        }

    @Test
    fun testSixSpanGrid_minSpanThree_itemInThirdRow_sizeThreeSpans() =
        testScope.runTest {
            updateGridLayout(
                singleSpanGrid.copy(
                    maxItemSpan = 6,
                    currentRow = 3,
                    currentSpan = 3,
                    minItemSpan = 3,
                )
            )

            val topState = underTest.topDragState
            assertThat(topState.currentValue).isEqualTo(0)
            assertThat(topState.anchors.toList()).containsExactly(0 to 0f, -3 to -45f)

            val bottomState = underTest.bottomDragState
            assertThat(bottomState.currentValue).isEqualTo(0)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f)
        }

    @Test
    fun testTwoSpanGrid_elementMovesFromFirstRowToSecondRow() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2))

            val topState = underTest.topDragState
            val bottomState = underTest.bottomDragState

            assertThat(topState.anchors.toList()).containsExactly(0 to 0f)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f, 1 to 45f)

            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, currentRow = 1))

            assertThat(topState.anchors.toList()).containsExactly(0 to 0f, -1 to -45f)
            assertThat(bottomState.anchors.toList()).containsExactly(0 to 0f)
        }

    @Test
    fun testTwoSpanGrid_expandElementFromBottom() = runTestWithSnapshots {
        val resizeInfo by collectLastValue(underTest.resizeInfo)
        updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2))

        assertThat(resizeInfo).isNull()
        underTest.bottomDragState.anchoredDrag { dragTo(45f) }
        assertThat(resizeInfo).isEqualTo(ResizeInfo(1, DragHandle.BOTTOM))
    }

    @Test
    fun testThreeSpanGrid_expandMiddleElementUpwards() = runTestWithSnapshots {
        val resizeInfo by collectLastValue(underTest.resizeInfo)
        updateGridLayout(singleSpanGrid.copy(maxItemSpan = 3, currentRow = 1))

        assertThat(resizeInfo).isNull()
        underTest.topDragState.anchoredDrag { dragTo(-30f) }
        assertThat(resizeInfo).isEqualTo(ResizeInfo(1, DragHandle.TOP))
    }

    @Test
    fun testThreeSpanGrid_expandTopElementDownBy2Spans() = runTestWithSnapshots {
        val resizeInfo by collectLastValue(underTest.resizeInfo)
        updateGridLayout(singleSpanGrid.copy(maxItemSpan = 3))

        assertThat(resizeInfo).isNull()
        underTest.bottomDragState.anchoredDrag { dragTo(60f) }
        assertThat(resizeInfo).isEqualTo(ResizeInfo(2, DragHandle.BOTTOM))
    }

    @Test
    fun testTwoSpanGrid_shrinkElementFromBottom() = runTestWithSnapshots {
        val resizeInfo by collectLastValue(underTest.resizeInfo)
        updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, currentSpan = 2))

        assertThat(resizeInfo).isNull()
        underTest.bottomDragState.anchoredDrag { dragTo(-45f) }
        assertThat(resizeInfo).isEqualTo(ResizeInfo(-1, DragHandle.BOTTOM))
    }

    @Test(expected = IllegalArgumentException::class)
    fun testIllegalState_maxSpanSmallerThanMinSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, minItemSpan = 3))
        }

    @Test(expected = IllegalArgumentException::class)
    fun testIllegalState_minSpanOfZero() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 2, minItemSpan = 0))
        }

    @Test(expected = IllegalArgumentException::class)
    fun testIllegalState_maxSpanOfZero() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 0, minItemSpan = 0))
        }

    @Test(expected = IllegalArgumentException::class)
    fun testIllegalState_currentRowNotMultipleOfMinSpan() =
        testScope.runTest {
            updateGridLayout(singleSpanGrid.copy(maxItemSpan = 6, minItemSpan = 3, currentSpan = 2))
        }

    private fun TestScope.updateGridLayout(gridLayout: GridLayout) {
        underTest.setGridLayoutInfo(
            gridLayout.verticalItemSpacingPx,
            gridLayout.verticalContentPaddingPx,
            gridLayout.viewportHeightPx,
            gridLayout.maxItemSpan,
            gridLayout.minItemSpan,
            gridLayout.currentRow,
            gridLayout.currentSpan,
        )
        runCurrent()
    }

    private fun DraggableAnchors<Int>.toList() = buildList {
        for (index in 0 until this@toList.size) {
            add(anchorAt(index) to positionAt(index))
        }
    }

    private fun runTestWithSnapshots(testBody: suspend TestScope.() -> Unit) {
        val globalWriteObserverHandle =
            Snapshot.registerGlobalWriteObserver {
                // This is normally done by the compose runtime.
                Snapshot.sendApplyNotifications()
            }

        try {
            testScope.runTest(testBody = testBody)
        } finally {
            globalWriteObserverHandle.dispose()
        }
    }

    private data class GridLayout(
        val verticalItemSpacingPx: Float,
        val verticalContentPaddingPx: Float,
        val viewportHeightPx: Int,
        val maxItemSpan: Int,
        val minItemSpan: Int,
        val currentRow: Int,
        val currentSpan: Int,
    )
}
+204 −0

File added.

Preview size limit exceeded, changes collapsed.

+21 −0
Original line number 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.communal.ui.viewmodel

import com.android.systemui.kosmos.Kosmos

val Kosmos.resizeableItemFrameViewModel by Kosmos.Fixture { ResizeableItemFrameViewModel() }