Skip to content

Commit

Permalink
Fix pixels distribution algorithm for weight-based sizes
Browse files Browse the repository at this point in the history
  • Loading branch information
landarskiy committed Dec 20, 2022
1 parent e4478fe commit a992e54
Show file tree
Hide file tree
Showing 3 changed files with 190 additions and 52 deletions.
22 changes: 11 additions & 11 deletions app/src/main/java/com/touchlane/gridpad/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
Expand Down
131 changes: 90 additions & 41 deletions gridpad/src/main/kotlin/com/touchlane/gridpad/GridPadDsl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<GridPadContent> = scopeContent.data.toImmutableList()
Layout(modifier = modifier, content = {
scopeContent.data.forEach {
displayContent.forEach {
it.item(GridPadItemScopeImpl)
}
}) { measurables, constraints ->
Expand All @@ -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) {
Expand All @@ -103,35 +88,49 @@ 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)
}
}
}
}

/**
* Measure children
*/
private fun List<Measurable>.measure(
content: ImmutableList<GridPadContent>,
cellPlaces: Array<Array<CellPlaceInfo>>,
constraints: Constraints
): List<Placeable> = 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<Array<CellPlaceInfo>> {
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
Expand All @@ -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<GridPadCellSize>, totalSize: TotalSize
): List<Int> {
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)

/**
Expand Down
89 changes: 89 additions & 0 deletions gridpad/src/test/kotlin/com/touchlane/gridpad/GridPadScopeTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit a992e54

Please sign in to comment.