Lesson 09 / 17
Constraint-Based Layout
Measuring that the position of items anchored to a sibling is computed through the parent and that a direct move from sibling to sibling cannot be set up.
Published Updated
In this lesson
- Explains that in a constraint-based layout an item's position depends on its sibling
- Measures that the sibling's position first goes up to the layout parent and then down to the child
- Shows that a direct move from sibling to sibling cannot be set up in the tree
- Recognizes that a guideline behaves like a sibling in this relationship
Contents
In the previous lesson the children of a column were arranged in the order written in the source. Each child took its position only from its parent; it did not need to know where its siblings were. But in some interfaces an item’s place depends on another item’s place: the name goes to the right of the icon, and the badge goes under the name. This dependency can be built with nested columns and rows, but each dependency adds a level. Constraint-based layout (ConstraintLayout) solves it in a single level: items are anchored to one another, to the parent and to guidelines.
This lesson measures what anchoring to a sibling means in the UI tree. Siblings are children of the same parent, and there is no edge between them in the tree. If an item’s position depends on another item, the question arises of which path this information travels.
// 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 }}")
}
}
Anchoring Items to One Another
In a constraint-based layout, items are first created as references, and then each anchors its edges to the edge of another reference. On the horizontal axis, one item’s start can be anchored to another’s end; on the vertical axis, its top can be anchored to another’s bottom. A margin can be put between them. A guideline, on the other hand, is a helper that is not visible on screen: it sits at a fraction of the parent’s width, and items can be anchored to it.
// Platform code: items anchored to a sibling and a guideline
import androidx.constraintlayout.compose.ConstraintLayout
@Composable
fun ProfileRow() {
ConstraintLayout(Modifier.fillMaxWidth()) {
val (avatar, name, badge) = createRefs()
val guide = createGuidelineFromStart(0.3f)
Box(Modifier.size(48.dp).constrainAs(avatar) {
start.linkTo(parent.start)
top.linkTo(parent.top)
})
Text("Ada", Modifier.constrainAs(name) {
start.linkTo(avatar.end, margin = 8.dp)
top.linkTo(parent.top)
})
Text("New", Modifier.constrainAs(badge) {
start.linkTo(guide)
top.linkTo(name.bottom)
})
}
}
In this snippet all three items are children of ConstraintLayout; name is anchored to the end of avatar, and badge to the bottom of name and to the guideline. The syntax keeps the dependency in the item’s own lambda: name reads avatar through its own edge. In the tree, though, name and avatar are siblings; the link visible in the source is not an edge in the tree.
The model captures this platform rule. The model is not the platform itself. The helper file takes the items in dependency order: an item’s start is the sum of the start of the item it is anchored to, that item’s width and the gap between them; the start of an item anchored to nothing is only its gap. The dependency order comes from the order the items are given; the platform’s resolving of the dependency graph is not in the model.
// Anchors.kt — the position of items anchored to a sibling (the model's calculation)
class Item(val name: String, val width: Int, val anchor: String?, val gap: Int)
class Placement(val name: String, val x: Int, val anchor: String?)
fun solveAnchors(items: List<Item>): List<Placement> {
val placed = mutableMapOf<String, Int>()
val widths = items.associate { it.name to it.width }
return items.map { item ->
val anchor = item.anchor
val x = if (anchor == null) item.gap else placed.getValue(anchor) + widths.getValue(anchor) + item.gap
placed[item.name] = x
Placement(item.name, x, anchor)
}
}
The Sibling’s Position Goes Up First
The position of an item anchored to a sibling comes about in two steps. First the sibling’s position is reported to the layout parent: the layout has to know which item is where, because the placing step is its job. Then the parent gives the anchored item its new position. The first move goes from child to parent, that is, up; the second goes from parent to child, that is, down.
The tree puts ConstraintLayout under Screen and three items under it: Avatar, Name and Badge. The first measurement places the first two items. Avatar is anchored to the parent; Name is anchored to Avatar.
// Main.kt — the sibling's position goes up to the parent and comes down from it
fun main() {
val tree = Tree(mapOf(
"Screen" to "App", "ConstraintLayout" to "Screen",
"Avatar" to "ConstraintLayout", "Name" to "ConstraintLayout", "Badge" to "ConstraintLayout",
))
val placements = solveAnchors(listOf(Item("Avatar", 48, null, 0), Item("Name", 120, "Avatar", 8)))
val first = placements[0]
val second = placements[1]
println("${first.name}: x=${first.x}")
println("${second.name}: x=${second.x}")
val moves = listOf(
Move("report-position-avatar", first.name, "ConstraintLayout", tree),
Move("give-position-name", "ConstraintLayout", second.name, tree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
Avatar: x=0 Name: x=56 report-position-avatar: from=Avatar | to=ConstraintLayout -> up give-position-name: from=ConstraintLayout | to=Name -> down down: 1 up: 1 deep: 0 outside: 0
Name got the position x=56: Avatar’s width is 48 and the gap between them is 8. This information did not go from Avatar to Name directly. First Avatar reported its own position to the layout, up; the layout used it to compute Name’s position and gave it to Name, down. What looks like a one-line link in the source is a two-move path in the tree.
LC1. The model takes the items in dependency order and places them only on the horizontal axis. Vertical links, anchoring to several items at once, and the case where dependencies form a cycle were not measured.
Two-Move Steps Along a Chain
When the chain gets longer, each link repeats the same two moves. Badge is anchored to Name; Name’s position goes up to the layout first, then down to Badge. This means that in a three-item chain Name plays two different roles: the item anchored relative to Avatar, and the item read by Badge.
// Main.kt — the moves of a three-item chain
fun main() {
val tree = Tree(mapOf(
"Screen" to "App", "ConstraintLayout" to "Screen",
"Avatar" to "ConstraintLayout", "Name" to "ConstraintLayout", "Badge" to "ConstraintLayout",
))
val placements = solveAnchors(listOf(
Item("Avatar", 48, null, 0), Item("Name", 120, "Avatar", 8), Item("Badge", 32, "Name", 8),
))
val moves = mutableListOf<Move>()
for (p in placements) {
println("${p.name}: x=${p.x}")
val anchor = p.anchor ?: continue
moves.add(Move("report-position-${anchor.lowercase()}", anchor, "ConstraintLayout", tree))
moves.add(Move("give-position-${p.name.lowercase()}", "ConstraintLayout", p.name, tree))
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
Avatar: x=0 Name: x=56 Badge: x=184 report-position-avatar: from=Avatar | to=ConstraintLayout -> up give-position-name: from=ConstraintLayout | to=Name -> down report-position-name: from=Name | to=ConstraintLayout -> up give-position-badge: from=ConstraintLayout | to=Badge -> down down: 2 up: 2 deep: 0 outside: 0
Three items, two links and four moves: two up, two down. Badge got the position x=184: Name’s start is 56, its width is 120 and the gap is 8. The number of up moves equals the number of links; each link sends the position of the sibling it is anchored to up to the parent once.
LC2. The model uses the right edge of the item a link is anchored to. Anchoring to a guideline is a separate relationship and was not measured.
A Guideline Is a Sibling
A guideline is an invisible helper that sits at the same level as the items. When items are anchored to it, the position information again flows through the parent: the line’s position is computed from a fraction of the parent’s width, and the layout gives it to the item anchored to the line. In this respect a guideline is the same as a sibling: in the tree it sits beside the items, and there is no direct edge between it and them. The model measured in this lesson did not build the guideline as a separate item; this sentence is a description of platform behavior, not a measurement result.
Sibling to Sibling Cannot Be Set Up
In the source code it is easy to write the line where name is anchored directly to avatar. In the tree, though, two siblings sending data to each other is not possible without going up to the common ancestor and coming down from it. The core classifies this case not as a direction but as an error: it is neither down, nor up, nor deep, nor outside. Code that tries to set up the move stops with an unrelated error.
// Main.kt — a direct move between siblings is rejected
fun main() {
val tree = Tree(mapOf(
"Screen" to "App", "ConstraintLayout" to "Screen",
"Avatar" to "ConstraintLayout", "Name" to "ConstraintLayout",
))
val direct = runCatching { Move("direct", "Avatar", "Name", tree) }
println("built=${direct.isSuccess}")
println(direct.exceptionOrNull()?.message)
val viaParent = listOf(
Move("up-path", "Avatar", "ConstraintLayout", tree),
Move("down-path", "ConstraintLayout", "Name", tree),
)
for (move in viaParent) {
move.describe()
}
}
built=false unrelated: Avatar and Name up-path: from=Avatar | to=ConstraintLayout -> up down-path: from=ConstraintLayout | to=Name -> down
Both ends of the up-path and down-path lines are written by hand: these lines are written to show the rule; they are not measurements. The finding that a sibling constraint is two moves rests on the bounding measurement below, whose ends come from nodes the model placed.
The direct move could not be set up, and the core’s own message unrelated: Avatar and Name was printed. The same information was carried through the parent in two moves: up and down. In the tree, a sibling constraint is the sum of these two moves.
The Bounding Measurement
The lesson’s direction is chosen from the move that makes a sibling constraint possible. The constraint and position moves in the downward direction were measured in the previous lesson; what is new in this lesson is the anchored sibling’s position going up to the parent. The measurement below splits a link into its moves and counts the two moves put in place of the direct path.
// Main.kt — the moves of a sibling constraint
fun main() {
val tree = Tree(mapOf(
"Screen" to "App", "ConstraintLayout" to "Screen",
"Avatar" to "ConstraintLayout", "Name" to "ConstraintLayout",
))
val placements = solveAnchors(listOf(Item("Avatar", 48, null, 0), Item("Name", 120, "Avatar", 8)))
val moves = listOf(
Move("report-position", placements[0].name, "ConstraintLayout", tree),
Move("give-position", "ConstraintLayout", placements[1].name, tree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
report-position: from=Avatar | to=ConstraintLayout -> up give-position: from=ConstraintLayout | to=Name -> down down: 1 up: 1 deep: 0 outside: 0
A sibling constraint is two moves: one up, one down. The down move is the position-giving already present in every parent-child relationship. What is specific to constraint-based layout is the up move, because this layout’s ability to anchor to a sibling works only with this move. The lesson’s direction: up.
LC3. The model splits a link into two moves. Whether the platform actually merges these two steps into a single solving pass was not measured.
Summary
- This lesson’s direction: up. What is new in this lesson is the position of the bound sibling going up to the parent.
- In a constraint-based layout an item’s position can be anchored to another item; in the tree this means the two items are siblings.
- A direct move from sibling to sibling cannot be set up; the core rejects it with an
unrelatederror. - A sibling constraint is two moves: the sibling’s position goes up to the parent, and from the parent down to the anchored item.
- A guideline sits at the same level as the items, and for it too the position information flows from the parent.
Next Step
Constraint-based layout assumes that all the items are visible together. In a list holding thousands of items this assumption breaks: for items that are not on screen, there is no node in the tree either. The next lesson measures the lazy list, in which only the visible items are created, and the scroll position’s travel out to its owner.
To keep your progress and take notes, Log in
My notes
Log in to take notes.