Lesson 08 / 17
Modifiers
Measuring how the order of a modifier chain changes the final width, the area a background covers and the direction the constraint travels.
Published Updated
In this lesson
- Reads a modifier chain as nested wrapper nodes
- Measures that swapping the order of the same two modifiers changes the final width
- Shows that the area a background covers depends on the order
- Classifies the same constraint as travelling down or deep depending on the order
Contents
In the previous lesson a column passed the width it was given to its children, and the constraint was seen to travel from parent to child, that is, down. This lesson looks at the same constraint passing not through one node but through a chain. The modifier chain (Modifier) given to a composable’s modifier parameter adds size, padding, background and click behavior. The chain is written from left to right, and the order carries meaning: the same two modifiers give a different result in the opposite order.
The reason is that each modifier in the chain wraps the ones that come after it. The outer modifier takes the constraint, changes it and hands it to the inner one; the size that returns from the inner one, it enlarges or limits with its own share. What is to be measured is how this wrapping looks in the UI tree.
// Direction.kt — classifies which direction a value travels in the UI tree
class Tree(private val parentOf: Map<String, String>) {
fun contains(node: String): Boolean = node in parentOf.keys || node in parentOf.values
fun parent(node: String): String? = parentOf[node]
fun ancestors(node: String): List<String> {
val chain = mutableListOf<String>()
var current = parentOf[node]
while (current != null) {
chain.add(current)
current = parentOf[current]
}
return chain
}
}
class Move(val label: String, val from: String, val to: String, tree: Tree) {
val direction: String = when {
!tree.contains(from) || !tree.contains(to) -> "outside"
to in tree.ancestors(from) -> "up"
tree.parent(to) == from -> "down"
from in tree.ancestors(to) -> "deep"
else -> error("unrelated: $from and $to")
}
fun describe() {
println("$label: from=$from | to=$to -> $direction")
}
}
fun countDirections(moves: List<Move>) {
for (name in listOf("down", "up", "deep", "outside")) {
println("$name: ${moves.count { it.direction == name }}")
}
}
The Chain and the Order
The snippet below sets up two chains. In the first, the size comes before the padding; in the second, after. The third example is a chain whose order changes whether the click area includes the padding.
// Platform code: chains whose order carries meaning
@Composable
fun Chains() {
Box(Modifier.size(200.dp).padding(16.dp).background(Color.LightGray))
Box(Modifier.padding(16.dp).size(200.dp).background(Color.LightGray))
Text(
text = "Open",
modifier = Modifier.fillMaxWidth().clickable { open() }.padding(16.dp),
)
}
The first two Box calls carry the same three modifiers, but in a different order. If size(200.dp) comes first, the node’s total width is 200 and the padding is taken out of that 200. If padding(16.dp) comes first, the content is 200 wide and the padding is added outside it. In the third example, clickable comes before padding, so the clickable area includes the padding; if the order is reversed, it covers only the content.
The platform’s rule is this: the first modifier in the chain takes the constraint, processes it and passes it to the second. At the end of the chain, the content is measured with the narrowed constraints. The model captures this rule. The model is not the platform itself. The helper file knows three modifiers: SizeMod limits the width, PaddingMod sets aside padding on both sides, and BackgroundMod records the area it covers. The content takes the largest width it was given; this is a modeling assumption. The wrapChain function also turns the chain into a UI tree: the first modifier is the parent’s child, each following modifier is the child of the one before it, and the content is innermost.
// Chain.kt — the measurement of a modifier chain (the model's calculation)
sealed interface Mod
class SizeMod(val width: Int) : Mod
class PaddingMod(val amount: Int) : Mod
object BackgroundMod : Mod
class Measured(val total: Int, val content: Int, val painted: Int?)
fun measureChain(chain: List<Mod>, min: Int, max: Int): Measured {
if (chain.isEmpty()) return Measured(max, max, null)
val rest = chain.drop(1)
return when (val mod = chain.first()) {
is SizeMod -> {
val fixed = mod.width.coerceIn(min, max)
val inner = measureChain(rest, fixed, fixed)
Measured(fixed, inner.content, inner.painted)
}
is PaddingMod -> {
val inner = measureChain(rest, maxOf(0, min - 2 * mod.amount), maxOf(0, max - 2 * mod.amount))
Measured((inner.total + 2 * mod.amount).coerceIn(min, max), inner.content, inner.painted)
}
BackgroundMod -> {
val inner = measureChain(rest, min, max)
Measured(inner.total, inner.content, inner.total)
}
}
}
class Wrapped(val tree: Tree, val nodes: List<String>)
fun wrapChain(chain: List<Mod>, parent: String): Wrapped {
val nodes = chain.map {
when (it) {
is SizeMod -> "Size"
is PaddingMod -> "Padding"
BackgroundMod -> "Background"
}
} + "Content"
val parentOf = nodes.indices.associate { nodes[it] to (if (it == 0) parent else nodes[it - 1]) }
return Wrapped(Tree(parentOf), nodes)
}
Size Inside the Padding
The first measurement sets up the chain size(200) followed by padding(16). The parent gives a constraint of at most 360 wide. The tree has Size under Row, Padding under Size and Content under Padding. Each constraint passes from a wrapper to the one immediately inside it.
// Main.kt — size first, padding second
fun main() {
val chain = listOf(SizeMod(200), PaddingMod(16))
val wrapped = wrapChain(chain, "Row")
val measured = measureChain(chain, 0, 360)
println("total=${measured.total} content=${measured.content}")
val moves = listOf(
Move("constraint-size", wrapped.nodes[0], wrapped.nodes[1], wrapped.tree),
Move("constraint-padding", wrapped.nodes[1], wrapped.nodes[2], wrapped.tree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
total=200 content=168 constraint-size: from=Size | to=Padding -> down constraint-padding: from=Padding | to=Content -> down down: 2 up: 0 deep: 0 outside: 0
The node’s total width is 200 and the content is 168. The padding was taken out of the 200: 16 on each side, 32 in total. Both constraint moves are down, because each goes from a wrapper to the node immediately inside it. The constraint a modifier gives straight to what it wraps is classified as down.
LC1. The model limits the size modifier to a single width and keeps it within the constraint with coerceIn. Modifiers that can exceed the constraint, such as the platform’s requiredSize, were not measured.
The Final Width When the Order Changes
Now the order of the two modifiers is reversed: padding first, then size. The parent’s constraint is the same. The difference is that the constraint reaches the padding first; the padding narrows the constraint, the size picks 200 within the narrowed constraint, and the padding is added outside that 200.
// Main.kt — the final width in two orders
fun main() {
val orders = listOf(
"size-padding" to listOf(SizeMod(200), PaddingMod(16)),
"padding-size" to listOf(PaddingMod(16), SizeMod(200)),
)
for ((name, chain) in orders) {
val measured = measureChain(chain, 0, 360)
println("$name: total=${measured.total} content=${measured.content}")
}
}
size-padding: total=200 content=168 padding-size: total=232 content=200
The same two modifiers, the same constraint; the total width is 200 against 232, and the content is 168 against 200. The order of the chain decides in which wrapper the value is added. These are the model’s calculations. The tree counterpart of the order is measured in the last section.
LC2. The parent constraint was fixed at 360. The lower bound of the constraint was taken as zero; constraints with a lower bound above zero were not measured in this lesson.
The Constraint Goes In, the Size Comes Out
There are two directions in reading the chain, and both live in the same chain. The constraint descends from the outside in: each link processes the constraint and passes it to the one inside. The size returns from the inside out: the content reports its size, each link adds its own share to that size or subtracts it, and reports to the one outside. In the first measurement above, the content returned 168 wide, Padding added 32 to it and Size reported 200. In the second order, the same return started with 200 instead of 168 and ended with 232.
This distinction prevents two common misreadings. The first is the idea that the size modifier always settles the final size. size only pins the constraint; the value it pins still stays within the constraint of the links that come before it. The model captured this with coerceIn. The platform also has a separate modifier that can exceed the constraint, requiredSize; this lesson did not measure it. The second is the idea that order affects only appearance. The order decides which link sees which constraint; appearance is the result.
The chain’s place in the tree should also be noted separately. On the platform, each link is not assumed to be a separate UI node. The model, though, counts each link as a node, because the relationship being measured is which link receives the constraint before which. This is a simplification, and its results hold to the extent that it reflects the order in which the constraint passes.
LC3. The model counts each modifier as a node. How the platform represents the modifier chain internally was not measured in this lesson.
The Background Follows the Order
A background paints the area its own node covers; that is, as much as the total of the chain it wraps. If padding comes before the background, the background stays inside the padding. If the background comes first, it paints the padding too.
// Main.kt — the area the background covers
fun main() {
val orders = listOf(
"padding-background" to listOf(PaddingMod(16), BackgroundMod),
"background-padding" to listOf(BackgroundMod, PaddingMod(16)),
)
for ((name, chain) in orders) {
val measured = measureChain(chain, 0, 360)
println("$name: painted=${measured.painted} content=${measured.content}")
}
}
padding-background: painted=328 content=328 background-padding: painted=360 content=328
The content is 328 wide in both cases. The painted area is 328 against 360. This shows that the background modifier has no width of its own: its width is the total of what comes after it in the chain. The click area works by the same logic: clickable listens to the area covered by the chain it wraps.
LC4. The model measures the painted area only as a width; shape information such as height and corner rounding is not in the model. The click area was also not measured in this lesson; it was only said to follow the same logic as the background.
The Bounding Measurement
The lesson’s direction is decided by the question of whether the order changes the direction. Size’s constraint reaches the content in both orders. But in one order Padding is immediately inside it, and in the other Size wraps the content directly. The measurement classifies the constraint move from Size to Content in both orders.
// Main.kt — the direction of the same constraint in two orders
fun main() {
val orders = listOf(
"size-padding" to listOf(SizeMod(200), PaddingMod(16)),
"padding-size" to listOf(PaddingMod(16), SizeMod(200)),
)
val moves = mutableListOf<Move>()
for ((name, chain) in orders) {
val wrapped = wrapChain(chain, "Row")
moves.add(Move("size-to-content-$name", "Size", wrapped.nodes.last(), wrapped.tree))
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
size-to-content-size-padding: from=Size | to=Content -> deep size-to-content-padding-size: from=Size | to=Content -> down down: 1 up: 0 deep: 1 outside: 0
The same Size constraint reaches Content deep in the size-padding order, because Padding sits in between. In the padding-size order, Size wraps the content directly and the constraint goes down. The modifier is the same, the constraint is the same; when the order changes, the wrapper that steps in changes the direction. Each link in the chain gives the constraint to the one immediately inside it; those moves are down. Deep is the case where the intervening links are skipped, and only the order produces it. The constraint’s real path through the chain descends link by link, so the lesson’s direction is: down.
LC5. The model classifies the direction for only one constraint and two orders. In a chain with three or more links, the move is still deep as the number of skipped links grows, but that number was not measured.
Summary
- This lesson’s direction: down. In the chain, the constraint goes straight from the wrapping link to the one it wraps.
- A modifier chain is nested nodes, each link wrapping the one after it.
- The outer modifier’s constraint goes down to the one it directly wraps.
- When the order of the same two modifiers changes, the final width changes; the padding stays inside or outside the size.
- The area a background covers is the total of the chain that comes after it.
Next Step
Modifiers pass the constraint along a chain; but each link in the chain is still inside a parent-child relationship. There is no direct constraint between siblings. This boundary becomes visible in layouts that give an item’s position relative to another sibling. The next lesson measures constraint-based layout.
To keep your progress and take notes, Log in
My notes
Log in to take notes.