Layout modifiers in Jetpack Compose
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.
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 π
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
)
}
}
- We can measure the element (
measurable
) using the constraints imposed by the parent. We only need to constrainminWidth
a bit further to make the element take at least half of themaxWidth
allowed by the parent. - 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
, andfillMaxSize
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
, orminHeight == 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! π