Android Jetpack Compose ConstraintLayout Grid Helper

Shane Wong
15 min readMar 8, 2023

--

Thanks to John Hoford for help review the article

This article will cover the basics of the ConstraintLayout Grid helper in Compose.

Table of Contents

Introduction

Although ConstraintLayout is useful for building a complex layout, building a Grid representation using ConstraintLayout requires adding multiple constraints on widgets. Take the layout below as an example. To align button 1, button 2, and button 3 properly, we need to add horizontal constraints between each button, a vertical constraint to the widget above (a Box), and buttons below them. See the code below for details.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

@Composable
public fun DslKeypad() {
ConstraintLayout(
ConstraintSet {
val btn1 = createRefFor("btn1")
val btn2 = createRefFor("btn2")
val btn3 = createRefFor("btn2")
...

// add constraint to btn1
constrain(btn1) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
// constrain start to parent start with 5 dp margin
start.linkTo(parent.start, 5.dp)
top.linkTo(box.bottom, 5.dp)
end.linkTo(btn2.start, 5.dp)
bottom.linkTo(btn4.top, 5.dp)
}
// add constraint to btn2
constrain(btn2) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
start.linkTo(btn1.end, 5.dp)
top.linkTo(box.bottom, 5.dp)
end.linkTo(btn3.start, 5.dp)
bottom.linkTo(btn5.top, 5.dp)
}
// add constraint to btn3
constrain(btn3) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
start.linkTo(btn2.end, 5.dp)
top.linkTo(box.bottom, 5.dp)
end.linkTo(parent.end, 5.dp)
bottom.linkTo(btn6.top, 5.dp)
}
...
},

modifier = Modifier.fillMaxSize()
) {
val numArray = arrayOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
for (num in numArray) {
Button(
modifier = Modifier.layoutId(String.format("btn%s", num)),
onClick = {},
) {
Text(text = num, fontSize = 45.sp)
}
}
Box(
modifier = Modifier.background(Color.Gray).layoutId("box"),
Alignment.BottomEnd
) {
Text("100", fontSize = 80.sp)
}
}
}

In this article, we discuss a new ConstraintLayout helper called the Grid helper. It offers an alternative way to build a Grid, Row, and Column using ConstraintLayout. It is currently supported in both the View system and Compose. With the Grid helper, you can start building a Grid representation by defining the number of rows and number of columns. You can use various attributes to customize the spacing, the skipped position(s), the spanned position(s), etc.

The Jetpack Compose library supports not only the ConstraintLayout Grid Helper but also LazyVerticalGrid and LazyHorizontalGrid for displaying a large number of items in a “lazy” manner that users can scroll through. In contrast, the Grid helper is suitable for designs that have a fixed number of widgets to be displayed in a Grid without scrolling.

To start using ConstraintLayout in Compose, add constraintlayout-compose to your Gradle file:

dependencies { 
implementation 'androidx.constraintlayout:constraintlayout-compose:1.1.0-alpha08'
}

If you’re new to ConstraintLayout in Compose, a quick guide on the topic can provide you with a high-level understanding to help you get started.

Let’s introduce the basic concept of the Grid helper starting with the same keypad example — a 4x3 grid representation (see the code below for details). By specifying rows = "4" and columns = "3", the Grid helper creates a grid space with 4 rows and 3 columns. Later, we add button references to be arranged in the grid-based space. The widgets will be arranged based on the order they are passed to the Grid helper. A keypad-like layout can then be generated.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

@Composable
public fun GridDslKeypad() {
// Currently, we still have problem with positioning the Flow Helper
// and/or setting the width/height properly.
ConstraintLayout(
ConstraintSet {
// create a reference for a Button with the id - btn1
val btn1 = createRefFor("btn1")
val btn2 = createRefFor("btn2")
val btn3 = createRefFor("btn3")
... // same for btn4 - btn9
// create the grid helper
val grid = createGrid(
// pass the references of widgets to be arranged in the Grid
btn1, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9, btn0,
rows = 4,
columns = 3,
)

// add constraint to grid
constrain(grid) {
width = Dimension.matchParent
height = Dimension.matchParent
// constraint to parent top with 20 dp margin
top.linkTo(parent.top, 20.dp)
bottom.linkTo(parent.bottom, 20.dp)
start.linkTo(parent.start, 20.dp)
end.linkTo(parent.end, 20.dp)
}
constrain(btn1) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
... // same for btn1 - btn9
},
modifier = Modifier.fillMaxSize()
) {
val numArray = arrayOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
for (num in numArray) {
Button(
modifier = Modifier.layoutId(String.format("btn%s", num)),
onClick = {},
) {
Text(text = num, fontSize = 45.sp)
}
}
}
}

There are two possible ways to improve the layout a bit:

1. Let btn0 take the entire row, or

2. move the btn0 to the center of the row.

To make btn0 take the entire row, we can use the spans feature of the Grid helper by adding spans = "9:1x3". This newly added attribute tells the Grid helper to create a spanned area (1 row x 3 columns) starting at position 9 (zero-based numbering) for the first widget reference.

How widgets are assigned to the spanned area is determined by the order among the widget references — the first widget would be assigned to the first spanned area, the second widget is assigned to the second spanned area, and so on. To make btn0 take the spanned area, we also need to move the id of btn0 to the beginning.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
btn0, btn1, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9,
rows = 4,
columns = 3,
spans = "9:1x3"
)

On the other hand, if we want to place btn0 in the bottom to the center of the row, we can use the skips feature to skip certain positions in a Grid. In this case, we can add skips = "9:1x1" so that no widgets can be placed at the skipped positions (skip 1 row x 1 column at position 9). Here, skips = "9:1x1" means that we want to skip a 1 by 1 space starting at position 9.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
btn1, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9, btn0,
rows = 4,
columns = 3,
skips = "9:1x1"
)

We might want to add a new row to place a text area to display the clicked numbers. We can increase the row number from 4 to 5 and use the spans feature (spans="0:1x3") to indicate that the first widget will take the 1 by 3 (1 row and 3 columns) space. Then, we place the new text area (a Box) at the beginning of the widget references.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
box, btn1, btn2, btn3, ...
rows = 5,
columns = 3,
spans = "0:1x3",
skips = "9:1x1",
)

The layout is off because we skipped the 1 by 1 space at position 9 earlier. Since we added a row, the position we want to skip is updated from position 9 to position 12.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
...
skips = "12:1x1",
)

It now looks like what we would expect, but the buttons seem too close to each other. We can add horizontal spacing between each button by adding a new attribute, horizontalGap = "5dp". By adding this attribute, we tell the Grid helper to add 5dp spacing horizontally between each position in the Grid.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
...
horizontalGap = 5.dp,
)

The layout looks better now. As a final step, we may want to double the height of the text area. There are two ways to do that:

  1. Increase the number of rows from 5 to 6; then, increase the height of the spanned area with spans = "0:2x3".
  2. Use the rowWeight feature to specify the weight (height) of each row in a Grid. By adding the attribute rowWeights = "2,1,1,1,1", we tell the Grid helper that we want the first row to have twice the weight (height) as the others.

The final code can be seen below. For the full code, see the sample on GitHub.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val weights = intArrayOf(2, 1, 1, 1, 1)
val grid = createGrid(
box, btn1, btn2, btn3, btn4, btn5, btn6, btn7, btn8, btn9, btn0,
rows = 5,
columns = 3,
horizontalGap = 5.dp,
spans = "0:1x3",
skips = "12:1x1",
rowWeights = weights,
)

More examples

Earlier, we gave an example to demonstrate how the Grid helper can be used to create a Grid representation. In this section, different Grid representations using the Grid helper are presented. You can find all the examples on our GitHub.

Calculator

Let’s look at a more complete Calculator UI example that shows how to use the spans attribute to arrange widgets (Textarea and Button 0) in spanned areas. Note that the widget references are passed as an Array instead of individual references in this example.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val grid = createGrid(
elements = *elem,
rows = 7,
columns = 4,
verticalGap = 10.dp,
horizontalGap = 10.dp,
spans = "0:2x4,24:1x2",
)

Special Row mode

You can use the Grid helper to create a Row representation by setting columns = "0" and rows = "1". When you set the value of columns as 0, the value of columns is determined by the number of widgets assigned to Grid. Since there are five ids (btn0 to btn4), a 1X5 Grid is created.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val row = createGrid(
btn0, btn1, btn2, btn3, btn4
rows = 1,
columns = 0,
)

As an alternative, you may also use createRow to generate a Row representation.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val row = createRow(
btn0, btn1, btn2, btn3, btn4
)

Special Column mode

The following Column example shows how you can use the Grid Helper to create a Column representation by setting columns = "1" and rows = "0". Similar to the Row example, a 5X1 Grid is created.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val column = createGrid(
btn0, btn1, btn2, btn3, btn4
rows = 0,
columns = 1,
)

As an alternative, you may also use createColumn to generate a Row representation.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val column = createColumn(
btn0, btn1, btn2, btn3, btn4
)

Nesting Grid

With the Grid helper, you can also create a nested Grid representation. How it works is straightforward. To create a nested representation, you only need to add the id of an inner Grid to the outer Grid.

/* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val g1 = createGrid(
btn5, btn6, btn7, btn8,
rows = 3,
columns = 3,
skips= "0:1x2,4:1x1,6:1x1",
)
val g2 = createGrid(
g1, btn1, btn2, btn3, btn4,
rows = 3,
columns = 3,
skips = "1:1x1,4:1x1,6:1x1",
)

Column in Row

The nested Grid example shows how we can create a Grid in another. Since we can also use the Grid helper to create a Row or a Column representation, we are able to create a row within a column and vice versa. The layout below is an example of Column in Row.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val column = createColumn(
btn4, btn5, btn6,
verticalGap = 10.dp
)
val row = createRow(
btn0, column, btn1, btn2, btn3,
horizontalGap = 10.dp,
)

In addition to the demos shown above, you can also use the Grid helper to build more concrete layouts. The full code of the above examples can be found on GitHub. Examples for the Grid helper in View Systems are also on our GitHub.

Grid’s Attributes

From the demos above, we know that the Grid helper has different custom attributes, including:

  • rows and columns: Specifies the number of rows and columns to be created for the grid form. If only one of the values is set (e.g., rows), the other value (columns) would be calculated based on the number of widgets in a Grid.
  • spans: Offers the capability to span a widget across multiple rows and columns. The format of a span is Position:RowxColumn. Position means the top and left most position of the spanned area. Row and Column indicate the height (number of Rows) and the width (number of Columns) of the spanned area.
  • skips: Enables you to skip certain positions in the grid and leave them blank. The format of a skip is the same as the grid_spans.
  • orientation: Defines how the associated widgets will be arranged — horizontally (0) or vertically (1).
  • horizontalGaps and verticalGaps: Add margin horizontally and vertically between widgets.
  • rowWeights and columnWeights: Specify the weight of each row/column . The default weight is 1.

Grid with MotionLayout

Grid helper in Compose also works with MotionLayout, which means that you can create an animation or a transition using MotionLayout. The two examples in this section show how Grid helper works with MotionLayout.

The example below demonstrates the transition when the number of rows and columns is updated. In the beginning of the transition, we have 6 buttons positioned in a 2 (rows) X 3 (columns) Grid. At the end of the transition, we have a 3 (rows) X 2 (columns) Grid instead. To create a transition with MotionLayout in Compose, provide a MotionScene that contains a Transition (defaultTransition) that specifies the starting layout (the ConstraintSet assigned to from) and the ending layout (the ConstraintSet assigned to to).

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

@Composable
public fun MotionGridDslDemo() {
val numArray = arrayOf("1", "2", "3", "4", "5", "6")
var animateToEnd by remember { mutableStateOf(false) }
val progress = remember { Animatable(0f) }

LaunchedEffect(animateToEnd) {
progress.animateTo(if (animateToEnd) 1f else 0f,
animationSpec = tween(3000))
}

Column(modifier = Modifier.background(Color.White)) {
val scene1 = MotionScene {
val elem = Array(numArray.size) { i -> createRefFor(i) }
for (i in numArray.indices) {
elem[i] = createRefFor(String.format("btn%s", numArray[i]))
}
// basic "default" transition
defaultTransition(
// specify the starting layout
from = constraintSet { // this: ConstraintSetScope
val grid = createGrid(
elements = *elem,
rows = 2,
columns = 3,
)
constrain(grid) {
width = Dimension.matchParent
height = Dimension.matchParent
}
},
// specify the ending layout
to = constraintSet { // this: ConstraintSetScope
val grid = createGrid(
elements = *elem,
rows = 3,
columns = 2,
)
constrain(grid) {
width = Dimension.matchParent
height = Dimension.matchParent
}
}
)
}

MotionLayout(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
motionScene = scene1,
progress = progress.value) {
for (num in numArray) {
Button(
modifier = Modifier.layoutId(String.format("btn%s", num)),
onClick = {},
) {
Text(text = num, fontSize = 35.sp)
}
}
}
Button(onClick = { animateToEnd = !animateToEnd },
modifier = Modifier
.fillMaxWidth()
.padding(3.dp)) {
Text(text = "Run")
}
}
}

In the next example, we can see a row of buttons positioned within a column of buttons in the beginning. In the end, the layout shows a column of buttons positioned within a row of buttons.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

@Composable
public fun MotionDslDemo2() {
val numArray = arrayOf("1", "2", "3", "4", "5", "6", "7")
var animateToEnd by remember { mutableStateOf(false) }
val progress = remember { Animatable(0f) }

LaunchedEffect(animateToEnd) {
progress.animateTo(if (animateToEnd) 1f else 0f,
animationSpec = tween(3000))
}

Column(modifier = Modifier.background(Color.White)) {
val scene1 = MotionScene {
val btn1 = createRefFor("btn1")
val btn2 = createRefFor("btn2")
val btn3 = createRefFor("btn3")
val btn4 = createRefFor("btn4")
val btn5 = createRefFor("btn5")
val btn6 = createRefFor("btn6")
val btn7 = createRefFor("btn7")

// basic "default" transition
defaultTransition(
// specify the starting layout
from = constraintSet { // this: ConstraintSetScope
val row = createRow(
btn5, btn6, btn7,
horizontalGap = 10.dp
)
val column = createColumn(
btn1, btn2, row, btn3, btn4,
verticalGap = 10.dp
)
constrain(row) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
constrain(column) {
width = Dimension.matchParent
height = Dimension.matchParent
}
constrain(btn1) {
width = Dimension.fillToConstraints
}
... // same for btn2 - btn4
constrain(btn5) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
... // same for btn6 - btn7
},
// specify the ending layout
to = constraintSet { // this: ConstraintSetScope
val column = createColumn(
btn5, btn6, btn7,
verticalGap = 10.dp
)
val row = createRow(
btn1, btn2, column, btn3, btn4,
horizontalGap = 10.dp
)
constrain(row) {
width = Dimension.matchParent
height = Dimension.matchParent
}
constrain(column) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
constrain(btn1) {
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
... // same for btn2 - btn4
constrain(btn5) {
height = Dimension.fillToConstraints
}
... // same for btn6 - btn7
}
)
}

MotionLayout(
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
motionScene = scene1,
progress = progress.value) {
for (num in numArray) {
Button(
modifier = Modifier.layoutId(String.format("btn%s", num)),
colors = ButtonDefaults.buttonColors(
backgroundColor = Color.Gray.copy(
alpha = 0.1F,
),
),
onClick = {},
) {
Text(text = num, fontSize = 35.sp)
}
}
}

Button(onClick = { animateToEnd = !animateToEnd },
modifier = Modifier
.fillMaxWidth()
.padding(3.dp)) {
Text(text = "Run")
}
}
}

Idea behind the Grid Helper

How the positions are determined in a Grid depends on the orientation. Figure 1 shows the positions of a grid if the value of orientation is horizontal (left) or vertical (right). The widgets in the Grid are arranged based on the order of the horizontal or vertical positions.

Widgets are arranged depending on the orientation attribute — horizontal (left) and vertical (right).

The layout below shows how you can apply the Grid helper to create this Grid representation. As seen in the code below, the values of columns and rows are both set to 4, meaning this is a 4x4 Grid. Since the orientation has the value 0 — horizontal, the index of each position in the Grid is increased row by row.

spans = "12:1x3" indicates the first widget (btn4) has position 12 in the Grid as the starting position and spans across 1 row and 3 columns (positions 12, 13, and 14).

From skips = "0:2x2,6:1x1", we can see two values are assigned (separated by comma). The first value suggests skipping a 2x2 area starting at position 0 (position 0, 1, 4, 5), and the second value suggests skipping a 1x1 area starting at position 6.

/* 
* Copyright 2023 Google LLC.
* SPDX-License-Identifier: Apache-2.0
*/

val g1 = createGrid(
btn4, btn0, btn1, btn2, btn3
rows = 4,
columns = 4,
orientation = 0,
spans = "12:1x3",
skips = "0:2x2,6:1x1"
)

I’ll pause here to briefly explain the main logic behind the Grid helper.

  • A boolean matrix is created to track what positions in a Grid are unavailable or already occupied.
  • Handle the Skips by invalidating the positions in the boolean matrix (in other words, assign a false value to those positions).
  • Handle the Spans by assigning the corresponding widgets to the spanned areas and then invalidating those positions.
  • Arrange the unassigned widgets in a Grid either horizontally or vertically, depending on the orientation of a Grid.

To assign a widget to a position or a spanned area, constraints are added on the four directions of the widget (left, top, right, and bottom) so that it can be placed in the desired position/area.

Limitations

Although the Grid helper can be helpful for building different kinds of layouts, there are two known issues to be resolved:

  1. For the width and the height of a grid, wrap_content doesn’t work properly.
  2. Grid helper in the View system doesn’t work with MotionLayout.

Conclusion

In this post, we introduced a new ConstraintLayout helper, the Grid helper, that is supported in both the View system and Compose. It offers the capability to easily create a Grid representation. It can also be used to create a Row, a Column, and a nested representation.

In the beginning of the post, a sample Grid layout was built incrementally to show how you can use different attributes to create a desired Grid representation. Various examples were shared to help users understand how different representations can be created with the Grid helper, including Grid, Row, Column, and nested representations. Finally, we discussed Grid helper logic to help you better understand the idea behind this helper.

Remember to check our GitHub for any updates!

--

--