Lesson 17 / 17
The Classic View System
Measuring the comparison of the classic view hierarchy with the declarative tree, the outside direction of data going to a wrapped view, and the move crossing the boundary of the two trees; the closing table of the course's seventeen lessons.
Published Updated
In this lesson
- Measures that in the classic view hierarchy the screen's code reaches the deep view directly
- Shows that data going to a classic view placed in a component function is outside
- Distinguishes that declarative content placed inside a classic view also crosses the boundary with an outside move
- Explains that the same label goes down when local and outside when in a wrapped view
Contents
The previous lesson closed the course’s tree with a reader outside the tree by carrying the merged label to the screen reader. The tree of this course is the tree of the declarative interface. Many applications, though, carry screens written with the classic view system: the interface is declared in an xml file, the views are built from the file at runtime, and the screen’s code reaches a view by its id and writes values to it directly. The two worlds can stand together on the same screen. The question of this lesson is how the direction is named when a piece of data crosses the boundary of the two. The core file is the same as in the earlier lessons.
// 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 Classic Hierarchy
In the classic view system the screen is an xml file. The nesting in the file builds the view hierarchy: the outer item is the parent, the ones inside are the children. The screen’s code does not feed the hierarchy downward with parameters; it writes directly to the view it finds by id.
<!-- Platform code: a classic view layout, res/layout/screen.xml --> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <TextView android:id="@+id/title" android:layout_width="wrap_content" android:layout_height="wrap_content" /> <FrameLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <Button android:id="@+id/pay" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </FrameLayout> </LinearLayout>
The model takes the file’s indentation as a node list and builds a tree from it. The tree is the same Tree class; only the way it is built differs. The ends of the moves are not written by hand in this lesson’s measurements: they are picked from the built tree and the node list. deepest gives the deepest node in the list, firstAt gives the first node at a given indentation level; the screen node is read from the end of the tree’s ancestors chain.
// Inflate.kt — builds a tree from an indented xml node list and picks nodes (the model's calculation)
fun inflate(root: String, nodes: List<Pair<Int, String>>): Tree {
val parents = linkedMapOf<String, String>()
val path = mutableListOf(root)
for ((depth, name) in nodes) {
while (path.size > depth) path.removeAt(path.size - 1)
parents[name] = path.last()
path.add(name)
}
return Tree(parents)
}
fun deepest(nodes: List<Pair<Int, String>>): String = nodes.maxByOrNull { it.first }!!.second
fun firstAt(depth: Int, nodes: List<Pair<Int, String>>): String = nodes.first { it.first == depth }.second
The first measurement builds the same screen in two forms and carries the same two pieces of data: the title’s text and the button’s click event. On the classic side the screen’s code writes to the title, skipping the intermediate views. On the declarative side the text descends through the column.
// Main.kt — the same screen: the xml hierarchy and the declarative tree
fun main() {
val classicNodes = listOf(1 to "LinearLayout", 2 to "Title", 2 to "Frame", 3 to "PayButton")
val classic = inflate("Screen", classicNodes)
val button = deepest(classicNodes)
val screen = classic.ancestors(button).last()
val declarative = Tree(mapOf("Screen" to "App", "Column" to "Screen", "Title" to "Column", "PayButton" to "Column"))
val column = declarative.parent("Title")!!
val declarativeScreen = declarative.parent(column)!!
val classicMoves = listOf(
Move("write-title", screen, firstAt(2, classicNodes), classic),
Move("button-listener", button, screen, classic),
)
val declarativeMoves = listOf(
Move("title-path-1", declarativeScreen, column, declarative),
Move("title-path-2", column, "Title", declarative),
Move("button-event", "PayButton", declarativeScreen, declarative),
)
println("classic:")
classicMoves.forEach { it.describe() }
countDirections(classicMoves)
println("declarative:")
declarativeMoves.forEach { it.describe() }
countDirections(declarativeMoves)
}
classic: write-title: from=Screen | to=Title -> deep button-listener: from=PayButton | to=Screen -> up down: 0 up: 1 deep: 1 outside: 0 declarative: title-path-1: from=Screen | to=Column -> down title-path-2: from=Column | to=Title -> down button-event: from=PayButton | to=Screen -> up down: 2 up: 1 deep: 0 outside: 0
The classic screen’s code goes to the title with a single move, deep. On the declarative screen the same text descends with two down moves. The button’s event is up on both sides. The difference is in the channel: access by id skips the nodes in between, while the parameter chain passes through every node. The rule does not see the channel; it classifies the geometry of the two moves.
LC6. The model takes the xml file as an indented node list. Attributes, styles, the cost of looking up by id, and the pass in which views are measured and placed have no counterpart in the model.
A View Inside a Component Function
The way to put a classic view into a declarative tree is AndroidView. The component function asks for the view to be built, and then to be updated when it is built and when the state the update block reads changes.
// Platform code: putting a classic view inside a component function
@Composable
fun LabelHost(caption: String) {
AndroidView(
factory = { context -> TextView(context) },
update = { view -> view.text = caption },
)
}
LabelHost is a node in the tree. The TextView inside it is not: the declarative tree does not know it as a child; the platform’s view hierarchy owns it. In the model the wrapped view is built as a name that is not in the tree.
// Bridge.kt — the wrapped view stays outside the tree (the model's calculation)
class ClassicHost(val node: String, val view: String, tree: Tree) {
val viewInTree: Boolean = tree.contains(view)
}
The measurement follows the data to the wrapper, to the wrapped view and back from the view.
// Main.kt — data going to the wrapped view and returning from it
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Header" to "Screen", "LabelHost" to "Screen"))
val host = ClassicHost("LabelHost", "LabelView", tree)
println("wrapped view in tree: ${host.viewInTree}")
val moves = listOf(
Move("wrapper", "Screen", host.node, tree),
Move("update", host.node, host.view, tree),
Move("direct", "Screen", host.view, tree),
Move("touch", host.view, host.node, tree),
)
moves.forEach { it.describe() }
countDirections(moves)
}
wrapped view in tree: false wrapper: from=Screen | to=LabelHost -> down update: from=LabelHost | to=LabelView -> outside direct: from=Screen | to=LabelView -> outside touch: from=LabelView | to=LabelHost -> outside down: 1 up: 0 deep: 0 outside: 3
The data going to the wrapper is down: the parent gives to its child. Every move that reaches the wrapped view is outside, and the touch returning from the view is outside too. Going through the wrapper and going directly from the screen give the same result, because the path of a move toward an end that is not in the tree does not change the rule.
LC7. The model builds the wrapped view as a name that is not in the tree. The separation of factory and update, the recomposition in which the update runs, and the view’s lifecycle are not in the model.
Declarative Content Inside a View
The reverse direction is possible too: a ComposeView is placed inside a classic hierarchy and declarative content is created inside it.
// Platform code: putting declarative content into a classic view hierarchy
val composeView = ComposeView(context).apply {
setContent {
Panel { Label(text = "Cart") }
}
}
frame.addView(composeView)
In this case there are two trees. In the classic hierarchy ComposeView is a leaf. The Panel and Label inside it belong to a second, declarative tree. The measurement builds the two trees separately and classifies the move crossing the boundary from both sides.
// Main.kt — the boundary of the two trees
fun main() {
val classicNodes = listOf(1 to "LinearLayout", 2 to "Frame", 3 to "ComposeView")
val classic = inflate("Screen", classicNodes)
val host = deepest(classicNodes)
val screen = classic.ancestors(host).last()
val inner = Tree(mapOf("Label" to "Panel"))
val panel = inner.parent("Label")!!
val moves = listOf(
Move("layout-data", screen, host, classic),
Move("boundary-classic", host, panel, classic),
Move("boundary-declarative", host, panel, inner),
Move("inside", panel, "Label", inner),
)
moves.forEach { it.describe() }
countDirections(moves)
}
layout-data: from=Screen | to=ComposeView -> deep boundary-classic: from=ComposeView | to=Panel -> outside boundary-declarative: from=ComposeView | to=Panel -> outside inside: from=Panel | to=Label -> down down: 1 up: 0 deep: 1 outside: 2
In the classic tree, data going from the screen to ComposeView is deep. The move crossing beyond the boundary, however, is outside whichever tree it is looked at from: the source or the target is not in the tree being looked at. An in-tree move exists only from Panel to Label, that is, down inside the second tree.
LC8. The model builds the two trees as two separate Trees. ComposeView’s lifecycle being bound to its owner, the boundary of the recomposition scope, and how the two trees are integrated in a single window were not measured.
The Bounding Measurement
The lesson’s direction comes from the name outside being given when an end is not in the tree. The rule does not see whether an item is classic or declarative; it sees which tree it is in. Let the same label be built in two forms: with a local component function and with a view wrapped by AndroidView. The wrapped view’s own internal hierarchy can be measured as a separate tree.
// Main.kt — the same label in two forms
fun main() {
val tree = Tree(mapOf("Screen" to "App", "Header" to "Screen", "Label" to "Screen", "LabelHost" to "Screen"))
val wrappedNodes = listOf(1 to "Glyphs")
val wrapped = inflate("LabelView", wrappedNodes)
val glyphs = deepest(wrappedNodes)
val labelView = wrapped.parent(glyphs)!!
val screen = tree.parent("Label")!!
val moves = listOf(
Move("local-label", screen, "Label", tree),
Move("wrapped-label", screen, labelView, tree),
Move("wrapped-inside", labelView, glyphs, wrapped),
)
moves.forEach { it.describe() }
countDirections(moves)
}
local-label: from=Screen | to=Label -> down wrapped-label: from=Screen | to=LabelView -> outside wrapped-inside: from=LabelView | to=Glyphs -> down down: 2 up: 0 deep: 0 outside: 1
The same label goes down when local and outside when in a wrapped view. There is a hierarchy inside the wrapped view too, and it is down in its own tree; but the declarative tree cannot see this hierarchy. The lesson’s direction: outside.
This distinction is not to be confused with two other questions. The question of M31/K02 is how the platform meets a request; what is asked and what is given are not compared here. The question of M30/K04 is whether a screen agrees with the model; the screen’s agreement is not asked anywhere here. Neither is made a category or a measure name: the measure is, from start to finish, the direction of the data in the tree.
LC9. The model builds the wrapped view’s internal structure with a single child. The number of children of a real view, and the redraw and measurement order inside it, were not measured.
Summary
- This lesson’s direction: outside. The wrapped view is an end that is not in the tree, and every move with an end outside the tree comes out outside even when the measurement is built from nodes picked out of the
inflatereturn. - The classic view hierarchy is built from an
xmlfile; the screen’s code reaches the deep view directly by its id, and this move is deep. - A classic view placed in a component function is not in the declarative tree; every move to it and back from it is outside.
- Declarative content placed inside a classic hierarchy is a second tree; the move crossing the boundary is outside from both sides.
- The same label goes down when local and outside when in a wrapped view; the rule sees tree membership, not the item type.
Course Wrap-Up
The course measured seventeen lessons with a single question: in which direction does this data travel in the UI tree? Each lesson’s measured move, direction and bounding measurement are below. The direction cells are taken from the source lesson’s summary.
| Lesson | Measured move | Direction | Bounding measurement |
|---|---|---|---|
| Composable Functions | parameter and provider value | down | same provider: down to a direct child, deep to a grandchild |
| State and Remembering | value from the owner to the reader | down | local state enters no move |
| State Hoisting | event to the owner, value to the reader | up | lifted to App: down 3, up 2 |
| Recomposition | the change reaching the reader | deep | parameter two down, deferred read a single deep |
| Side Effects | request from the effect to the service | outside | inner write up, the response outside too |
| Immutability and Stability | change going to the owner | up | two channels same direction, observation different |
| Basic Layouts | constraint, size, position | down | size up, constraint and position down |
| Modifiers | constraint, order of the chain | down | when the order changes the constraint is deep |
| Constraint-Based Layout | sibling constraint | up | the sibling move has no relative, splits into two moves |
| Lazy Lists | scroll position | up | data going to an invisible row is outside |
| Prebuilt UI Components | content color | deep | down to a direct child, deep when wrapped |
| Scaffold Structure | padding | down | captured state skips the scaffold, deep |
| Navigation and Routes | argument before navigation | outside | down once in the tree, result back outside |
| Theme and Typography | theme from the root to the deep | deep | subtree theme down to a direct child |
| Animations | per-frame intermediate value | outside | target down, finish up |
| Accessibility | labels merging into the ancestor | up | merged label outside to the reader |
| The Classic View System | data going to a wrapped view | outside | same label down when local, outside when wrapped |
The distribution of the seventeen lessons: 5 down, 5 up, 3 deep, 4 outside. The core’s 3/2/1/2 distribution is a separate count: the core counts the eight moves in the opening lesson, while the table counts the directions of seventeen lessons; the two are not the same thing.
The next course (M31/K04) takes layered architecture as its subject. This course’s measuring axis does not continue there; K04 asks its own question itself.
To keep your progress and take notes, Log in
My notes
Log in to take notes.