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:

  1. First we define some helper functions to retrieve the GridData and the columnSpan and rowSpan from our custom Modifier.
  2. Next we define a standard Grid of 1x1 as a fallback in case a child didn’t provide one.
  3. We now walk our children and get their required GridData and keep this in a list we name spans.
  4. We then use a helper method to calculate how many rows we need, based on the spans and the columns we are given as input.
  5. In the helper method we walk the spans.
  6. For each span we get the columnSpan and rowSpan 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 exceeds columns.
  7. 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.
  8. 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.
  9. 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.
  10. 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:

  1. We build our base 1x1 Constraints by dividing the input Constraints by the number of columns and rows we need.
  2. Next we walk all the children in order to measure them.
  3. For each child we get the column and row span to use.
  4. Next we build individual Constraints for each child, using the baseConstraints as a starting point and multiplying the width by the columnSpan and the height by the rowSpan.
  5. 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:

  1. To place the children we use the layout method, passing in the incoming Constraints.
  2. We initialize some variables to keep track of the (x, y) coordinates where to place the current child, and the column and row spans.
  3. Next we walk our placeables.
  4. For each placeable we get the required column and row span.
  5. Here we check if the child fits in the current row, by checking if the total column span fits in the number of columns.
  6. 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.
  7. 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.
  8. Next we place the child at the calculated (x, y) coordinates.
  9. 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!

Senior Android Developer