Creating a dynamic grid in Jetpack Compose
Introduction
I’ve come across another interesting question in StackOverflow, the question asks how could we create a Grid composable that lets its children set individual column and row spans so that the composable fills the whole space allocated to it, and places its children given their column/row spans. An image of what we want to achieve may better explain what we are after:
So given a certain area, we place the children given their individual spans as best we can, leaving a gap where necessary (the black sections you see above), while using all the available space from the parent. Let’s see how we can accomplish this.
The Modifier
The first thing we need to do is determine how we are going to provide the column and row span to the main composable. To do this we can replicate what most composables from the Jetpack Compose libraries do, they offer an extension on Modifier
and a custom scope within the composable to use this extension. I have described this approach here so I won’t go over the specifics in this post, I’ll just show the code and briefly go over it:
We create a GridScope
interface that defines an extension on Modifier
. This extension allows us to specify the number of columns and rows that a child wants to occupy.
We then create a GridData
object that extends ParentDataModifier
— this is what we need in order to read the custom attributes (column and row spans) in our Layout.
The Layout
Layout signature
To draw the children we need the equivalent of a ViewGroup
in the view system, which in Jetpack Compose is a Layout
. This is a composable that accepts a content lambda with the children to lay out, and a MeasurePolicy
object to determine how to measure and lay the children out. As is customary, we will also accept a Modifier
that we will pass to our root composable to make the Grid composable more customizable. For our Grid, we also need to know how many columns we need to render, so we will take that as an argument as well.
With that said, let’s see how we define our Grid composable:
The key thing to notice here is that our content is scoped to the GridScope
we defined above, so when adding the children we will have access to the Modifier
extension we defined earlier. We then call Layout
, and pass as content the content
lambda we receive, scoped to the GridScope
, and the input modifier.
Computing the number of rows
Next we need to figure out how many rows we will need to render the children. The way we will do that is by walking the children and attempting to place them in a row, based on their column span. Once they no longer fit in the current row, we will move them to the next one.
Once we have filled a row we need to determine what the column span for this row is, which it is simply the maximum of all the column spans of the children in this row.
Finally, the number of rows will be the sum of all the column spans. Let’s see how we do this:
- First we define some helper functions to retrieve the
GridData
and thecolumnSpan
androwSpan
from our custom Modifier. - Next we define a standard Grid of 1x1 as a fallback in case a child didn’t provide one.
- We now walk our children and get their required
GridData
and keep this in a list we namespans
. - We then use a helper method to calculate how many rows we need, based on the
spans
and thecolumns
we are given as input. - In the helper method we walk the spans.
- For each span we get the
columnSpan
androwSpan
that the child wants to occupy. Note that we add a safety check to constraint the column span to at most the number of columns we want to render. Alternatively we could throw an error if the span count exceedscolumns
. - Now we try to fit the current child in the current row, by checking if the number of columns we have occupied in this row, plus the number of columns the child wants to occupy fit in the total number of columns we have.
- If the child fits, we increase our column span by the column span of this child and adjust our row span based on the row spans of all the children in this row.
- If the child does not fit, we then increase the number of rows by the previous row span, and reset our current column and row spans for a new row.
- Finally, we return the number of rows we need — note that the last row is not included in the previous calculation, so we add it there.
Building the Constraints
Now that we have calculated how many rows we need it’s time to measure the children. For this, we need to use specific Constraints
for each child, based on the column and row span for the child.
First we will create a basic Constraint
object that will represent a 1x1 cell, that’ll be our baseline that we will then use or each child. If a child wants to occupy 2 columns, then the Constraint
will be the base constraint, with twice the width.
The code to do so is below:
- We build our base 1x1
Constraints
by dividing the inputConstraints
by the number of columns and rows we need. - Next we walk all the children in order to measure them.
- For each child we get the column and row span to use.
- Next we build individual
Constraints
for each child, using thebaseConstraints
as a starting point and multiplying the width by thecolumnSpan
and the height by therowSpan
. - Once we have the
Constraints
for each child, we measure them all.
Placing the children
We’re almost there, we have calculated how may rows we need and have measured all the children. It is now time to place them in the layout. This will follow a similar pattern to the row calculation, we will attempt to place each child in the current column and, if it does not fit based on its column span, we will move it to the next one and increase the vertical offset accordingly.
Let’s see how we can achieve that:
- To place the children we use the
layout
method, passing in the incomingConstraints
. - We initialize some variables to keep track of the (x, y) coordinates where to place the current child, and the column and row spans.
- Next we walk our placeables.
- For each placeable we get the required column and row span.
- Here we check if the child fits in the current row, by checking if the total column span fits in the number of columns.
- If the child does not fit we reset the column span to this child’s column span, we reset the x offset to 0 and we increase the y offset by the accumulated row span, times the height of an individual cell.
- If the child fits, then we increase the current column span by this child’s column span and keep track of the number of rows we will use for this column.
- Next we place the child at the calculated (x, y) coordinates.
- And finally we increase our x offset based on how many columns the child takes times the width of a base cell.
Optimizing the calculation
The code is now complete and we have our Grid that places the children based on their respective column and row spans. However, as you may have noticed, there is some duplication in the calculations for the number of rows and the placement logic, so we are basically doing the same calculation twice. We can optimize this by keeping track of the number of children that fit in each row so that we don’t need to recalculate this in the placement phase.
To do so, when we walk the children to calculate the rows we will keep track of how many children fit in this row and the row spans, so that we can leverage that information later.
Let’s update our row calculation function:
We define a GridInfo
class to hold the information we need. Now, when we walk the list of children, we keep track of how many children fit in this row and its spans.
To leverage this data we need to update our placement block as shown below:
In this simplified block we walk the grid info list, retrieve the child based on how many children we have processed so far and how many are in this row, and update the (x, y) coordinates based on the information for this grid.
Final result
The final code with a preview is below. I hope you found this useful, and I’ll see you on the next one. Happy coding!