diff options
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() } |