Lesson 14 / 17
Theme and Typography
Measuring how a theme provided at the root descends to the deep text in the tree, how the closest provider changes when a subtree provides its own theme, and how the direction changes with it.
Published Updated
In this lesson
- Measures that a theme provided at the root travels in the deep direction to the deep text
- Builds a model that picks the closest provider among the ancestors in the tree
- Shows that a subtree providing its own theme changes the direction of the move
- Explains why the theme value is not carried as a parameter
Contents
An application’s colors, font sizes and corner roundings are chosen in one place and used on every screen. Giving these values to every component function as parameters is possible, but as the tree gets deeper the parameter chain gets longer, and every function in between carries a parameter only to pass it on. Design systems solve this differently: the value is provided at one point of the tree and everyone below reads it.
This lesson measures the direction of that solution. The theme is provided at the root and descends to the deep text; no function in between carries it. When a subtree provides its own theme, the closest provider changes for the reader, and so does the node the move leaves from.
// 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 Theme Descends from a Provider
The theme is provided by a wrapper function: this function sets up the values containing the color scheme, the typography and the shapes for the content below it. A text below reaches these values through the theme object. Under the access there is a mechanism called a composition local: a local takes a value at one node of the tree and every function below that node can read it.
// Platform code: a theme provider and a text reading the theme
@Composable
fun AppScreen() {
MaterialTheme(colorScheme = lightColorScheme()) {
Header {
Text(
text = "Orders",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleLarge,
)
}
}
}
Text does not take the color and the text style from AppScreen as parameters; it reads them from the theme. The Header in between knows nothing about the theme and does not pass it on. The local carries the value to those below on its own.
The model captures this platform rule; the model is not the platform itself. The helper file does two things: it builds a screen’s tree and it picks the closest theme provider for a reader among its ancestors.
// Theme.kt — the tree and the closest provider (the model's calculation)
fun themeTree(): Tree = Tree(
mapOf(
"Screen" to "App",
"Header" to "Screen",
"Title" to "Header",
"Panel" to "Screen",
"Label" to "Panel",
)
)
fun closestTheme(tree: Tree, providers: List<String>, reader: String): String =
tree.ancestors(reader).first { it in providers }
The ancestors list is ordered from the reader toward the root; the first matching provider is the closest one.
From the Root to the Deep
In the first measurement only the screen root provides the theme. There are two readers: the title text inside Header and the label inside Panel. Both take the theme from the closest provider, that is, from the root.
// Main.kt — theme moves with a single provider
fun main() {
val tree = themeTree()
val providers = listOf("Screen")
val moves = listOf("Title", "Label").map { reader ->
Move("theme-$reader", closestTheme(tree, providers, reader), reader, tree)
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
theme-Title: from=Screen | to=Title -> deep theme-Label: from=Screen | to=Label -> deep down: 0 up: 0 deep: 2 outside: 0
Both moves are deep. The theme left Screen and reached the text two levels below, skipping the nodes in between. No node in between knew the theme.
LC1. The model takes the providers as a list of names and does not carry the theme’s content. Which color or which text style arrives is not in the model; only which node the value goes from and to is measured.
A Subtree Provides Its Own Theme
In the second measurement Panel provides its own theme; say it is a dark panel. Nothing changes for Title, but for Label the closest provider is now Panel.
// Main.kt — while Panel provides its own theme
fun main() {
val tree = themeTree()
val providers = listOf("Screen", "Panel")
val moves = listOf("Title", "Label").map { reader ->
Move("theme-$reader", closestTheme(tree, providers, reader), reader, tree)
}
for (move in moves) {
move.describe()
}
countDirections(moves)
}
theme-Title: from=Screen | to=Title -> deep theme-Label: from=Panel | to=Label -> down down: 1 up: 0 deep: 1 outside: 0
For Title the move stayed the same: deep. For Label the starting point moved from the root to Panel, and since Label is a direct child of the panel, the move became down. The same reader, the same theme object; but when the provider changed, the direction changed. Label no longer gets the root’s theme by any path: the provider that stepped in shadows it.
LC2. The model assumes that a reader reads from a single provider. Changing only part of a value on the panel (for example only the color) and leaving the rest to the root is not in the model.
The Bounding Measurement
The lesson’s direction comes from the root giving the theme to the deep item; the panel providing for itself narrows this. The measurement below contains three moves: the theme the root gives the title, the theme the panel gives the label, and where the panel itself gets its theme.
// Main.kt — three moves while the panel provides its own theme
fun main() {
val tree = themeTree()
val providers = listOf("Screen", "Panel")
val moves = listOf(
Move("title", closestTheme(tree, providers, "Title"), "Title", tree),
Move("label", closestTheme(tree, providers, "Label"), "Label", tree),
Move("panel-source", "Screen", "Panel", tree),
)
for (move in moves) {
move.describe()
}
countDirections(moves)
}
title: from=Screen | to=Title -> deep label: from=Panel | to=Label -> down panel-source: from=Screen | to=Panel -> down down: 2 up: 0 deep: 1 outside: 0
Both ends of the panel-source line are written by hand: this line is written to show the rule; it is not a measurement. The provider end of the title and label lines, which count as measurements, comes from the closestTheme function: deep 1, down 1.
One deep, two down. The panel deriving the theme from the root and the panel giving it to the label both go to a direct child. The only deep move is the title’s theme, which skips the Header node in between; this is the real job of the local mechanism. Giving a theme to a direct child would not need a local: a parameter would be enough. The local gains meaning when the number of nodes in between is unknown or changes. The lesson’s direction: deep.
LC3. The model does not measure what happens when the theme changes. The functions that read a provider’s value rerunning when it changes, and the cost of that, were not covered in this lesson.
Summary
- This lesson’s direction: deep. The theme provided at the root reaches the title by skipping the
Headernode in between. - The theme is provided at one point of the tree; every function below it can read it.
- A theme provided at the root reaches the deep text, skipping the nodes in between: deep.
- The reader’s provider is the closest provider among its ancestors.
- If a subtree provides its own theme, the root is shadowed for the readers in that tree; a value going to a direct child goes down.
Next Step
The theme distributes values from a fixed point in the tree. An animation, on the other hand, is a value changing over time, and the change is driven by a clock outside the tree. The next lesson measures where the intermediate value coming from the frame clock arrives from in the tree, and in which direction the target value travels.
To keep your progress and take notes, Log in
My notes
Log in to take notes.