Fixed Grid inside LazyColumn in Jetpack Compose?
Asked Answered
K

7

18

Currently in Jetpack Compose, this code throws an IllegalStateException because you cannot nest two vertically scrolling Composables:

@ExperimentalFoundationApi
@Composable
fun MyLazyColumn() {
    LazyColumn {
        item {
            Text(text = "My LazyColumn Title")
        }
        item {
            LazyVerticalGrid(cells = GridCells.Fixed(4)) {
                items(10) {
                    Box(
                        modifier = Modifier
                            .size(50.dp)
                            .padding(5.dp)
                            .background(Color.Gray)
                    )
                }
            }
        }
    }
}

I do not want the grid itself to scroll, but simply display a fixed grid of the Composable that I pass into it. Is there any workaround to displaying a non-scrolling grid inside a LazyColumn?

Katzen answered 26/9, 2021 at 15:55 Comment(0)
L
17

If you don't mind using an unstable API, you can use LazyVerticalGrid and make item take the full width with the span parameter, as @Mustafa pointed out:

LazyVerticalGrid(
    cells = GridCells.Fixed(spanCount),
) {
    item(
        span = { GridItemSpan(spanCount) }
    ) {
        Text("Title")
    }
    items(10) {
        Text(it.toString())
    }
}

Until it's stable, it's recommended

using stable components like LazyColumn and Row to achieve the same result.

It can be done by implementing gridItems to be used with LazyColumn.

fun LazyListScope.gridItems(
    count: Int,
    nColumns: Int,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    itemContent: @Composable BoxScope.(Int) -> Unit,
) {
    gridItems(
        data = List(count) { it },
        nColumns = nColumns,
        horizontalArrangement = horizontalArrangement,
        itemContent = itemContent,
    )
}

fun <T> LazyListScope.gridItems(
    data: List<T>,
    nColumns: Int,
    horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
    key: ((item: T) -> Any)? = null,
    itemContent: @Composable BoxScope.(T) -> Unit,
) {
    val rows = if (data.isEmpty()) 0 else 1 + (data.count() - 1) / nColumns
    items(rows) { rowIndex ->
        Row(horizontalArrangement = horizontalArrangement) {
            for (columnIndex in 0 until nColumns) {
                val itemIndex = rowIndex * nColumns + columnIndex
                if (itemIndex < data.count()) {
                    val item = data[itemIndex]
                    androidx.compose.runtime.key(key?.invoke(item)) {
                        Box(
                            modifier = Modifier.weight(1f, fill = true),
                            propagateMinConstraints = true
                        ) {
                            itemContent.invoke(this, item)
                        }
                    }
                } else {
                    Spacer(Modifier.weight(1f, fill = true))
                }
            }
        }
    }
}

Usage:

LazyColumn {
    item {
        Text(text = "My LazyColumn Title")
    }
    // with count
    gridItems(10, nColumns = 4) { index -> 
        Box(
            modifier = Modifier
                .size(50.dp)
                .padding(5.dp)
                .background(Color.Gray)
        )
    }
    // or with list of items
    gridItems(listOf(1,2,3), nColumns = 4) { item ->
        Box(
            modifier = Modifier
                .size(50.dp)
                .padding(5.dp)
                .background(Color.Gray)
        )
    }
}
Lambeth answered 26/9, 2021 at 16:19 Comment(4)
This solution works fine in case you have a small list of data and simple UI for grid items, but when your data is large and grid's items have a complex UI and state, it will become a laggy and heavy.Refrangible
@MustafaIbrahim Have you tried running a Release version of the app? LazyColumn is much less performant in Debug. Also have you tried plain single cell LazyColumn with the same layout? I don't think my approach adds much overhead to plain LazyColumnLambeth
@MustafaIbrahim LazyVerticalGrid should perform better than my solution since 1.2.* because it was rewritten from using LazyColumn, but in 1.1.* I expect performance to be about the same.Lambeth
Sorry, but when you find yourself writing complex code like this, it's the wrong solution. Good effort though.Noontime
R
7

This accepted answer is great and works fine in case you have a small list of data and simple UI for grid items, but when your data is large and grid's items have a complex UI and state, it will become a laggy and heavy.

In my case , I manage to solve this issue by making the LazyVerticalGrid as the container for grid's items and also the other content , now it looks like this :

val spanCount = 2
LazyVerticalGrid(
    modifier = modifier
        .fillMaxWidth(),
    cells = GridCells.Fixed(spanCount),
    state = rememberLazyGridState(),
) {
    item(
        span = {
            /** Take a full row */
            GridItemSpan(currentLineSpan = spanCount)
        }
    ) {
        Column(
            modifier = Modifier.fillMaxWidth()
        ) {
            items.forEach {
                /** Iterate over your items here */
            }
        }
    }
    items(
        items = gridItems
    ){gridItem->
        /** Grid items go here */
    }
}
Refrangible answered 9/2, 2022 at 13:41 Comment(0)
B
2

For me I have added height explicity and it worked fine with me for LazyverticalGrid inside lazy colum

 LazyVerticalGrid(
        cells = GridCells.Fixed(3),
        contentPadding = PaddingValues(5.dp),
        modifier = Modifier
            .layoutId("lazy_list")
            .height(200.dp)
Bascom answered 12/5, 2022 at 10:29 Comment(0)
E
0

If your grid is small and not too complex (e.g. requires lazy loading) you can use a jetpack compose ConstraintLayout. I ran into this when having a scrollable parent which I could not modify.

It works pretty much the same as the view based ConstraintLayout though a little more limited.

E.g this would be a bullet point grid which keeps the text rows aligned to the longest bullet width:

val spaceAfterBullet = 8.dp
val spaceBetweenRows = 4.dp

ConstraintLayout(modifier = Modifier.fillMaxWidth()) {

    val (bullet1, bullet2, bullet3, text1, text2, text3) = createRefs()
    val bulletBarrier = createEndBarrier(bullet1, bullet2, bullet3)
    val row1Barrier = createBottomBarrier(bullet1, text1)
    val row2Barrier = createBottomBarrier(bullet2, text2)

    Text(text = "1.", textAlign = TextAlign.End, modifier = Modifier.constrainAs(bullet1) {
    })
    Text(text = "First line of text", modifier = Modifier
        .padding(start = spaceAfterBullet)
        .constrainAs(text1) {
            start.linkTo(bulletBarrier)
        })

    Text(text = "2.", textAlign = TextAlign.End, modifier = Modifier.constrainAs(bullet2) {
        top.linkTo(row1Barrier, margin = spaceBetweenRows)
    })
    Text(
        text = "Second line of text which will overflow into two lines of text :)",
        modifier = Modifier
            .padding(start = spaceAfterBullet)
            .constrainAs(text2) {
                start.linkTo(bulletBarrier)
                top.linkTo(row1Barrier, margin = spaceBetweenRows)
            })

    Text(text = "99.", textAlign = TextAlign.End, modifier = Modifier.constrainAs(bullet3) {
        top.linkTo(row2Barrier, margin = spaceBetweenRows)
    })
    Text(
        text = "\"Every man has two lives, and the second starts when he realizes he has just one\" — Confucius",
        modifier = Modifier
            .padding(start = spaceAfterBullet)
            .constrainAs(text3) {
                start.linkTo(bulletBarrier)
                top.linkTo(row2Barrier, margin = spaceBetweenRows)
            })

}

enter image description here

I also created a generic one in this gist here which allows for some reuse.

Erik answered 9/3, 2023 at 23:59 Comment(0)
M
0

Currently in Jetpack Compose, this code throws an IllegalStateException because you cannot nest two vertically scrolling Composables:

This is not entirely true, what it says you can't pass Constraints.Infinity to another scrollable Composable. Composables with scroll as LazyLists also rely on scroll modifier are not allowed to measure themselves with maxWidth/Height of infinity. If you change maxHeight to something finite you won't have the issue. Constraints mean a range to measure Composables before placing them. If you pass a 0-finite dimension it will be measured in dimension.

You don't have give fix height either if your content in vertical grid is smaller than parent height- sum of other content height.

Passing it with Modifier.heighIn(max) you constrain height measurement of grid to 0 and between parent.

@Preview
@Composable
private fun LazyTest() {
    BoxWithConstraints {
        val parentHeight = maxHeight
        LazyColumn {
            item {
                Text(text = "My LazyColumn Title")
            }
            item {
                LazyVerticalGrid(
                    modifier = Modifier.heightIn(max = parentHeight),
                    columns = GridCells.Fixed(4)
                ) {
                    items(10) {
                        Box(
                            modifier = Modifier
                                .size(50.dp)
                                .padding(5.dp)
                                .background(Color.Gray)
                        )
                    }
                }
            }
        }
    }
}

If in your case other if you wish to allow Grid to cover rest of the LazyColumn height you can measure height of other elements and use Modifier.heigh(parent height - sum of height of other content), this is actually pretty easy with a custom Layout.

And if you are curious why this exception occurs it's in scroll code because they do a check before setting composable with scroll modifier as.

private class ScrollingLayoutNode(
    var scrollerState: ScrollState,
    var isReversed: Boolean,
    var isVertical: Boolean
) : LayoutModifierNode, Modifier.Node() {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        // And here in LazyVerticalGrid there is a check if there is Constraints.Infinity
        checkScrollableContainerConstraints(
            constraints,
            if (isVertical) Orientation.Vertical else Orientation.Horizontal
        )

        // 🔥 this is how LazyColumn passes Constraints.Infinity content or child Composables
        val childConstraints = constraints.copy(
            maxHeight = if (isVertical) Constraints.Infinity else constraints.maxHeight,
            maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity
        )
        val placeable = measurable.measure(childConstraints)
        val width = placeable.width.coerceAtMost(constraints.maxWidth)
        val height = placeable.height.coerceAtMost(constraints.maxHeight)
        val scrollHeight = placeable.height - height
        val scrollWidth = placeable.width - width
        val side = if (isVertical) scrollHeight else scrollWidth
        // The max value must be updated before returning from the measure block so that any other
        // chained RemeasurementModifiers that try to perform scrolling based on the new
        // measurements inside onRemeasured are able to scroll to the new max based on the newly-
        // measured size.

}

And checkScrollableContainerConstraints function is

fun checkScrollableContainerConstraints(
    constraints: Constraints,
    orientation: Orientation
) {
    if (orientation == Orientation.Vertical) {
        check(constraints.maxHeight != Constraints.Infinity) {
            "Vertically scrollable component was measured with an infinity maximum height " +
                "constraints, which is disallowed. One of the common reasons is nesting layouts " +
                "like LazyColumn and Column(Modifier.verticalScroll()). If you want to add a " +
                "header before the list of items please add a header as a separate item() before " +
                "the main items() inside the LazyColumn scope. There are could be other reasons " +
                "for this to happen: your ComposeView was added into a LinearLayout with some " +
                "weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a " +
                "custom layout. Please try to remove the source of infinite constraints in the " +
                "hierarchy above the scrolling container."
        }
    } else {
        check(constraints.maxWidth != Constraints.Infinity) {
            "Horizontally scrollable component was measured with an infinity maximum width " +
                "constraints, which is disallowed. One of the common reasons is nesting layouts " +
                "like LazyRow and Row(Modifier.horizontalScroll()). If you want to add a " +
                "header before the list of items please add a header as a separate item() before " +
                "the main items() inside the LazyRow scope. There are could be other reasons " +
                "for this to happen: your ComposeView was added into a LinearLayout with some " +
                "weight, you applied Modifier.wrapContentSize(unbounded = true) or wrote a " +
                "custom layout. Please try to remove the source of infinite constraints in the " +
                "hierarchy above the scrolling container."
        }
    }
}
Macruran answered 24/8, 2023 at 6:12 Comment(0)
W
0
@Composable
fun <T> VerticalGrid(
modifier: Modifier = Modifier,
items: List<T>,
columns: Int,
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
    if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,//You can set Arrangement.spacedBy to set the spacing between columns
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,//You can set Arrangement.spacedBy to set the spacing between rows.
content: @Composable (item: T) -> Unit
) {

val rows = items.chunked(columns)
Column(modifier = modifier, verticalArrangement = verticalArrangement) {
    rows.forEachIndexed { rowindex, rowItems ->
        Row(Modifier.fillMaxWidth(), horizontalArrangement = horizontalArrangement) {
            rowItems.forEachIndexed { index, item ->
                Box(
                    modifier = Modifier
                        .weight(1f)
                ) {
                    content(item)
                }
                if (index == rowItems.lastIndex && rowindex == rows.lastIndex) {
                    // Add a placeholder empty view
                    for (i in 0 until (columns - rowItems.size)) {
                        Spacer(
                            modifier = Modifier
                                .weight(1f)
                        )
                    }
                }

            }

        }

    }
}

}

Wilmot answered 19/9, 2023 at 3:15 Comment(1)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Vicious
C
0

If you are using fixed column size, the solution can be quite simple using the chunked list extension, which splits the original list into a list of lists, each not exceeding the given size. So by using the column count, each inner list will then be a row.

@Composable
private fun MyLazyColumn() {
    LazyColumn {
        item {
            Text(text = "My LazyColumn Title")
        }

        item {
            val gridItems = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
            val gridRows = gridItems.chunked(4) // makes this list: [[1,2,3,4],[5,6,7,8],[9,10]]

            gridRows.forEach { row ->
                // divide by the number of columns to make sure the last row takes up the correct amount of space
                val maxWidthFraction = row.size / 4f 
                
                Row(
                    modifier = Modifier.fillMaxWidth(maxWidthFraction)
                ) {
                    row.forEach { item ->
                        Box(
                            modifier = Modifier
                                .size(50.dp)
                                .padding(5.dp)
                                .weight(1f) // 1f weight fills the space evenly
                                .background(Color.Gray)
                        ) {
                            Text(text = item.toString())
                        }
                    }
                }
            }
        }
    }
}

This creates: grid of 10 items

Codeclination answered 11/3 at 17:44 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.