Jetpack Compose - Modifier.layout (Part 1)

Reference: https://developer.android.com/jetpack/compose/layouts/custom#layout-modifier

The simple Modifier.layout from sample code

Let's replicate the Modifier.firstBaselineToTop in the official document, step by step to understand how it works.

The final form:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = layout { measurable, constraints ->
    // Measure the composable
    val placeable = measurable.measure(constraints)

    // Check the composable has a first baseline
    check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
    val firstBaseline = placeable[FirstBaseline]

    // Height of the composable with padding - first baseline
    val placeableY = firstBaselineToTop.roundToPx() - firstBaseline
    val height = placeable.height + placeableY
    layout(placeable.width, height) {
        // Where the composable gets placed
        placeable.placeRelative(0, placeableY)
    }
}

And its result:

Step 1: the do-nothing attempt

In the first step, let's just add the bare minimum code that does nothing:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = layout { _: Measurable, _: Constraints ->
    // Usually, we need to measure the composable and get the `placeable`, but in this step, we will ignore it, and pass 0 as width and height to the layout callback.
    layout(width = 0, height = 0) { /* Do nothing */ }
}

And verify the UI by using the Android Studio layout inspector. In this step, it is easy to imagine that there is nothing shown in the UI. Let's skip this step.

Step 2: measure it, lay it out, but don't place it there.

What we do is to measure the composable, pass the measured width and height to the layout callback in the lambda.:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = layout { measurable: Measurable, constraints: Constraints ->
    // Measure the composable and get the placeable, using the provided measurable and constraints
    val placeable = measurable.measure(constraints)
    layout(width = placeable.width, height = placeable.height) { /* Do nothing */ }
}

Now let's check the UI. It is still a blank space in the screen, but if we inspect it, there is something populates the space:

Screen Shot 2021-08-07 at 22.15.56.png

Adjusting the width or height above, we can conclude that: the layout callback will tell the parent to reserve a rectangle whose width and height are provided value, and its coordinate starts from (0, 0). This rectangle will be the boundary of our composable after laying out. Indeed, if we update out code to the following and check the result:

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = layout { measurable: Measurable, constraints: Constraints ->
    // Measure the composable and get the placeable, using the provided measurable and constraints
    val placeable = measurable.measure(constraints)
    layout(width = placeable.width, height = placeable.height) {
        placeable.placeRelative(x = 20, y = 20)
    }
}

Screen Shot 2021-08-07 at 22.20.12.png

The layout inspector shows that: our text is placed 20 pixel from the left, and 20 pixel from the top, and it is cut at the edge of the dimension we provided above.

To be able to correctly place the composable, we need to adjust the space required by it. It is time to take the firstBaselineToTop into account.

Step 3: measure it, reserve space for it and place it

fun Modifier.firstBaselineToTop(
    firstBaselineToTop: Dp
) = layout { measurable, constraints ->
    // Measure the composable
    val placeable = measurable.measure(constraints)

    // Check the composable has a first baseline and extract it
    check(placeable[FirstBaseline] != AlignmentLine.Unspecified)
    val firstBaseline = placeable[FirstBaseline]

    // Calculate: the total height of the composable after applying the `firstBaselineToTop` value is equal to its original height plus the diff between the
`firstBaselineToTop` value and the first baseline, which is:
    val totalHeight = placeable.height + (firstBaselineToTop.roundToPx() - firstBaseline)
    // Therefore, the starting point from top by which we place the composable is:
    val placeableY = totalHeight - placeable.height
    layout(placeable.width, totalHeight) {
        // Now place it
        placeable.placeRelative(0, placeableY)
    }
}

And tada:

Screen Shot 2021-08-07 at 22.26.55.png

Exploring the layout callback

It seems that the layout callback is one of the core actor here. Let's learn more about it:

interface MeasureScope : IntrinsicMeasureScope {
    /**
     * @param alignmentLines the alignment lines defined by the layout
     * @param placementBlock block defining the children positioning of the current layout
     */
    fun layout(
        width: Int,
        height: Int,
        alignmentLines: Map<AlignmentLine, Int> = emptyMap(),
        placementBlock: Placeable.PlacementScope.() -> Unit
    ) = object : MeasureResult {
        override val width = width
        override val height = height
        override val alignmentLines = alignmentLines
        override fun placeChildren() {
            Placeable.PlacementScope.executeWithRtlMirroringValues(
                width,
                layoutDirection,
                placementBlock
            )
        }
    }
}

So beside the width, the height and the placementBlock lambda we used to place the placeable, there is alignmentLines values which is default to an empty map. Let's learn about this value in part 2.