Blog Infos
Author
Published
Topics
,
Published

 

Although Jetpack Compose provides a wide range of components for creating a beautiful UI, one day you may find that your following layout needs to be implemented with a custom component. Jetpack Compose for example doesn’t have a ready-to-use convenient component to work with grid-based UI. What we understand by convenience:

  • flexible possibility to define row and column sizes
  • сontent placement in any specified location without the need to place neighbors

We would like to have a component that allows us to create UI like in the following image:

In Jetpack Compose we already have similar components: LazyVerticalGrid and LazyHorizontalGrid. Here is a common usage of LazyHorizontalGrid:

LazyHorizontalGrid(rows = GridCells.Fixed(3)) {
item {
// content
}
}
view raw LazyGrid.kt hosted with ❤ by GitHub

Look close but not the same as what we need. Lazy grids operate only with rows and columns count and place items sequentially. Whereas we would like to control not only counts but sizes. In addition, we need to have the possibility to place an item in any position.

API definition

Based on the lazy grids API, we’ll define our component API and call it GridPad:

GridPad(
// defining the grid API
cells = GridPadCells(
rowSizes = listOfRowSized,
columnSizes = listOfColumnSizes
)
) {
// placement the cell API
item(row = rowIndex, column = columnIndex, rowSpan = hSpan, columnSpan = wSpan) {
// content
}
}
view raw GridPad.kt hosted with ❤ by GitHub

Because we would like to control content placement our component will manage to add items with Kotlin DSL via item sections.

Grid definition

Let’s take a closer look at GridPadCells. This class contains information about row and column sizes.

public data class GridPadCells(
val rowSizes: ImmutableList<GridPadCellSize>,
val columnSizes: ImmutableList<GridPadCellSize>
) {
val rowCount: Int = rowSizes.size
val columnCount: Int = columnSizes.size
internal val rowsTotalSize: TotalSize = rowSizes.calculateTotalSize()
internal val columnsTotalSize: TotalSize = columnSizes.calculateTotalSize()
}
view raw GridPadCells.kt hosted with ❤ by GitHub

Let’s go a little back and remember what we need. We need to have the possibility to specify a size for each row and column in a grid.

 

 

Therefore, GridPadCells cover all our requirements. You might notice, for storing rowSizes and columnSizes using ImmutableList. Thanks to this implementation of a list, the compose compiler marks this class as stable and we don’t need to mark GridPadCells with @Immutable or @Stable annotation. Why it is important? Here’s what the official documentation says:

Not all classes need to be stable, but a class being stable unlocks a lot of flexibility for the compose compiler to make optimizations when a stable type is being used in places, which is why it is such an important concept for compose.

Here is a great article that explains in more detail Jetpack Compose core concepts such as stabilityskippability, and restartability. I recommend reading it to better understand the basic concepts of Jetpack Compose and what they can affect.

There are two more classes that we have not covered yet: GridPadCellSize and TotalSize. The GridPadCellSize is just a container that contains information about the size of a specific row or column:

public sealed class GridPadCellSize {
public data class Fixed(val size: Dp) : GridPadCellSize() {
init {
check(size.value > 0) { "size have to be > 0" }
}
}
public data class Weight(val size: Float = 1f) : GridPadCellSize() {
init {
check(size > 0) { "size have to be > 0" }
}
}
}
view raw GridPadCells.kt hosted with ❤ by GitHub

The TotalSize is a helper class that contains information about the sum of all types of sizes for a row or column and is used in a measuring stage:

internal data class TotalSize(
val weight: Float,
val fixed: Dp
)
view raw GridPadCells.kt hosted with ❤ by GitHub

And the final class here is a builder class. This class is not necessary, but it helps to reduce boilerplate code. Our GridPad requires two lists of sizes, which in the basic use case will be the same. For that reason, we should create a mutable class with default initialization and with the ability to override selected sizes:

public data class GridPadCells(
val rowSizes: ImmutableList<GridPadCellSize>,
val columnSizes: ImmutableList<GridPadCellSize>
) {
public constructor(
rowSizes: Iterable<GridPadCellSize>, columnSizes: Iterable<GridPadCellSize>
) : this(rowSizes = rowSizes.toImmutableList(), columnSizes = columnSizes.toImmutableList())
//...
public class Builder(rowCount: Int, columnCount: Int) {
private val rowSizes: MutableList<GridPadCellSize> = GridPadCellSize.weight(rowCount)
private val columnSizes: MutableList<GridPadCellSize> = GridPadCellSize.weight(columnCount)
public fun rowSize(index: Int, size: GridPadCellSize): Builder = apply {
rowSizes[index] = size
}
public fun columnSize(index: Int, size: GridPadCellSize): Builder = apply {
columnSizes[index] = size
}
public fun build(): GridPadCells {
return GridPadCells(
rowSizes = rowSizes, columnSizes = columnSizes
)
}
}
}
view raw GridPadCells.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

Content placement

The second major goal that we would like to achieve is placement content in any cell on the grid with any span width and height.

Few important edge cases that we will handle:

  • a cell may contain more than one element
  • the content may overlap with each other
  • content that goes outside the grid would be ignored

 

 

Here is the signature of GridPad:

 

@Composable
public fun GridPad(
cells: GridPadCells, modifier: Modifier = Modifier, content: GridPadScope.() -> Unit
) {
// implementation
}
view raw GridPadDsl.kt hosted with ❤ by GitHub

Let’s take a look in detail at GridPadScopeGridPadScope is the DSL scope that limits what users can put into our composable layouts. Only content with the item are acceptable:

GridPad(
cells = GridPadCells(2, 2)
) {
// valid content placement
item(row = 0, column = 0, rowSpan = 1, columnSpan = 1) {
// content
}
// invalid content placement
Text(text = "Text")
// valid content placement
item(row = 1, column = 1) {
// invalid content placement
item(row = 1, column = 1) {
// content
}
}
}
view raw GridPad.kt hosted with ❤ by GitHub

Looks pretty easy on the top but is a little bit tricky in the details. Here is GridPadScope definition:

@GridPadScopeMarker
public sealed interface GridPadScope {
public fun item(
row: Int? = null,
column: Int? = null,
rowSpan: Int = 1,
columnSpan: Int = 1,
itemContent: @Composable GridPadItemScope.() -> Unit
)
}
view raw GridPadDsl.kt hosted with ❤ by GitHub

Here are two meaningful things: @GridPadScopeMarker and GridPadItemScope. The @GridPadScopeMarke is a @DslMarker:

@DslMarker
public annotation class GridPadScopeMarker

The GridPadItemScope is a context receiver:

@Stable
@GridPadScopeMarker
public interface GridPadItemScope

The above combination helps to make strict use of our API: a developer cannot place other composables without wrapping them into an item. Nor can the developer put an item inside another item. If you would like to learn more about context receivers I recommend you read this article.

As you can notice, everything that we have just explored is an interface. Let’s take a look in detail at implementations. Before moving on, remember what the composable lifecycle looks like. For custom layout, we need to implement three sub-stages of the layout stage:

Thus we need to measure the children, determine the size of the layout and place the content. Here is a very top-level of the GridPad implementation:

@Composable
public fun GridPad(
cells: GridPadCells, modifier: Modifier = Modifier, content: GridPadScope.() -> Unit
) {
val scopeContent: GridPadScopeImpl = GridPadScopeImpl(cells).apply(content)
Layout(modifier = modifier, content = {
scopeContent.data.forEach {
it.item(GridPadItemScopeImpl)
}
}) { measurables, constraints ->
// measure and placement
}
}
view raw GridPadDsl.kt hosted with ❤ by GitHub

The only interface definition isn’t enough to implement the logic of adding content. For that reason, we need to have some implementation, and that implementation is GridPadScopeImpl. Here is where all calls from using GridPad are redirected to the interface implementation:

val scopeContent: GridPadScopeImpl = GridPadScopeImpl(cells).apply(content)
view raw GridPadDsl.kt hosted with ❤ by GitHub

The GridPadScopeImpl is like a container that collects all emits from top-level DSL, builds a list to display, and provides the ability to get added composables:

internal class GridPadScopeImpl(private val cells: GridPadCells) : GridPadScope {
// list of composables to future measurement and placement
internal val data: MutableList<GridPadContent> = mutableListOf()
override fun item(
row: Int?, column: Int?, rowSpan: Int, columnSpan: Int,
itemContent: @Composable GridPadItemScope.() -> Unit
) {
findPlaceForContent(
row = row, column = column, rowSpan = rowSpan, columnSpan = columnSpan
) { cellRow, cellColumn ->
this.data.add(
GridPadContent(
row = cellRow,
column = cellColumn,
rowSpan = rowSpan,
columnSpan = columnSpan,
item = { itemContent() }
)
)
}
}
private fun findPlaceForContent(
row: Int?, column: Int?, rowSpan: Int, columnSpan: Int,
callback: (row: Int, column: Int) -> Unit
): Boolean {
// some logic for locating in the grid in cases where no row or column is specified
}
}
internal class GridPadContent(
val row: Int, val column: Int, val rowSpan: Int, val columnSpan: Int,
val item: @Composable GridPadItemScope.() -> Unit
)

In the code above, the most significant part is a transformation from DSL call item {} inside GridPad to meta-class GridPadContent that stores information for future measurement and placement.

Let’s go back to GridPad and finish the component. After we collect all information about placement content everything that we need is accurate calculate sizes for each cell, measure composables, and place them into the proper position. Here we wouldn’t deep dive into placement logic calculation, will focus on Jetpack Compose-related API. The first step is to measure children according to their location and span size:

@Composable
public fun GridPad(
cells: GridPadCells, modifier: Modifier = Modifier, content: GridPadScope.() -> Unit
) {
val scopeContent: GridPadScopeImpl = GridPadScopeImpl(cells).apply(content)
Layout(modifier = modifier, content = {
scopeContent.data.forEach {
it.item(GridPadItemScopeImpl)
}
}) { measurables, constraints ->
// ...
val cellPlaces = calculateCellPlaces(cells, width = constraints.maxWidth, height = constraints.maxHeight)
// list of measured children
val placeables = measurables.mapIndexed { index, measurable ->
val contentMetaInfo = scopeContent.data[index]
// calculate the actual maximum cell width in pixels based on the span size
val maxWidth = (0 until contentMetaInfo.columnSpan).sumOf {
cellPlaces[contentMetaInfo.row][contentMetaInfo.column + it].width
}
// calculate the actual maximum cell height in pixels based on the span size
val maxHeight = (0 until contentMetaInfo.rowSpan).sumOf {
cellPlaces[contentMetaInfo.row + it][contentMetaInfo.column].height
}
// measurement of children with cell boundary constraints, not the parent
measurable.measure(
constraints.copy(
minWidth = min(constraints.minWidth, maxWidth),
maxWidth = maxWidth,
minHeight = min(constraints.minHeight, maxHeight),
maxHeight = maxHeight
)
)
}
// ...
}
}
view raw GridPadDsl.kt hosted with ❤ by GitHub

Most of the above code works by calculating boundaries for composables to call the measure for the placed item.

The last touch after measurement of all children is to define the component size and place items:

@Composable
public fun GridPad(
cells: GridPadCells, modifier: Modifier = Modifier, content: GridPadScope.() -> Unit
) {
val scopeContent: GridPadScopeImpl = GridPadScopeImpl(cells).apply(content)
Layout(modifier = modifier, content = {
scopeContent.data.forEach {
it.item(GridPadItemScopeImpl)
}
}) { measurables, constraints ->
// ...
val placeables = measurables.mapIndexed { index, measurable ->
// ...
}
//define component size
layout(layoutWidth, layoutHeight) {
placeables.forEachIndexed { index, placeable ->
val contentMetaInfo = scopeContent.data[index]
val cellPlaceInfo = cellPlaces[contentMetaInfo.row][contentMetaInfo.column]
// placement the item in the desired location
placeable.placeRelative(x = cellPlaceInfo.x, y = cellPlaceInfo.y)
}
}
}
}
view raw GridPadDsl.kt hosted with ❤ by GitHub
Conclusion

A lot of work has been done here. The above examples might look a bit complicated, but Jetpack Compose combined with Kotlin DSL provides possibilities to implement elegant APIs for your custom layouts. When you are faced with a problem, feel free to look into the source code of already completed components and take inspiration there, it’s the best way to learn something new.

This article was originally published on proandroiddev.com on December 21, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu