Making grid items’ size identical using SubcomposeLayout in Jetpack Compose

Kurt Lemond Capatan
11 min readMar 10, 2023

--

taken from https://www.pexels.com/photo/green-woven-pavement-570047/

While working on an android project, one of its features is a vertical grid that displays items with expiration dates. Each item’s width and height may vary depending on its content and allowed space. Take a look at the image below.

Grid Items in different sizes. Code can be found here.

From left to right:

  • Width and height are not restricted, texts inside take as much width and height as possible
  • The item’s size is 150x150 dp square. The expiration goes to a new row because it exceeds the available space.
  • The item’s width is 120dp. This time both ‘available’ and expiration texts goes to new lines due to a narrow width.

Each text row inside the item utilizes FlowRow, a special row from Accompanist that allows children to go to a new line if there is insufficient space.

Now let’s put them inside a lazy vertical grid. Given the number of columns and equal span sizes, this is the result.

The left grid has 350 dp width while the right grid has 300 dp width. Code can be found here.

As you can see, the right grid became an eyesore. Item sizes became unidentical due to FlowRow’s behavior and narrow grid width. Also, this cannot be fixed by setting Modifier.fillMaxSize (or .fillMaxHeight) to each item as it has no effect. The docs for fillMaxSize says:

If the incoming maximum width or height is Constraints.Infinity this modifier will have no effect in that dimension.

fillMaxSize having no effect makes sense since each grid item can take as much height as it needs because LazyVerticalGrid does not restrict its items to a specific height. LazyVerticalGrid will take care of its items’ width since I want a fixed number of columns so no problems with that. However, if we want our items to have identical heights, these are the possible solutions ( comment if you have more ideas :D ).

  • Define the item’s height using Modifier.height
  • Define the item’s width and height ratio by using Modifier.aspectRatio
  • Create a custom grid combining columns and rows, then define the height of rows using Modifier.height. Finally, use .fillMaxHeight to items.

I did not opt-in to any solutions above since I want to ensure that the items’ contents are fully visible while such items have identical sizes. I also want my grid to be flexible on various width sizes as much as possible. In the worst-case scenario, the screen width is as narrow as a TV remote (since my grid will always fill the screen’s width), in that case, item contents might become unreadable, but that’s a problem for later :P

If I want all my items to have identical heights, then I need to obtain the height of the tallest item, given that this item is placed on the screen with its contents. Then, apply the obtained height to all other items. Is there a way to measure each grid item and then apply the measurements upon composition? YES!!

Behold, the SubcomposeLayout

SubcomposeLayout allows us to “subcompose” the actual contents during the measuring stage so that we can use the calculated measurements as params for its children’s composition. As of this moment, I couldn't find the definition of the term “subcompose” in the docs so please do not hesitate to put it in the comments if you do find it :D . I assume “subcomposition” differs from “composition” in that the former composes composables onto a layout plane but not for displaying purposes but for measurement purposes. I may be wrong but that’s how I view it, correct me in the comments if I’m wrong. That being said, “subcomposition” still involves drawing within a constraint, like screen size, the parent’s size, or a specific size.

According to the docs, SubcomposeLayout has 3 possible use cases, one of them is this one:

You want to use the size of one child during the composition of the second child.

This fits our use case since we want to apply the height of the tallest grid item to all other items so that they’ll have identical heights. To measure such height, we have to take a look at how SubcomposeLayout works.

A basic SubcomposeLayout structure

@Composable
fun ExampleSubcomposeLayout(
modifier: Modifier = Modifier,
layoutContent: @Composable () -> Unit,
) {
// 1
SubcomposeLayout(modifier) { constraints ->
// 2
val measurables = subcompose("some_id") { layoutContent() }
// 3
val placeables = measurables.map {
it.measure(
Constraints(
maxWidth = constraints.maxWidth / measurables.size,
minWidth = constraints.maxWidth / measurables.size,
)
)
}
val tallestHeight = placeables.maxOf { it.height }
// 4
layout(constraints.maxWidth, tallestHeight) {
var xOffset = 0
placeables.forEach { it
it.placeRelative(xOffset, 0)
xOffset += it.width
}
}
}
}

ExampleSubcomposeLayout method above utilizes SubcomposeLayout to display the layoutContent composable param. It accepts the usual Modifier for SubcomposeLayout use. Here’s how this function work.

  1. Here we declare the SubcomposeLayout passing our modifier. The other params are SubcomposeLayoutState and SubcomposeMeasureScope.(Constraints) -> MeasureResult . We will not talk about SubcomposeLayoutState as this topic might get more complex. The latter parameter is a function param that provides aConstraintsobject. Constraints is a class that contains properties (min/max height/width) that limit child composables’ size. This function param should return a MeasureResult that is done by the layout() function.
  2. Here we create our List of Measurables using the subcompose() function. This function is the main reason why we would use a SubcomposeLayout. subcompose() performs the subcomposition of composables. The composables that are subject to subcomposition must be invoked within its content function param. The content function param could emit multiple composables, in that case the returned list of Measurables will have multiple elements. For example, layoutContent has 3 Text composables within it like this one. Then the returned List<Measurables> will contain 3 Measurables, one for each Text. subcompose() can be invoked multiple times, in that case a uniqueslotId (of Any? type) must be supplied to avoid IllegalArgumentException. In our case above, I need to measure the ExampleSubcomposeLayout‘s layoutContent so I invoked it within the subcompose()'scontent lambda.
  3. Here we convert each Measureable into a Placeable. This is where the actual measurement is happening. To measure a Measurable, we need to invokemeasure(), passing in a Constraintsobject. The measure() function will then use the provided Constraints object to limit the size of the Measurable being measured. If the measurement is a success, this function returns a Placeable that contains sizing data such as measuredHeight and measuredWidth. In the example above, I want to equally divide the width of my SubcomposeLayout’s width (by using its constraint) to each of my Measurables. Each of my Measurable has now a fixed width since I set both minWidth and maxWidth of the provided Constraints object.
  4. In this last part, we finally layout and place our Placeables. In this phase, the composables are already drawn using subcompose(), they just need to be placed. We can do that using the layout() function. The parameters I passed determined the size of the layout. The first parameter is for width, so I passed constraints.maxWidth which should resemble the SubcomposeLayout’s width. While the second is for height, so I passed in the tallest child’s height. Then the third parameter is a Placeable.PlacementScope.() -> Unit lambda, inside it is where we place ourPlaceables. There are many functions to choose from when placing, but I chose placeRelative since it reacts to RTL layout unlike place. Then with the traditional XY coordinates system, I placed each Placeable.

SubcomposeLayout in action

Take a good look at the code above and analyze how ExampleSubcomposeLayout would look if we pass this as our layoutContent. If you assume that it will arrange the texts in a horizontal manner while having equal widths, then you are correct. This is the same as using a Row while giving each text a weight of 1f. Here’s what it looks like on Preview.

ExampleSubcomposeLayout Preview, code here

Let’s update our ExampleSubcomposeLayout so that it displays the texts in several ways and satisfies the following use cases.

  • Display the texts horizontally, imitating a Row. Each text consumes only its required width. Layout size should wrap the contents.
SubcomposeLayout(modifier) { constraints ->
val measurables = subcompose("some_id") { layoutContent() }
val placeables = measurables.map {
// maxWidth and maxHeight is Constraints.Infinity, it can use as much width/height as it wants
it.measure(Constraints())
}
// total width of texts, for layout's width
val totalWidth = placeables.sumOf { it.width }
val tallestHeight = placeables.maxOf { it.height }
layout(totalWidth, tallestHeight) {
var xOffset = 0
placeables.forEach { it
it.placeRelative(xOffset, 0)
xOffset += it.width
}
}
}
  • Display the texts vertically, imitating a Column . Layout size should wrap the contents.
SubcomposeLayout(modifier) { constraints ->
val measurables = subcompose("some_id") { layoutContent() }
val placeables = measurables.map {
it.measure(constraints) // provide the parent's constraints
}
val longestWidth = placeables.maxOf { it.width } // find longest child
val totalHeight = placeables.sumOf { it.height } // sum of children's heights
layout(longestWidth, totalHeight) {
var yOffset = 0
placeables.forEach {
it.placeRelative(0, yOffset)
yOffset += it.height // use the height of each child to adjust the next child's placement
}
}
}
  • Reduce each text’s width to half of the longest text’s width. Imitate a Column, layout size should wrap the contents.
SubcomposeLayout(modifier) { constraints ->
// measure each child composable
val placeables = subcompose("id") {
layoutContent()
}.map { it.measure(Constraints()) } // Infinity constraint
val longestWidth = placeables.maxOf { it.width } // get longest text
// pass another id to avoid IllegalArgumentException
val resizedPlaceables = subcompose("resized") {
layoutContent()
}.map {
// convert to measurable, passing half the longest text's width
it.measure(
Constraints(
minWidth = longestWidth / 2,
maxWidth = longestWidth / 2
)
)
}
// get longest width of resized Placeable
val longestResizedWidth = resizedPlaceables.maxOf { it.width }
val totalHeight = resizedPlaceables.sumOf { it.height }
layout(longestResizedWidth, totalHeight) {
var yOffset = 0
resizedPlaceables.forEach {
it.placeRelative(0, yOffset)
yOffset += it.height
}
}
}

Solving the Problem

Now that we are now familiar with how does aSubcomposeLayout work, let’s now solve our problem!

I need to obtain the height of the tallest item, given that this item is placed on the screen with its contents. Then, apply the obtained height to all other items.

The following topics are the things I considered while building this custom vertical grid. Each topic has some code and explanation accompanying it to make it understandable. Let’s name our new composable CustomVerticalGrid.

Content padding

CustomVerticalGrid most likely will have some padding for its item content. This can be solved by passing PaddingValues as a parameter. Then inside the SubcomposeLayout‘s block, calculate the side paddings.

@Composable
fun CustomVerticalGrid(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(16.dp),
...
) {
val layoutDirection = LocalLayoutDirection.current
SubcomposeLayout(modifier) { constraints ->
val bottomPadding = contentPadding.calculateBottomPadding().roundToPx()
val leftPadding = contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
val rightPadding = contentPadding.calculateRightPadding(layoutDirection).roundToPx()
val topPadding = contentPadding.calculateTopPadding().roundToPx()
....
}
}

Spacing between Rows and Columns

CustomVerticalGrid‘s items most likely will have some spacing between them. Let’s add them as Dp params and convert them to pixels.

@Composable
fun CustomVerticalGrid(
...
horizontalSpacing: Dp = 12.dp,
verticalSpacing: Dp = 12.dp,
...
) {
val layoutDirection = LocalLayoutDirection.current
SubcomposeLayout(modifier) { constraints ->
...
val horizontalSpace = horizontalSpacing.roundToPx()
val verticalSpace = verticalSpacing.roundToPx()
...
}
}

Item Width

Item widths should be equal. To get the exact width needed, we have to find the sum of all spaces (in-between and horizontal padding), then deduct that sum from the layout’s width. Finally, the difference is divided by the number of columns. The resulting width can finally be used to subcompose each element so that we can find the tallest’s item height.

@Composable
fun <E> CustomVerticalGrid(
modifier: Modifier = Modifier,
numberOfColumns: Int = 2,
contentPadding: PaddingValues = PaddingValues(16.dp),
horizontalSpacing: Dp = 12.dp,
elements: List<E>,
gridItem: @Composable (E) -> Unit,
) {
val layoutDirection = LocalLayoutDirection.current
SubcomposeLayout(modifier) { constraints ->
...
val leftPadding = contentPadding.calculateLeftPadding(layoutDirection).roundToPx()
val rightPadding = contentPadding.calculateRightPadding(layoutDirection).roundToPx()
val horizontalSpace = horizontalSpacing.roundToPx()
...
val totalSpacesWidth = leftPadding + rightPadding + (horizontalSpace * (numberOfColumns - 1))
// calculate the maximum width a grid item can take
val itemWidth = (constraints.maxWidth - totalSpacesWidth) / numberOfColumns
val placeables = elements.map { e ->
subcompose(e) {
// content param could emit multiple layouts, we use Box to ensure only one layout is emitted
Box(Modifier.width(itemWidth.toDp())) { gridItem(e) }
}
.first() // get the first and only Measurable from each item
.measure(Constraints()) // measure the Measurable to obtain a Placeable from each item
}
...
}
}

Item Height

As you might notice from the previous topic above, measuring the subcomposed items converts them into Placeables. To find the tallest height, we can use Placeable’s height property. We will use this tallest height to lay out our items later

...
val itemHeight = placeables.maxOf { it.height }
...

Laying out the items

I want my items to adapt to their parent’s height and avoid unexpected scrolling behavior. To do this, we first have to check if our SubcomposeLayout has a fixed height or not. It has a fixed height if fillMaxSize, fillMaxHeight, or any function that sets its height is used on its Modifier. Otherwise, I want SubcomposeLayout to wrap my items. To verify if SubcomposeLayout has a fixed height, we can use its constraints’ hasBoundedHeight property. Then use the necessary constraints to lay out our items.

@Composable
fun <E> CustomVerticalGrid(...) {
...
SubcomposeLayout(modifier) { constraints ->
...
val itemsTotalHeight = (placeables.size / numberOfColumns).run {
val numberOfRows = if (placeables.size % numberOfColumns == 0) this else this + 1
itemHeight * numberOfRows
}
val spacesTotalHeight = (placeables.size / numberOfColumns).run {
val numberOfSpaces = if (placeables.size % numberOfColumns == 0) this - 1 else this
(numberOfSpaces * verticalSpace) + topPadding + bottomPadding
}
val totalGridHeight = itemsTotalHeight + spacesTotalHeight
val gridConstraints = getGridConstraints(constraints = constraints, totalCalculatedHeight = totalGridHeight)
// place the Grid itself
if (!constraints.hasBoundedHeight) {
// if this composable's maxHeight is not defined (or infinity) then place the items in a grid manner
layoutCustomVerticalGrid(
gridConstraints = gridConstraints,
numberOfColumns = numberOfColumns,
horizontalSpace = horizontalSpace,
leftPadding = leftPadding,
topPadding = topPadding,
verticalSpace = verticalSpace,
elements = elements,
gridItem = gridItem,
itemHeight = itemHeight,
itemWidth = itemWidth,
)
} else {
// if this composable's maxHeight is defined then utilize LazyVerticalGrid
layoutLazyVerticalGrid(
gridConstraints = gridConstraints,
numberOfColumns = numberOfColumns,
contentPadding = contentPadding,
horizontalSpacing = horizontalSpacing,
verticalSpacing = verticalSpacing,
elements = elements,
gridItem = gridItem,
itemHeight = itemHeight,
)
}
}
}
/**
* Returns the [Constraints] to be used on the grid. The returned [Constraints] may depend on the
* parent [constraints] and the total calculated height needed for resulting grid. The resulting
* constraints will be used on layout phase.
*/
private fun getGridConstraints(
constraints: Constraints,
totalCalculatedHeight: Int,
): Constraints {
return if (!constraints.hasBoundedHeight) {
// if this parent composable's maxHeight is not defined (Infinity) then
// use the calculated totalHeight as the grid height.
Constraints(
minHeight = totalCalculatedHeight,
maxHeight = totalCalculatedHeight,
minWidth = constraints.minWidth,
maxWidth = constraints.maxWidth,
)
} else {
// use the parent's constraint as its height is defined (or fills its parent)
constraints
}
}

This is the result! Our CustomVerticalGrid on the left fills its parent size while the one on the right is inside a column along with other contents. Items sizes are identical too!

That’s it! Thank you for taking the time to read this long post. If you want to view the full working code, you can view it here.

Post your question or comment if you have one. Until next time!

--

--