From a992e547f0ba2ca5ae656bd76b5aed1cdfad4e2b Mon Sep 17 00:00:00 2001 From: Evgeny Landarsky Date: Tue, 20 Dec 2022 12:11:07 +0100 Subject: [PATCH] Fix pixels distribution algorithm for weight-based sizes --- .../touchlane/gridpad/example/MainActivity.kt | 22 +-- .../com/touchlane/gridpad/GridPadDsl.kt | 131 ++++++++++++------ .../com/touchlane/gridpad/GridPadScopeTest.kt | 89 ++++++++++++ 3 files changed, 190 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/com/touchlane/gridpad/example/MainActivity.kt b/app/src/main/java/com/touchlane/gridpad/example/MainActivity.kt index 628f7db..ca9746c 100644 --- a/app/src/main/java/com/touchlane/gridpad/example/MainActivity.kt +++ b/app/src/main/java/com/touchlane/gridpad/example/MainActivity.kt @@ -273,37 +273,37 @@ fun SimpleBlueprintCardWithSpansOverlapped() { fun ListOfPads(modifier: Modifier = Modifier) { LazyColumn(modifier = modifier) { item { - SimpleBlueprintCard() + InteractivePinPadCard() } item { - CustomSizeBlueprintCard() + EngineeringCalculatorPadCard() } item { - SimpleBlueprintCardWithContent() + SimplePriorityCalculatorPadCard() } item { - SimpleBlueprintCardWithContentMixOrdering() + SimpleCalculatorPadCard() } item { - SimpleBlueprintCardWithSpans() + PinPadCard() } item { - SimpleBlueprintCardWithSpansOverlapped() + SimpleBlueprintCard() } item { - PinPadCard() + CustomSizeBlueprintCard() } item { - SimpleCalculatorPadCard() + SimpleBlueprintCardWithContent() } item { - SimplePriorityCalculatorPadCard() + SimpleBlueprintCardWithContentMixOrdering() } item { - EngineeringCalculatorPadCard() + SimpleBlueprintCardWithSpans() } item { - InteractivePinPadCard() + SimpleBlueprintCardWithSpansOverlapped() } } } diff --git a/gridpad/src/main/kotlin/com/touchlane/gridpad/GridPadDsl.kt b/gridpad/src/main/kotlin/com/touchlane/gridpad/GridPadDsl.kt index 0028fc8..2caedfe 100644 --- a/gridpad/src/main/kotlin/com/touchlane/gridpad/GridPadDsl.kt +++ b/gridpad/src/main/kotlin/com/touchlane/gridpad/GridPadDsl.kt @@ -27,8 +27,12 @@ package com.touchlane.gridpad import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.Placeable import androidx.compose.ui.unit.Constraints +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlin.math.min import kotlin.math.roundToInt @@ -51,8 +55,9 @@ public fun GridPad( cells: GridPadCells, modifier: Modifier = Modifier, content: GridPadScope.() -> Unit ) { val scopeContent: GridPadScopeImpl = GridPadScopeImpl(cells).apply(content) + val displayContent: ImmutableList = scopeContent.data.toImmutableList() Layout(modifier = modifier, content = { - scopeContent.data.forEach { + displayContent.forEach { it.item(GridPadItemScopeImpl) } }) { measurables, constraints -> @@ -64,27 +69,7 @@ public fun GridPad( } val cellPlaces = calculateCellPlaces(cells, width = constraints.maxWidth, height = constraints.maxHeight) - // Don't constrain child views further, measure them with given constraints - // List of measured children - val placeables = measurables.mapIndexed { index, measurable -> - val contentMetaInfo = scopeContent.data[index] - val maxWidth = (0 until contentMetaInfo.columnSpan).sumOf { - cellPlaces[contentMetaInfo.row][contentMetaInfo.column + it].width - } - val maxHeight = (0 until contentMetaInfo.rowSpan).sumOf { - cellPlaces[contentMetaInfo.row + it][contentMetaInfo.column].height - } - - // Measure each children - measurable.measure( - constraints.copy( - minWidth = min(constraints.minWidth, maxWidth), - maxWidth = maxWidth, - minHeight = min(constraints.minHeight, maxHeight), - maxHeight = maxHeight - ) - ) - } + val placeables = measurables.measure(displayContent, cellPlaces, constraints) //in cases when all columns have a fixed size, we limit layout width to the sum of them val layoutWidth = if (cells.columnsTotalSize.weight == 0f) { @@ -103,7 +88,7 @@ public fun GridPad( //place items layout(layoutWidth, layoutHeight) { placeables.forEachIndexed { index, placeable -> - val contentMetaInfo = scopeContent.data[index] + val contentMetaInfo = displayContent[index] val cellPlaceInfo = cellPlaces[contentMetaInfo.row][contentMetaInfo.column] placeable.placeRelative(x = cellPlaceInfo.x, y = cellPlaceInfo.y) } @@ -111,27 +96,41 @@ public fun GridPad( } } +/** + * Measure children + */ +private fun List.measure( + content: ImmutableList, + cellPlaces: Array>, + constraints: Constraints +): List = mapIndexed { index, measurable -> + val contentMetaInfo = content[index] + val maxWidth = (0 until contentMetaInfo.columnSpan).sumOf { + cellPlaces[contentMetaInfo.row][contentMetaInfo.column + it].width + } + val maxHeight = (0 until contentMetaInfo.rowSpan).sumOf { + cellPlaces[contentMetaInfo.row + it][contentMetaInfo.column].height + } + + // Measure each children + measurable.measure( + constraints.copy( + minWidth = min(constraints.minWidth, maxWidth), + maxWidth = maxWidth, + minHeight = min(constraints.minHeight, maxHeight), + maxHeight = maxHeight + ) + ) +} + +/** + * Build the grid that will be used in the measurement and placement stage. + */ private fun MeasureScope.calculateCellPlaces( cells: GridPadCells, width: Int, height: Int ): Array> { - val availableWeightWidth = width - cells.columnsTotalSize.fixed.roundToPx() - val availableWeightHeight = height - cells.rowsTotalSize.fixed.roundToPx() - - //Calculate real cell widths - val columnWidths = cells.columnSizes.map { columnSize -> - when (columnSize) { - is GridPadCellSize.Fixed -> columnSize.size.roundToPx() - is GridPadCellSize.Weight -> (availableWeightWidth * columnSize.size / cells.columnsTotalSize.weight).roundToInt() - } - } - - //Calculate real cell heights - val columnHeights = cells.rowSizes.map { rowSize -> - when (rowSize) { - is GridPadCellSize.Fixed -> rowSize.size.roundToPx() - is GridPadCellSize.Weight -> (availableWeightHeight * rowSize.size / cells.rowsTotalSize.weight).roundToInt() - } - } + val columnWidths = calculateSizesForDimension(width, cells.columnSizes, cells.columnsTotalSize) + val columnHeights = calculateSizesForDimension(height, cells.rowSizes, cells.rowsTotalSize) //Calculate grid with positions and cell sizes var y = 0f @@ -153,6 +152,56 @@ private fun MeasureScope.calculateCellPlaces( return cellPlaces.map { it.toTypedArray() }.toTypedArray() } +/** + * Calculate sizes for row or column. + * The remaining pixels should be distributed equally between the last cells in cases where + * the first pass did not distribute them. For example, we have to split 101px among 3 cells + * with a weight equal to 1. This means that after the first pass the cell sizes + * would be [33, 33, 33] with the remainder size equal to 2. In a second pass, + * the remaining 2 pixels will be distributed between the last 2 cells and the final + * result will be [33, 34, 34]. + * + * @param availableSize parent size that available for distribution + * @param cellSizes sizes for rows or columns + * @param totalSize precalculated total size + */ +private fun MeasureScope.calculateSizesForDimension( + availableSize: Int, cellSizes: ImmutableList, totalSize: TotalSize +): List { + val availableWeight = availableSize - totalSize.fixed.roundToPx() + + //First pass calculation + var distributedSize = 0 + val sizes = cellSizes.map { cellSize -> + when (cellSize) { + is GridPadCellSize.Fixed -> { + val size = cellSize.size.roundToPx() + distributedSize += size + size + } + is GridPadCellSize.Weight -> { + val size = (availableWeight * cellSize.size / totalSize.weight).toInt() + distributedSize += size + size + } + } + }.toMutableList() + + //Distribute remaining pixels + var notDistributedSize = availableSize - distributedSize + cellSizes.reversed().mapIndexed { index, gridPadCellSize -> + if (notDistributedSize <= 0) { + return@mapIndexed + } + val originalIndex = cellSizes.size - index - 1 + if (gridPadCellSize is GridPadCellSize.Weight) { + sizes[originalIndex] = sizes[originalIndex] + 1 + notDistributedSize-- + } + } + return sizes +} + private data class CellPlaceInfo(val x: Int, val y: Int, val width: Int, val height: Int) /** diff --git a/gridpad/src/test/kotlin/com/touchlane/gridpad/GridPadScopeTest.kt b/gridpad/src/test/kotlin/com/touchlane/gridpad/GridPadScopeTest.kt index 75b36bd..69eae45 100644 --- a/gridpad/src/test/kotlin/com/touchlane/gridpad/GridPadScopeTest.kt +++ b/gridpad/src/test/kotlin/com/touchlane/gridpad/GridPadScopeTest.kt @@ -26,12 +26,18 @@ package com.touchlane.gridpad +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before @@ -122,6 +128,89 @@ class GridPadScopeTest { onNode(hasText("0:0")) .assertDoesNotExist() } + + @Test + fun `Check size distribution 100x100`() = with(composeTestRule) { + setContent { + Box(modifier = Modifier.size(100.dp, 100.dp)) { + GridPad(cells = GridPadCells(rowCount = 3, columnCount = 3)) { + item(row = 0, column = 0) { MaxSizeText(text = "0:0") } + item(row = 0, column = 1) { MaxSizeText(text = "0:1") } + item(row = 0, column = 2) { MaxSizeText(text = "0:2") } + item(row = 1, column = 0) { MaxSizeText(text = "1:0") } + item(row = 2, column = 0) { MaxSizeText(text = "2:0") } + } + } + } + val bounds00 = boundsForNodeWithText("0:0") + assertEquals(Rect(0f, 0f, 33f, 33f), bounds00) + val bounds01 = boundsForNodeWithText("0:1") + assertEquals(Rect(33f, 0f, 66f, 33f), bounds01) + val bounds02 = boundsForNodeWithText("0:2") + assertEquals(Rect(66f, 0f, 100f, 33f), bounds02) + val bounds10 = boundsForNodeWithText("1:0") + assertEquals(Rect(0f, 33f, 33f, 66f), bounds10) + val bounds20 = boundsForNodeWithText("2:0") + assertEquals(Rect(0f, 66f, 33f, 100f), bounds20) + } + + @Test + fun `Check size distribution 101x10`() = with(composeTestRule) { + setContent { + Box(modifier = Modifier.size(101.dp, 10.dp)) { + GridPad(cells = GridPadCells(rowCount = 1, columnCount = 3)) { + item(row = 0, column = 0) { MaxSizeText(text = "0:0") } + item(row = 0, column = 1) { MaxSizeText(text = "0:1") } + item(row = 0, column = 2) { MaxSizeText(text = "0:2") } + } + } + } + val bounds00 = boundsForNodeWithText("0:0") + assertEquals(Rect(0f, 0f, 33f, 10f), bounds00) + val bounds01 = boundsForNodeWithText("0:1") + assertEquals(Rect(33f, 0f, 67f, 10f), bounds01) + val bounds02 = boundsForNodeWithText("0:2") + assertEquals(Rect(67f, 0f, 101f, 10f), bounds02) + } + + @Test + fun `Check size distribution 76x10`() = with(composeTestRule) { + setContent { + Box(modifier = Modifier.size(76.dp, 10.dp)) { + GridPad(cells = GridPadCells(rowCount = 1, columnCount = 7)) { + item(row = 0, column = 0) { MaxSizeText(text = "0:0") } + item(row = 0, column = 1) { MaxSizeText(text = "0:1") } + item(row = 0, column = 2) { MaxSizeText(text = "0:2") } + item(row = 0, column = 3) { MaxSizeText(text = "0:3") } + item(row = 0, column = 4) { MaxSizeText(text = "0:4") } + item(row = 0, column = 5) { MaxSizeText(text = "0:5") } + item(row = 0, column = 6) { MaxSizeText(text = "0:6") } + } + } + } + val bounds00 = boundsForNodeWithText("0:0") + assertEquals(Rect(0f, 0f, 10f, 10f), bounds00) + val bounds01 = boundsForNodeWithText("0:1") + assertEquals(Rect(10f, 0f, 21f, 10f), bounds01) + val bounds02 = boundsForNodeWithText("0:2") + assertEquals(Rect(21f, 0f, 32f, 10f), bounds02) + val bounds03 = boundsForNodeWithText("0:3") + assertEquals(Rect(32f, 0f, 43f, 10f), bounds03) + val bounds04 = boundsForNodeWithText("0:4") + assertEquals(Rect(43f, 0f, 54f, 10f), bounds04) + val bounds05 = boundsForNodeWithText("0:5") + assertEquals(Rect(54f, 0f, 65f, 10f), bounds05) + val bounds06 = boundsForNodeWithText("0:6") + assertEquals(Rect(65f, 0f, 76f, 10f), bounds06) + } +} + +@Composable +private fun MaxSizeText(text: String) { + Text( + text = text, + modifier = Modifier.fillMaxSize() + ) } internal fun ComposeContentTestRule.boundsForNodeWithText(text: String): Rect {