summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ResizeableItemFrame.kt242
-rw-r--r--packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelTest.kt323
-rw-r--r--packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModel.kt204
-rw-r--r--packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelKosmos.kt21
4 files changed, 790 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ResizeableItemFrame.kt b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ResizeableItemFrame.kt
new file mode 100644
index 000000000000..fda46b855a65
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/communal/ui/compose/ResizeableItemFrame.kt
@@ -0,0 +1,242 @@
+/*
+ * 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) }
+ }
+ }
+}
diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelTest.kt
new file mode 100644
index 000000000000..e1946fc7bc6f
--- /dev/null
+++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelTest.kt
@@ -0,0 +1,323 @@
+/*
+ * 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,
+ )
+}
diff --git a/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModel.kt b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModel.kt
new file mode 100644
index 000000000000..7aad33da97b6
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModel.kt
@@ -0,0 +1,204 @@
+/*
+ * 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.AnchoredDraggableState
+import androidx.compose.foundation.gestures.DraggableAnchors
+import androidx.compose.runtime.snapshotFlow
+import com.android.app.tracing.coroutines.coroutineScope
+import com.android.systemui.lifecycle.ExclusiveActivatable
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.dropWhile
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+
+enum class DragHandle {
+ TOP,
+ BOTTOM,
+}
+
+data class ResizeInfo(
+ /**
+ * The number of spans to resize by. A positive number indicates expansion, whereas a negative
+ * number indicates shrinking.
+ */
+ val spans: Int,
+ /** The drag handle which was used to resize the element. */
+ val fromHandle: DragHandle,
+)
+
+class ResizeableItemFrameViewModel : ExclusiveActivatable() {
+ private data class GridLayoutInfo(
+ val minSpan: Int,
+ val maxSpan: Int,
+ val heightPerSpanPx: Float,
+ val verticalItemSpacingPx: Float,
+ val currentRow: Int,
+ val currentSpan: Int,
+ )
+
+ /**
+ * The layout information necessary in order to calculate the pixel offsets of the drag anchor
+ * points.
+ */
+ private val gridLayoutInfo = MutableStateFlow<GridLayoutInfo?>(null)
+
+ val topDragState = AnchoredDraggableState(0, DraggableAnchors { 0 at 0f })
+ val bottomDragState = AnchoredDraggableState(0, DraggableAnchors { 0 at 0f })
+
+ /** Emits a [ResizeInfo] when the element is resized using a drag gesture. */
+ val resizeInfo: Flow<ResizeInfo> =
+ merge(
+ snapshotFlow { topDragState.settledValue }.map { ResizeInfo(-it, DragHandle.TOP) },
+ snapshotFlow { bottomDragState.settledValue }
+ .map { ResizeInfo(it, DragHandle.BOTTOM) },
+ )
+ .dropWhile { it.spans == 0 }
+ .distinctUntilChanged()
+
+ /**
+ * Sets the necessary grid layout information needed for calculating the pixel offsets of the
+ * drag anchors.
+ */
+ fun setGridLayoutInfo(
+ verticalItemSpacingPx: Float,
+ verticalContentPaddingPx: Float,
+ viewportHeightPx: Int,
+ maxItemSpan: Int,
+ minItemSpan: Int,
+ currentRow: Int,
+ currentSpan: Int,
+ ) {
+ require(maxItemSpan >= minItemSpan) {
+ "Maximum item span of $maxItemSpan cannot be less than the minimum span of $minItemSpan"
+ }
+ require(minItemSpan in 1..maxItemSpan) {
+ "Minimum span must be between 1 and $maxItemSpan, but was $minItemSpan"
+ }
+ require(currentSpan % minItemSpan == 0) {
+ "Current span of $currentSpan is not a multiple of the minimum span of $minItemSpan"
+ }
+ val availableHeight = viewportHeightPx - verticalContentPaddingPx
+ val totalSpacing = verticalItemSpacingPx * ((maxItemSpan / minItemSpan) - 1)
+ val heightPerSpanPx = (availableHeight - totalSpacing) / maxItemSpan
+ gridLayoutInfo.value =
+ GridLayoutInfo(
+ minSpan = minItemSpan,
+ maxSpan = maxItemSpan,
+ heightPerSpanPx = heightPerSpanPx,
+ verticalItemSpacingPx = verticalItemSpacingPx,
+ currentRow = currentRow,
+ currentSpan = currentSpan,
+ )
+ }
+
+ private fun calculateAnchorsForHandle(
+ handle: DragHandle,
+ layoutInfo: GridLayoutInfo,
+ ): DraggableAnchors<Int> {
+
+ if (!isDragAllowed(handle, layoutInfo)) {
+ return DraggableAnchors { 0 at 0f }
+ }
+
+ val (
+ minItemSpan,
+ maxItemSpan,
+ heightPerSpanPx,
+ verticalSpacingPx,
+ currentRow,
+ currentSpan,
+ ) = layoutInfo
+
+ // The maximum row this handle can be dragged to.
+ val maxRow =
+ if (handle == DragHandle.TOP) {
+ (currentRow + currentSpan - minItemSpan).coerceAtLeast(0)
+ } else {
+ maxItemSpan
+ }
+
+ // The minimum row this handle can be dragged to.
+ val minRow =
+ if (handle == DragHandle.TOP) {
+ 0
+ } else {
+ (currentRow + minItemSpan).coerceAtMost(maxItemSpan)
+ }
+
+ // The current row position of this handle
+ val currentPosition = if (handle == DragHandle.TOP) currentRow else currentRow + currentSpan
+
+ return DraggableAnchors {
+ for (targetRow in minRow..maxRow step minItemSpan) {
+ val diff = targetRow - currentPosition
+ val spacing = diff / minItemSpan * verticalSpacingPx
+ diff at diff * heightPerSpanPx + spacing
+ }
+ }
+ }
+
+ private fun isDragAllowed(handle: DragHandle, layoutInfo: GridLayoutInfo): Boolean {
+ val minItemSpan = layoutInfo.minSpan
+ val maxItemSpan = layoutInfo.maxSpan
+ val currentRow = layoutInfo.currentRow
+ val currentSpan = layoutInfo.currentSpan
+ val atMinSize = currentSpan == minItemSpan
+
+ // If already at the minimum size and in the first row, item cannot be expanded from the top
+ if (handle == DragHandle.TOP && currentRow == 0 && atMinSize) {
+ return false
+ }
+
+ // If already at the minimum size and occupying the last row, item cannot be expanded from
+ // the
+ // bottom
+ if (handle == DragHandle.BOTTOM && (currentRow + currentSpan) == maxItemSpan && atMinSize) {
+ return false
+ }
+
+ // If at maximum size, item can only be shrunk from the bottom and not the top.
+ if (handle == DragHandle.TOP && currentSpan == maxItemSpan) {
+ return false
+ }
+
+ return true
+ }
+
+ override suspend fun onActivated(): Nothing {
+ coroutineScope("ResizeableItemFrameViewModel.onActivated") {
+ gridLayoutInfo
+ .filterNotNull()
+ .onEach { layoutInfo ->
+ topDragState.updateAnchors(
+ calculateAnchorsForHandle(DragHandle.TOP, layoutInfo)
+ )
+ bottomDragState.updateAnchors(
+ calculateAnchorsForHandle(DragHandle.BOTTOM, layoutInfo)
+ )
+ }
+ .launchIn(this)
+ awaitCancellation()
+ }
+ }
+}
diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelKosmos.kt
new file mode 100644
index 000000000000..8422942a727a
--- /dev/null
+++ b/packages/SystemUI/tests/utils/src/com/android/systemui/communal/ui/viewmodel/ResizeableItemFrameViewModelKosmos.kt
@@ -0,0 +1,21 @@
+/*
+ * 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() }