MetroWind
MetroWind

Reputation: 541

Jetpack Compose: grid layout with "cell merge"?

I'm trying to implement a simple grid layout that

  1. the cell views are static (in the sense that it's not a dynamic list of things)
  2. Has a fixed number of columns. The grid fill the whole screen, and cell width and height is such that the cells will fill the whole screen without gaps.

So I have the following code

@Composable
fun GridView(modifier: Modifier = Modifier, content: @Composable () -> Unit)
{
    Layout(modifier = modifier, content = content)
    { measurables, constraints ->
        // For now I'll just hard code the number of columns.
        val num_cols = 2
        // Determine number of rows.
        val num_rows = ceil(measurables.size.toDouble() / num_cols).toInt()
        val cell_width = constraints.maxWidth / num_cols
        val cell_height = constraints.maxHeight / num_rows

        val cell_constraints = constraints.copy(minHeight = 0, minWidth = 0,
            maxHeight = cell_height, maxWidth = cell_width)
        val placeables = measurables.map{ measurable -> measurable.measure(cell_constraints) }
        layout(constraints.maxWidth, constraints.maxHeight)
        {
            placeables.forEachIndexed { i, placeable ->
                // Calculate location of childs exactly.
                placeable.placeRelative(x = i % num_cols * cell_width,
                    y = i / num_cols * cell_height)
            }
        }
    }
}

Since the number of cells is known, the custom layout can calculate and assign the position of each cell. If I compose this with some Buttons,

GridView(Modifier.fillMaxWidth().fillMaxHeight())
{
    Button(Modifier.fillMaxWidth().fillMaxHeight()) { /* ... */ }
    Button(Modifier.fillMaxWidth().fillMaxHeight()) { /* ... */ }
    // ...
}

I get the screenshot at the end, which is exactly what I want out of this code. However now I want to be able to say that a particular cell should be (for example) 2-wide. Is there a way to do that? In my mind the ideal interface would be something like

data class CellSpan(val col: Int, val row: Int)

GridView(Modifier.fillMaxWidth().fillMaxHeight())
{
    // This cell takes 2 slots. It is 1 slot wide and 2 slot high.
    CellView(modifier = Modifier.fillMaxWidth().fillMaxHeight(), 
             span = CellSpan(1, 2) { /* ... */ }
    // This cell just takes 1 slot.
    CellView(Modifier.fillMaxWidth().fillMaxHeight()) { /* ... */ }
    // ...
}

Upvotes: 0

Views: 1025

Answers (2)

Maciej Przybylski
Maciej Przybylski

Reputation: 421

item(span = {
       GridItemSpan(2)
 }){// item code here}

Upvotes: 1

Francesc
Francesc

Reputation: 29260

I have written an article to describe how to achieve this here.

The full code is also below, with a Preview section to show how to use it:

interface GridScope {
    @Stable
    fun Modifier.span(columns: Int = 1, rows: Int = 1) = this.then(
        GridData(columns, rows)
    )

    companion object : GridScope
}

private class GridData(
    val columnSpan: Int,
    val rowSpan: Int,
) : ParentDataModifier {

    override fun Density.modifyParentData(parentData: Any?): Any = this@GridData

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false

        other as GridData

        if (columnSpan != other.columnSpan) return false
        if (rowSpan != other.rowSpan) return false

        return true
    }

    override fun hashCode(): Int {
        var result = columnSpan
        result = 31 * result + rowSpan
        return result
    }
}

private val Measurable.gridData: GridData?
    get() = parentData as? GridData

private val Measurable.columnSpan: Int
    get() = gridData?.columnSpan ?: 1

private val Measurable.rowSpan: Int
    get() = gridData?.rowSpan ?: 1

data class GridInfo(
    val numChildren: Int,
    val columnSpan: Int,
    val rowSpan: Int,
)

@Composable
fun Grid(
    columns: Int,
    modifier: Modifier = Modifier,
    content: @Composable GridScope.() -> Unit,
) {
    check(columns > 0) { "Columns must be greater than 0" }
    Layout(
        content = { GridScope.content() },
        modifier = modifier,
    ) { measurables, constraints ->
        // calculate how many rows we need
        val standardGrid = GridData(1, 1)
        val spans = measurables.map { measurable -> measurable.gridData ?: standardGrid }
        val gridInfo = calculateGridInfo(spans, columns)
        val rows = gridInfo.sumOf { it.rowSpan }

        // build constraints
        val baseConstraints = Constraints.fixed(
            width = constraints.maxWidth / columns,
            height = constraints.maxHeight / rows,
        )
        val cellConstraints = measurables.map { measurable ->
            val columnSpan = measurable.columnSpan
            val rowSpan = measurable.rowSpan
            Constraints.fixed(
                width = baseConstraints.maxWidth * columnSpan,
                height = baseConstraints.maxHeight * rowSpan
            )
        }

        // measure children
        val placeables = measurables.mapIndexed { index, measurable ->
            measurable.measure(cellConstraints[index])
        }

        // place children
        layout(
            width = constraints.maxWidth,
            height = constraints.maxHeight,
        ) {
            var x = 0
            var y = 0
            var childIndex = 0
            gridInfo.forEach { info ->
                repeat(info.numChildren) { index ->
                    val placeable = placeables[childIndex++]
                    placeable.placeRelative(
                        x = x,
                        y = y,
                    )
                    x += placeable.width
                }
                x = 0
                y += info.rowSpan * baseConstraints.maxHeight
            }
        }
    }
}

private fun calculateGridInfo(
    spans: List<GridData>,
    columns: Int,
): List<GridInfo> {
    var currentColumnSpan = 0
    var currentRowSpan = 0
    var numChildren = 0
    return buildList {
        spans.forEach { span ->
            val columnSpan = span.columnSpan.coerceAtMost(columns)
            val rowSpan = span.rowSpan
            if (currentColumnSpan + columnSpan <= columns) {
                currentColumnSpan += columnSpan
                currentRowSpan = max(currentRowSpan, rowSpan)
                ++numChildren
            } else {
                add(
                    GridInfo(
                        numChildren = numChildren,
                        columnSpan = currentColumnSpan,
                        rowSpan = currentRowSpan
                    )
                )
                currentColumnSpan = columnSpan
                currentRowSpan = rowSpan
                numChildren = 1
            }
        }
        add(
            GridInfo(
                numChildren = numChildren,
                columnSpan = currentColumnSpan,
                rowSpan = currentRowSpan,
            )
        )
    }
}

@Preview(widthDp = 420, heightDp = 800)
@Composable
fun PreviewGrid() {
    ComposeTheme {
        Surface(
            modifier = Modifier
                .fillMaxWidth()
                .background(MaterialTheme.colorScheme.background),
        ) {
            Grid(
                columns = 3,
                modifier = Modifier.fillMaxSize(),
            ) {
                Box(
                    modifier = Modifier
                        .background(Color.Red)
                        .span(
                            columns = 1,
                            rows = 1,
                        )
                ) {
                    Text(text = "1x1", modifier = Modifier.align(Alignment.Center))
                }
                Box(
                    modifier = Modifier
                        .background(Color.Cyan)
                        .span(
                            columns = 2,
                            rows = 1,
                        )
                ) {
                    Text(text = "2x1", modifier = Modifier.align(Alignment.Center))
                }
                Box(
                    modifier = Modifier
                        .background(Color.Green)
                        .span(
                            columns = 2,
                            rows = 1,
                        )
                ) {
                    Text(text = "2x1", modifier = Modifier.align(Alignment.Center))
                }
                Box(
                    modifier = Modifier
                        .background(Color.Red)
                        .span(
                            columns = 2,
                            rows = 3,
                        )
                ) {
                    Text(text = "2x3", modifier = Modifier.align(Alignment.Center))
                }
                Box(
                    modifier = Modifier
                        .background(Color.Cyan)
                        .span(
                            columns = 1,
                            rows = 2,
                        )
                ) {
                    Text(text = "1x2", modifier = Modifier.align(Alignment.Center))
                }
                Box(
                    modifier = Modifier
                        .background(Color.Magenta)
                        .span(
                            columns = 3,
                            rows = 1,
                        )
                ) {
                    Text(text = "3x1", modifier = Modifier.align(Alignment.Center))
                }
            }
        }
    }
}

Upvotes: 0

Related Questions