Lesson 11 / 17
Prebuilt UI Components
Measuring which direction the color a prebuilt component such as a button gives its content travels in the tree, and how wrapping the content in a layout changes that direction.
Published Updated
In this lesson
- Measures that a prebuilt button gives its content the color in the deep direction
- Shows that wrapping the content in a layout turns the color's direction from down to deep
- Reads on the tree how far the item receiving the color is from the provider
- Explains how a prebuilt UI component's content slot builds its child tree
Contents
A design system offers the parts that are needed often ready-made: button, card, text field, switch, checkbox. A developer who uses these parts does not rewrite the color, the corner rounding or the touch behavior. In return there is something to ask: where do the icon and text the developer puts inside a button get their color? If the button’s background is dark, the text inside it must be light; this decision is made somewhere and reaches the content along some path.
The question on this lesson’s measuring axis is the direction of that path. The button supplies a color to its content. If the content is directly under the button, the color goes down one level. If the content is wrapped in a layout, the color goes down skipping the intervening node. The direction carries different names in these two cases.
// 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 Button and the Content Slot
A prebuilt button takes a click function and a content slot. The slot lets the caller decide what will sit inside the button: just a text, just an icon, or both. The button places this slot into its own child tree and supplies the content color that the items in the slot will read.
// Platform code: a prebuilt button and its content slot
@Composable
fun SaveButton(onSave: () -> Unit) {
Button(onClick = onSave) {
Row {
Icon(Icons.Default.Check, contentDescription = null)
Text("Save")
}
}
}
The Row here is written inside the slot, so it is a child of the button; Icon and Text are children of the Row. The button supplies the content color through a composition local (LocalContentColor). Icon and Text read the color from this local instead of taking it as a parameter. The details of the local are in the theme lesson; this lesson measures only the direction of the color’s move.
The model captures this platform rule; the model is not the platform itself. The helper file below builds the button’s tree in two forms: the content directly under the button, or inside a Row. It also contains a small rule that picks the content color from the button’s background color, and it says which items read the color.
// Button.kt — the button tree and the content color (the model's calculation)
fun buttonTree(content: List<String>, wrapped: Boolean): Tree {
val parentOf = mutableMapOf("Screen" to "App", "Button" to "Screen")
val holder = if (wrapped) "Row" else "Button"
if (wrapped) {
parentOf["Row"] = "Button"
}
for (child in content) {
parentOf[child] = holder
}
return Tree(parentOf)
}
fun contentColorFor(container: String): String =
if (container == "dark") "light" else "dark"
fun colorReaders(content: List<String>): List<String> =
content.filter { it.startsWith("Icon") || it.startsWith("Text") }
Wrapped Content
In the first measurement the content is wrapped in a Row. The button carries a dark background; the content color is chosen as light, and it goes from the button to every item that reads the color.
// Main.kt — content wrapped in a Row
fun main() {
val content = listOf("Icon", "Text")
val tree = buttonTree(content, wrapped = true)
println("content color: ${contentColorFor("dark")}")
val moves = colorReaders(content).map { Move("color-${it.lowercase()}", "Button", it, tree) }
for (move in moves) {
move.describe()
}
countDirections(moves)
}
content color: light color-icon: from=Button | to=Icon -> deep color-text: from=Button | to=Text -> deep down: 0 up: 0 deep: 2 outside: 0
Both moves are deep. The color left the button; because the target’s parent is the Row and not the button, the move did not arrive at a direct child but descended skipping a node. The Row that steps in does nothing with the color: it neither reads it nor changes it. The color passes by it.
LC1. The model takes the button’s background color as one of two values and picks the content color from that pair. The platform’s real color calculation, the contrast ratio and the faded color of a disabled button are not in the model.
Unwrapped Content
In the second measurement the same two items are directly under the button. The button gives the same color, and the same items read the same color. The only thing that changes is the absence of the Row in between.
// Main.kt — content directly underneath
fun main() {
val content = listOf("Icon", "Text")
val tree = buttonTree(content, wrapped = false)
val moves = colorReaders(content).map { Move("color-${it.lowercase()}", "Button", it, tree) }
for (move in moves) {
move.describe()
}
countDirections(moves)
}
color-icon: from=Button | to=Icon -> down color-text: from=Button | to=Text -> down down: 2 up: 0 deep: 0 outside: 0
This time both moves are down. The color is the same, the source is the same, the target is the same; the direction changed depending on the presence of the node in between. Because the direction of a move is not a property of the data itself, when reading code it is necessary to say “this color goes down in this tree” instead of “this color goes down”.
LC2. The model compares only two tree forms. The button being wrapped several levels, the wrapper supplying its own local value, or the content being taken out of the slot were not measured in the model.
The Bounding Measurement
The lesson’s direction can be seen by putting the moves in the two trees side by side. The measurement below contains four moves: the background color from the screen to the button, then the content color from the button to the icon for the two wrapping forms.
// Main.kt — the two trees side by side
fun main() {
val content = listOf("Icon", "Text")
val wrappedTree = buttonTree(content, wrapped = true)
val plainTree = buttonTree(content, wrapped = false)
val moves = listOf(
Move("background-color", "Screen", "Button", plainTree),
Move("wrapped-icon", "Button", "Icon", wrappedTree),
Move("wrapped-text", "Button", "Text", wrappedTree),
Move("unwrapped-icon", "Button", "Icon", plainTree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
background-color: from=Screen | to=Button -> down wrapped-icon: from=Button | to=Icon -> deep wrapped-text: from=Button | to=Text -> deep unwrapped-icon: from=Button | to=Icon -> down down: 2 up: 0 deep: 2 outside: 0
Both ends of all four lines in this block are written by hand: these lines are written to show the two trees side by side; they are not measurements. The lesson’s deep direction rests on the earlier measurement, whose ends come from the colorReaders function.
Two down, two deep. The background color going from the screen to the button goes to a direct child: down. The content color going to the same item is deep if there is a layout in between, down if there is not. This is the interesting thing about prebuilt UI components: the button gives its content the color, but it does not know at what depth the content stands. Whatever the caller writes into the slot, the color reaches it. The deep direction is the kind of move that makes this possible.
So arranging the content of a prebuilt UI component is safe for the color; wrapping the content in a Row or a Box does not cut the color. As long as the color is supplied by an ancestor along the tree, it reaches the deep item. The lesson’s direction: deep.
LC3. The model assumes that only the items named Icon and Text read the color. What a custom item that does its own drawing must do to read the color was not measured in this lesson.
Summary
- This lesson’s direction: deep. When a layout sits between the button and its content, the color skips the node in between.
- A prebuilt UI component offers shared behavior and appearance ready-made; the caller decides the content through a slot.
- The button gives its content the color; if the content is directly under the button, the color goes down.
- If the content is wrapped in a layout, the same color goes deep, skipping the node in between.
- The direction is a property of the tree, not of the data: the same color carries two different directions in two trees.
Next Step
The button gave its content a color; the scaffold gives its content padding. The next lesson measures the direction of the padding the scaffold, which holds the screen’s top bar, bottom bar and content together, gives its content.
To keep your progress and take notes, Log in
My notes
Log in to take notes.