Lesson 07 / 17
Basic Layouts
Measuring which direction constraints, sizes and positions travel in the UI tree for column, row and box layouts.
Published Updated
In this lesson
- Distinguishes how column, row and box layouts arrange their children
- Measures that in the layout's single pass the constraint travels down, the size travels up and the position travels down
- Shows that reordering children changes the position but not the direction
- Verifies that alignment enters only the position calculation
Contents
The previous lesson measured how a change can quietly travel out to its owner. This lesson looks at a value that travels the opposite way in the same tree: the limit a parent gives its child. A column layout passes the width it was given to its children; the children stay within that width and report their own heights; the column then derives each child’s position from those heights. The measuring axis is the same throughout the course: which direction does this value travel in the UI tree? There are four directions. A value that goes from a parent straight to its child travels down; one that goes from a child to any ancestor in its parent chain travels up; one that goes from an ancestor to a descendant, skipping the parent, travels deep; and if either end is not in the tree, it travels outside.
This lesson’s question is the three values of the layout pass: constraint, size and position. All three move between the same two nodes, but not in the same direction. Which one decides the lesson’s direction is settled in the last section. First, the core that does the measuring:
// 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 }}")
}
}
Column, Row and Box
All three basic layout functions are composable functions (@Composable) and take their children in a lambda. Column arranges its children from top to bottom, Row from start to end (left to right in a left-to-right language); Box stacks its children on top of one another. The axis along which they are arranged is the main axis, and the axis perpendicular to it is the cross axis. The distribution of space along the main axis is given by Arrangement, and the position along the cross axis by Alignment.
// Platform code: Column, Row and Box
@Composable
fun Summary() {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Title")
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text("Name")
Text("Total")
}
Box(contentAlignment = Alignment.BottomEnd) {
Text("Note")
}
}
}
This snippet sets up three tree relationships. Each of Column, Text, Row and Box is a node in the UI tree; Text("Title"), Row and Box are children of Column; the two Text calls in Row are its children. The order is the order written in the source. The alignment and spacing parameters carry a rule about the node’s children; they say nothing about the node itself.
The Layout’s Single Pass
The platform processes a layout node in three steps. First the parent gives the child its constraints: the smallest and largest width and height the child may take. Then the child picks a size within that range and reports it; this is called measuring. Finally the parent looks at the reported sizes and decides each child’s position; this is called placing. The three steps happen in a single pass: the parent gives a child its constraint, the child is measured once and placed once.
The model captures this platform rule. The model is not the platform itself; the helper file carries only the column rule. Its input is the parent’s width, the (name, width, height) triples of the children and the cross-axis alignment. Its output is the x and y position of each child. y is the sum of the heights of the children before it; x depends on the alignment. The numbers here are the model’s calculation, not pixels measured by the platform.
// Layout.kt — the single pass of a column layout (the model's calculation)
class Placed(val node: String, val x: Int, val y: Int)
fun placeColumn(parentWidth: Int, items: List<Triple<String, Int, Int>>, alignment: String): List<Placed> {
var y = 0
return items.map { (node, width, height) ->
val x = when (alignment) {
"center" -> (parentWidth - width) / 2
"end" -> parentWidth - width
else -> 0
}
val placed = Placed(node, x, y)
y += height
placed
}
}
The first measurement is the three values of a single child. The tree puts Screen under App, Column under Screen and two children under Column. Title is the first child. The constraint is given from Column to Title. The size returns from Title to Column. The position again goes from Column to Title; the target of the move is the name of the node the model placed.
// Main.kt — the constraint, size and position of a single child
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Column" to "Screen", "Title" to "Column", "Body" to "Column"))
val placed = placeColumn(360, listOf(Triple("Title", 200, 48), Triple("Body", 300, 120)), "start")
val first = placed[0]
val moves = listOf(
Move("constraint-title", "Column", "Title", tree),
Move("size-title", first.node, "Column", tree),
Move("position-title", "Column", first.node, tree),
)
for (move in moves) {
move.describe()
}
println("${first.node}: x=${first.x} y=${first.y}")
countDirections(moves)
}
constraint-title: from=Column | to=Title -> down size-title: from=Title | to=Column -> up position-title: from=Column | to=Title -> down Title: x=0 y=0 down: 2 up: 1 deep: 0 outside: 0
Both ends of the constraint-title line are written by hand: this line is written to show the rule; it is not a measurement. The size-title and position-title lines, which count as measurements, take one end from the node the model placed.
The constraint and the position went down; the size went up. Title‘s position is x=0, y=0: the topmost child of the column sits in the top-left corner because the alignment is at the start. The two values that went down carry different data. The constraint says what the child may choose; the position says where the chosen size will be put. Both are in the parent’s hands, because the child does not know its own position.
LC1. The model reduces the constraint to a single number, the parent’s width. On the platform, a constraint is four bounds (the smallest and largest width and height), and the case where the height constraint is unbounded was not measured.
Order Changes the Position, Not the Direction
Column arranges its children in the order they are written in the source. If the same two children are given in reverse order, their positions swap. The measurement below places two children in two orders and classifies each position move in the tree.
// Main.kt — position moves in two orders
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Column" to "Screen", "Title" to "Column", "Body" to "Column"))
val items = listOf(Triple("Title", 200, 48), Triple("Body", 300, 120))
val forward = placeColumn(360, items, "start")
val backward = placeColumn(360, items.reversed(), "start")
val moves = mutableListOf<Move>()
for ((name, order) in listOf("forward" to forward, "backward" to backward)) {
for (p in order) {
moves.add(Move("position-$name-${p.node.lowercase()}", "Column", p.node, tree))
println("$name ${p.node}: y=${p.y}")
}
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
forward Title: y=0 forward Body: y=48 backward Body: y=0 backward Title: y=120 position-forward-title: from=Column | to=Title -> down position-forward-body: from=Column | to=Body -> down position-backward-body: from=Column | to=Body -> down position-backward-title: from=Column | to=Title -> down down: 4 up: 0 deep: 0 outside: 0
Title is at y=0 in the forward order and y=120 in the backward order; Body swaps between y=48 and y=0. All four position moves went in the same direction: down. The order changes the value at the end of the move; it does not change which two nodes the move is between. So reordering changes a layout result, not the flow of data.
LC2. The model leaves no space between children; main-axis spacing such as Arrangement values and spacedBy did not enter the y calculation.
Alignment Is Only Position
In Column, horizontalAlignment picks the position along the cross axis; in Row, verticalAlignment does the same job on the vertical axis. Because the model builds the column’s cross axis as x, the same measurement holds for the cross axis of Row: the axes swap, the relationship stays the same. The alignment calculation uses the difference between the child’s width and the parent’s width. That difference is computed in place in the parent; what is sent to the child is the result of the calculation.
// Main.kt — the position under three alignments
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Column" to "Screen", "Title" to "Column"))
val moves = mutableListOf<Move>()
for (alignment in listOf("start", "center", "end")) {
val title = placeColumn(360, listOf(Triple("Title", 200, 48)), alignment)[0]
println("$alignment: x=${title.x}")
moves.add(Move("position-$alignment", "Column", title.node, tree))
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
start: x=0 center: x=80 end: x=160 position-start: from=Column | to=Title -> down position-center: from=Column | to=Title -> down position-end: from=Column | to=Title -> down down: 3 up: 0 deep: 0 outside: 0
The same child got x=0, x=80 and x=160 under the three alignments. All three moves are the same: from the parent to the child, down. Alignment is not data but a calculation parameter; it sets up no new relationship in the tree. Box carries this relationship to the second axis: its children stack in the same box, and contentAlignment picks the horizontal and vertical position together.
LC3. The model reduces alignment to three values. The full set of Alignment values, the start and end meaning in a left-to-right language, and the case where the child is larger than the parent were not measured.
Row and Box Set Up the Same Relationship
Row is Column with its axes swapped. The main axis becomes horizontal: the children’s widths are summed, and each child’s x position is the sum of the widths before it. The cross axis is vertical, and y depends on the alignment. The shape of the calculation is the same; what changes is only which size is summed and which is compared with the parent. In the tree, nothing changes: Row also gives its children constraints, takes sizes from them and places them. The ends of the moves are the same, so their directions are the same.
Box behaves differently, but not in terms of tree relationships. None of the children affects the position of another; each child is measured independently within the box’s constraint and placed according to contentAlignment. The box’s own size fits the largest of its children. This is a case where the size goes up and the parent’s size comes from its children. But one child’s size does not change its sibling’s constraint. There is no direct data move between siblings; this boundary will come up often in the coming lessons.
What these three layouts share is that a child does not know its own position. The position arises not as a parameter of the child but as the result of the parent’s placing step. If a child wants to say “lean to the right of the screen”, it does so with the parent’s alignment parameter or with a modifier that widens its own constraint. The subject of modifiers belongs to the second lesson.
LC4. No separate model was built for Row and Box; the calculation described in this section is an account derived from the column model and was not measured.
The Bounding Measurement
The lesson’s direction is chosen by reading the three values together, not a single move. The measurement below counts four moves together: the constraint Screen gives Column, the constraint Column gives Body, the size Body reports and the position Column gives Body.
// Main.kt — the direction tally of four moves
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Column" to "Screen", "Title" to "Column", "Body" to "Column"))
val placed = placeColumn(360, listOf(Triple("Title", 200, 48), Triple("Body", 300, 120)), "start")
val body = placed[1]
val moves = listOf(
Move("constraint-column", "Screen", "Column", tree),
Move("constraint-body", "Column", "Body", tree),
Move("size-body", body.node, "Column", tree),
Move("position-body", "Column", body.node, tree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
constraint-column: from=Screen | to=Column -> down constraint-body: from=Column | to=Body -> down size-body: from=Body | to=Column -> up position-body: from=Column | to=Body -> down down: 3 up: 1 deep: 0 outside: 0
Both ends of the constraint-column and constraint-body lines are written by hand: these lines are written to show the rule; they are not measurements. The moves that count as measurements are size-body (up) and position-body (down), which take one end from the Body node the model placed; two of the three downs in the count belong to the demonstration lines.
Three down, one up. The only value that goes up is the size; the constraint and the position go down. A size never exists without a constraint, because a child can pick a size only within the range it was given; so every size that goes up is preceded by a constraint that goes down. That is why the lesson’s direction is the constraint’s direction: down.
LC5. The model sets up four moves for one Column and a single Body under it. As the number of children grows, the number of size and position moves grows with it; this measurement is limited to one child.
Summary
- This lesson’s direction: down. Constraint and position go from the parent to the child; the measured position move goes down as well.
- The layout’s single pass carries three values: constraint, size and position.
- The constraint goes from the parent to the child and the position goes from the parent to the child; both are down.
- The size a child reports returns to the parent; that move is up.
- Order and alignment change the position’s value, not the move’s direction.
Next Step
The column is not the only node that passes the constraint to a child. Every modifier chained onto a composable function also passes its own constraint on to whatever follows it. When the order changes, the same constraint travels through a different node. The next lesson measures this chain, whose order changes the direction.
To keep your progress and take notes, Log in
My notes
Log in to take notes.