Making grid items’ size identical using SubcomposeLayout in Jetpack Compose
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.
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.
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.
- Here we declare the
SubcomposeLayout
passing ourmodifier
. The other params areSubcomposeLayoutState
andSubcomposeMeasureScope.(Constraints) -> MeasureResult
. We will not talk aboutSubcomposeLayoutState
as this topic might get more complex. The latter parameter is a function param that provides aConstraints
object.Constraints
is a class that contains properties (min/max height/width) that limit child composables’ size. This function param should return aMeasureResult
that is done by thelayout()
function. - Here we create our
List
ofMeasurable
s using thesubcompose()
function. This function is the main reason why we would use aSubcomposeLayout
.subcompose()
performs the subcomposition of composables. The composables that are subject to subcomposition must be invoked within itscontent
function param. Thecontent
function param could emit multiple composables, in that case the returned list ofMeasurable
s will have multiple elements. For example,layoutContent
has 3Text
composables within it like this one. Then the returnedList<Measurables>
will contain 3Measurable
s, one for eachText
.subcompose()
can be invoked multiple times, in that case a uniqueslotId
(ofAny?
type) must be supplied to avoidIllegalArgumentException
. In our case above, I need to measure theExampleSubcomposeLayout
‘slayoutContent
so I invoked it within thesubcompose()
'scontent
lambda. - Here we convert each
Measureable
into aPlaceable
. This is where the actual measurement is happening. To measure aMeasurable
, we need to invokemeasure()
, passing in aConstraints
object. Themeasure()
function will then use the providedConstraints
object to limit the size of theMeasurable
being measured. If the measurement is a success, this function returns aPlaceable
that contains sizing data such asmeasuredHeight
andmeasuredWidth
. In the example above, I want to equally divide the width of mySubcomposeLayout
’s width (by using itsconstraint
) to each of myMeasurable
s. Each of myMeasurable
has now a fixed width since I set bothminWidth
andmaxWidth
of the providedConstraints
object. - In this last part, we finally layout and place our
Placeable
s. In this phase, the composables are already drawn usingsubcompose()
, they just need to be placed. We can do that using thelayout()
function. The parameters I passed determined the size of the layout. The first parameter is for width, so I passedconstraints.maxWidth
which should resemble theSubcomposeLayout
’s width. While the second is for height, so I passed in the tallest child’s height. Then the third parameter is aPlaceable.PlacementScope.() -> Unit
lambda, inside it is where we place ourPlaceable
s. There are many functions to choose from when placing, but I choseplaceRelative
since it reacts to RTL layout unlikeplace
. Then with the traditional XY coordinates system, I placed eachPlaceable
.
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.
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 Placeable
s. 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!