Build apps faster with our new App builder! Check it out β†’
This article was posted more than a year ago. The author's opinions, the state of technology and the world might have changed since then.

Layout modifiers in Jetpack Compose

by @jorgecastillopr
#compose
#advanced

In this article, Jorge Castillo shares his in-depth knowledge about how Layout Modifiers work under the hood in Jetpack Compose.

Jorge is the expert in such topics, as he is the author of Jetpack Compose internals. He is also a Google Developer Expert on Android and Kotlin. Enjoy this article and you can find more of Jorge's work in the end of this article.

Featured in Android Weekly #558


Purpose, how to use them, internals, and examples of the different implementations available in Compose πŸ“

Not long ago, I wrote about how measuring and drawing works in Jetpack Compose. Give it a read for some initial context πŸ‘‡

Measuring and drawing in Jetpack Compose

Purpose

The layout modifier allows to change the measure and placement (coordinates) of a Layout Composable.

By Layout Composable we can understand any UI Composable, like the ones from the material or foundation libraries, for example. Essentially any UI building block.

Modifier.layout affects a single layout, as opposed to the Layout Composable, that affects multiple elements (its children). Every time you need some custom measuring and placement logics for a UI element, consider using this modifier.

How to use it

We can learn this with an example. Let’s say we wanted to modify an element so it always takes at least half of the parent width, and gets centered within the parent. Something like this:

Here is how we could achieve that in code using the layout modifier:

import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout

fun Modifier.takeHalfParentWidthAndCenter(): Modifier =
    this.layout { measurable, constraints ->
        val maxWidthAllowedByParent = constraints.maxWidth
        val placeable = measurable.measure(
            constraints.copy(minWidth = maxWidthAllowedByParent / 2)
        )

        layout(placeable.width, placeable.height) {
            placeable.placeRelative(
                maxWidthAllowedByParent / 2 - placeable.width / 2,
                0
            )
        }
    }
  1. We can measure the element (measurable) using the constraints imposed by the parent. We only need to constrain minWidth a bit further to make the element take at least half of the maxWidth allowed by the parent.
  2. We center the element horizotally by placing it in x = (parent width) / 2 - (element width) / 2.

Calling measurable.measure() returns a Placeable, so the types enforce us to measure the element before placing it.

Read the official docs for more examples on how to use the layout modifier.

Internals

In Compose, there are a few modifier types defined as interfaces that several modifiers implement differently. One of them is the LayoutModifier. This interface sets the contract that all layout modifiers must fulfill. This is how it looks πŸ‘‡

interface LayoutModifier : Modifier.Element {
    fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult

    fun IntrinsicMeasureScope.minIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ): Int = MeasuringIntrinsics.minWidth(
        this@LayoutModifier,
        this,
        measurable,
        height
    )

    fun IntrinsicMeasureScope.minIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ): Int = MeasuringIntrinsics.minHeight(
        this@LayoutModifier,
        this,
        measurable,
        width
    )

    fun IntrinsicMeasureScope.maxIntrinsicWidth(
        measurable: IntrinsicMeasurable,
        height: Int
    ): Int = MeasuringIntrinsics.maxWidth(
        this@LayoutModifier,
        this,
        measurable,
        height
    )

    fun IntrinsicMeasureScope.maxIntrinsicHeight(
        measurable: IntrinsicMeasurable,
        width: Int
    ): Int = MeasuringIntrinsics.maxHeight(
        this@LayoutModifier,
        this,
        measurable,
        width
    )
}

Its main function is MeasureScope.measure(). This is the one more frequently overriden by the different implementations available. This function is used to measure and place the modified element (measurable), which can be affected by the incoming parent constraints and / or adapt to its content.

fun MeasureScope.measure(
    measurable: Measurable,
    constraints: Constraints
): MeasureResult

The other functions available in the contract are for calculating the measure intrinsics of the element. I introduce this topic at the end of this post, and will write about it in much detail in my newsletter soon. Consider subscribing to get notified.

Examples of implementations available in Compose

A good example of a layout modifier that makes the affected element adapt to parent constraints is the FillModifier.

This is the implementation shared by fillMaxWidth, fillMaxHeight, and fillMaxSize modifiers.

The goal of this modifier is to fill all the space available in the parent for a given dimension (or both). To do that, it needs to check if the parent imposes a bound for that dimension (constraints.hasBoundedWidth or constraints.hasBoundedHeight). If it does, this modifier sets that exact size to the affected Composable.

In Compose, we effectively do that by measuring the node with both min and max constraints for that direction set to the same exact value minWidth == maxWidth, or minHeight == maxHeight.

When the parent does not impose a bound in the required dimension, or it sets it as infinite (Column, Row, LazyColumn, LazyRow…), the affected Composable can be measured using the constraints imposed by the parent as they are.

private class FillModifier(
    private val direction: Direction,
    private val fraction: Float,
    inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val minWidth: Int
        val maxWidth: Int
        if (constraints.hasBoundedWidth && direction != Direction.Vertical) {
            val width = (constraints.maxWidth * fraction).roundToInt()
                .coerceIn(constraints.minWidth, constraints.maxWidth)
            minWidth = width
            maxWidth = width
        } else {
            minWidth = constraints.minWidth
            maxWidth = constraints.maxWidth
        }
        val minHeight: Int
        val maxHeight: Int
        if (constraints.hasBoundedHeight && direction != Direction.Horizontal) {
            val height = (constraints.maxHeight * fraction).roundToInt()
                .coerceIn(constraints.minHeight, constraints.maxHeight)
            minHeight = height
            maxHeight = height
        } else {
            minHeight = constraints.minHeight
            maxHeight = constraints.maxHeight
        }
        val placeable = measurable.measure(
            Constraints(minWidth, maxWidth, minHeight, maxHeight)
        )

        return layout(placeable.width, placeable.height) {
            placeable.placeRelative(0, 0)
        }
    }
}

Note that fill modifiers allow to pass a fraction so they only fill up to a percentage of the parent dimension/s.

Another interesting example of a LayoutModifier can be Modifier.padding. The padding modifier is implemented like this πŸ‘‡

private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp,
    val rtlAware: Boolean,
    inspectorInfo: InspectorInfo.() -> Unit
) : LayoutModifier, InspectorValueInfo(inspectorInfo) {

    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {

        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()

        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))

        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
            if (rtlAware) {
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
            } else {
                placeable.place(start.roundToPx(), top.roundToPx())
            }
        }
    }
}

To measure an element with Modifier.padding applied, it must measure it using the parent constraints but substract the room taken by the padding in both dimensions first. To do that, it uses the offset function:

val horizontal = start + end
val vertical = top + bottom

measurable.measure(constraints.offset(-horizontal, -vertical))

Since min and max constraints for each dimension are represented using absolute values, substracting from them means reducing the available space to measure the element. This effectively measures the element only, without the padding.

Once measured, it is time to place it, and here is where the padding becomes effective.

val placeable = measurable.measure(...)

val width = constraints.constrainWidth(placeable.width + horizontal)
val height = constraints.constrainHeight(placeable.height + vertical)
return layout(width, height) {
    if (rtlAware) {
        placeable.placeRelative(start.roundToPx(), top.roundToPx())
    } else {
        placeable.place(start.roundToPx(), top.roundToPx())
    }
}

To calculate the width and height of the layout, it takes the measured width and height and adds the total horizontal or vertical padding on top of it, respectively. That ensures there is room for both the measured child and the padding.

Finally, since we place it relative to the parent coordinates, we can pass start and top padding as the x and y positions to place it.

Intrinsics

The LayoutModifier interface also provides methods to calculate the intrinsic measures of the layout. Intrinsics are used to estimate the size of the layout before we’ve had a chance to measure it. That is a cheap and efficient way to do it, since measuring twice is forbidden by Compose (you get a crash if you try to measure an element twice). You can learn more about intrinsic measuring in the official documentation. I am also planning to write about that in the future, so stay tuned.


✍️ My newsletter

If you are interested in Jetpack Compose and its internals, or any other Android topics, you might consider subscribing to my newsletter. It will help you to grow a sense on how things work on the inside.

πŸ“– Jetpack Compose internals

If you liked this post consider acquiring the Jetpack Compose internals book, where I go over all this in more detail.

🐦 More on Twitter

You can follow me on Twitter for more. I post about Jetpack Compose and other Android topics all the time.

πŸ‘¨β€πŸ« The exclusive online training

The online training is already sold out, but I will be announcing new dates pretty soon. Expect some time around May / June. Stay tuned! πŸ™Œ

by @alexstyl