Lesson 05 / 19
The Three-Tree Model
The framework keeps the same interface in three separate trees: the widget tree of recipes, the living element tree, and the render object tree that does the layout. The lesson explains what each tree does and measures why the layout result belongs only to the last tree.
Published Updated
In this lesson
- Tell apart the job of the widget, element, and render object trees
- Explain which tree is rebuilt and which is kept when the state changes
- Measure that a kept node can receive a different width when its constraint changes
Contents
In the previous lesson we saw that the recipe is rebuilt when the state changes. But if the objects on screen were created from scratch every time, a scroll position or the cursor in a text field would be lost at every rebuild. This is why the framework keeps the same interface in three trees, and each tree’s lifetime runs at a different speed.
The Widget Tree
The first tree is the tree of the widgets you write. This tree consists of immutable recipes and is produced from scratch at every rebuild. It is cheap, because it answers only the question “what do I want”: let there be a padding on screen, and inside it a centered logo.
The core of the lesson is again the first lesson’s sizing.dart file; the model is the counterpart of this tree.
// sizing.dart — classifies who decides a widget's width
const sizings = ['forced', 'chosen', 'clamped', 'stretched'];
class Limit {
final int min;
final int? max; // null: unbounded
const Limit(this.min, this.max);
bool get tight => max == min;
Limit loosen() => Limit(0, max);
Limit deflate(int by) => Limit(_floor(min - by), max == null ? null : _floor(max! - by));
String bounds() => '$min..${max ?? 'inf'}';
}
int _floor(int value) => value < 0 ? 0 : value;
class Part {
final String kind;
final String name;
final int? width; // null: as wide as it can get
final List<Part> children;
final int factor;
final bool tightFlex;
const Part(this.kind, this.name, this.width, this.children,
{this.factor = 0, this.tightFlex = true});
}
Part leaf(String name, int width) => Part('leaf', name, width, const []);
Part fill(String name) => Part('leaf', name, null, const []);
Part pad(String name, int padding, Part child) => Part('pad', name, padding, [child]);
Part center(String name, Part child) => Part('center', name, null, [child]);
Part fixed(String name, int width, Part child) => Part('fixed', name, width, [child]);
Part column(String name, List<Part> children) => Part('column', name, null, children);
Part row(String name, List<Part> children) => Part('row', name, null, children);
Part flex(String name, int factor, Part child, {bool tight = true}) =>
Part('flex', name, null, [child], factor: factor, tightFlex: tight);
class Sizing {
final String sizing;
final String path;
final int width;
final Limit limit;
const Sizing(this.sizing, this.path, this.width, this.limit);
}
Sizing classify(String path, int? desire, Limit limit) {
if (limit.tight) return Sizing('forced', path, limit.min, limit);
if (desire == null) {
return limit.max == null
? Sizing('clamped', path, limit.min, limit)
: Sizing('stretched', path, limit.max!, limit);
}
if (desire < limit.min) return Sizing('clamped', path, limit.min, limit);
if (limit.max != null && desire > limit.max!) {
return Sizing('clamped', path, limit.max!, limit);
}
return Sizing('chosen', path, desire, limit);
}
int layout(Part part, Limit limit, List<Sizing> log, [String path = '']) {
final here = '$path/${part.name}';
final below = <Sizing>[];
if (part.kind == 'fixed') {
final own = classify(here, part.width, limit);
layout(part.children.first, Limit(own.width, own.width), below, here);
log..add(own)..addAll(below);
return own.width;
}
int? desire;
switch (part.kind) {
case 'leaf':
desire = part.width;
case 'pad':
final space = 2 * part.width!;
desire = layout(part.children.first, limit.deflate(space), below, here) + space;
case 'center':
final inner = layout(part.children.first, limit.loosen(), below, here);
desire = limit.max == null ? inner : null;
case 'flex':
desire = layout(part.children.first, limit, below, here);
case 'column':
var widest = 0;
for (final child in part.children) {
final width = layout(child, limit.loosen(), below, here);
if (width > widest) widest = width;
}
desire = widest;
case 'row':
final logs = [for (final _ in part.children) <Sizing>[]];
final bounded = limit.max != null;
var used = 0;
var factors = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind == 'flex' && bounded) {
factors += child.factor;
} else {
used += layout(child, const Limit(0, null), logs[i], here);
}
}
final left = bounded ? _floor(limit.max! - used) : 0;
var given = 0;
var seen = 0;
for (var i = 0; i < part.children.length; i++) {
final child = part.children[i];
if (child.kind != 'flex' || !bounded) continue;
seen += child.factor;
final share = (seen == factors ? left : left * seen ~/ factors) - given;
given += share;
final room = child.tightFlex ? Limit(share, share) : Limit(0, share);
used += layout(child, room, logs[i], here);
}
for (final entries in logs) {
below.addAll(entries);
}
desire = bounded ? null : used;
}
final own = classify(here, desire, limit);
log..add(own)..addAll(below);
return own.width;
}
List<Sizing> measure(Part root, int screen) {
final log = <Sizing>[];
layout(root, Limit(screen, screen), log);
return log;
}
void printSizes(List<Sizing> log) {
for (final entry in log) {
print('${entry.sizing}: ${entry.path} width=${entry.width} constraint=${entry.limit.bounds()}');
}
}
void countSizings(List<Sizing> log) {
for (final name in sizings) {
print('$name: ${log.where((entry) => entry.sizing == name).length}');
}
print('parent: ${log.where((entry) => entry.sizing != 'chosen').length}');
}
The call pad('Screen', 16, center('Center', fixed('Logo', 96, leaf('Image', 200)))) in the model is a widget tree. The nodes carry only a name, a kind, and a request; they carry no position or lasting state.
DC1. The model’s tree is a single tree. The three trees in the framework are not built separately in the model; the work of the third tree, which is to pass the constraint down and the size up, is the measure call.
The Element Tree
The second tree is the element tree. When the framework sees a recipe for the first time, it creates an element that corresponds to it. The element holds that recipe’s place in the tree and lives on: state objects are attached to elements. At a rebuild, the framework compares the new recipe with the old element. If the type and the key are the same, the element is kept and receives the new recipe; if they differ, the element is discarded and a new one is created.
This comparison explains why state survives. The state object of a stateful widget is attached to the element; as long as the element is kept, the state is kept. The expanded value of the search box in the previous lesson keeps living because the box’s element lives.
The Render Object Tree
The third tree is the render object tree. The objects that compute the size, position the node, and paint it on screen are here. The flow in which the constraint goes down and the size comes up works in this tree. The piece below shows how a render object does the layout; it is outside the model and is not measured.
// Platform code: render object
class RenderLogo extends RenderBox {
@override
void performLayout() {
size = constraints.constrain(const Size(96, 96));
}
}
performLayout is where the object takes the constraint and picks its size. constraints.constrain squeezes the request into the constraint; if the request is inside the range, it stays as it is. The model’s classify rule simplifies this behavior.
The three trees share the work like this: the widget says “what it wants”, the element carries the information “who is who, whose state is whose”, and the render object answers the question “how much space does it take”. The tree that determines the width is always the last one.
The Same Node, a Different Constraint
Now we measure the same tree. center loosens the constraint, while fixed gives its child a tight width.
// main.dart
import 'sizing.dart';
Part logoTree(int imageWidth) {
return pad('Screen', 16, center('Center', fixed('Logo', 96, leaf('Image', imageWidth))));
}
void main() {
printSizes(measure(logoTree(200), 360));
}
forced: /Screen width=360 constraint=360..360 forced: /Screen/Center width=328 constraint=328..328 chosen: /Screen/Center/Logo width=96 constraint=0..328 forced: /Screen/Center/Logo/Image width=96 constraint=96..96
Center receives a tight constraint of 328 from its parent and loosens it to 0..328 for Logo. Logo requests 96 and, since that is inside the range, chooses 96. Image inside Logo requests 200, but fixed gives it 96..96 and its width is forced.
Now we compare two things. First the recipe changes: the Image request falls from 200 to 120 and the paths stay the same, meaning the element tree is kept. Then the recipe stays the same and the screen falls from 360 to 320.
// main.dart
import 'sizing.dart';
Part logoTree(int imageWidth) {
return pad('Screen', 16, center('Center', fixed('Logo', 96, leaf('Image', imageWidth))));
}
void main() {
final first = measure(logoTree(200), 360);
final newRecipe = measure(logoTree(120), 360);
final newScreen = measure(logoTree(200), 320);
final samePaths = [for (final e in first) e.path].join(',') ==
[for (final e in newRecipe) e.path].join(',');
print('kept paths: $samePaths');
print('Image, new recipe: ${first[3].width} -> ${newRecipe[3].width}');
print('Center, new screen: ${first[1].width} -> ${newScreen[1].width}');
print('Logo, new screen: ${first[2].width} -> ${newScreen[2].width}');
}
kept paths: true Image, new recipe: 96 -> 96 Center, new screen: 328 -> 288 Logo, new screen: 96 -> 96
When the recipe changes, the nodes’ paths stay the same, meaning the framework keeps the elements. Image has lowered its request to 120, but since fixed still forces 96, its width does not change. When the screen changes, the tree is the same and yet Center falls from 328 to 288: a kept node receives a different width when its constraint changes. Logo is 96 in both.
In the React Native course, what an update does to a node in the view tree is asked: was it kept, updated, mounted, or unmounted. That fate is a comparison of two trees. Sizing, on the other hand, is the layout of a single tree and can be asked without knowing the result of that comparison. In the output above we know that Center was kept; yet its width changes with the screen. Being kept does not guarantee the width.
DC2. An element being kept means only that its state and identity are kept. It does not mean that a node will keep the width from the previous measurement; the width is determined at every measurement by the constraint of that moment.
The Bounding Measurement
The focus node is /Screen/Center/Logo. We measure the same tree at two screen widths.
// main.dart
import 'sizing.dart';
Part logoTree() {
return pad('Screen', 16, center('Center', fixed('Logo', 96, leaf('Image', 200))));
}
void main() {
for (final width in [480, 320]) {
print('screen: $width');
printSizes(measure(logoTree(), width));
}
}
screen: 480 forced: /Screen width=480 constraint=480..480 forced: /Screen/Center width=448 constraint=448..448 chosen: /Screen/Center/Logo width=96 constraint=0..448 forced: /Screen/Center/Logo/Image width=96 constraint=96..96 screen: 320 forced: /Screen width=320 constraint=320..320 forced: /Screen/Center width=288 constraint=288..288 chosen: /Screen/Center/Logo width=96 constraint=0..288 forced: /Screen/Center/Logo/Image width=96 constraint=96..96
This lesson’s sizing: chosen. /Screen/Center/Logo requests 96; because Center loosens the constraint, the range is 0..448 or 0..288, and 96 is inside it. The node itself chooses the width. Even when the screen changes, Logo stays at 96, while Center gets 448 and 288. Image inside it is forced in this case: fixed gives it its width.
Summary
- This lesson’s sizing: chosen.
/Screen/Center/Logorequests 96 in a loosened range and chooses 96 at both screen widths. - The framework keeps the same interface in three trees: the widget tree of immutable recipes, the element tree that holds the recipe’s place, and the render object tree that does the layout.
- At a rebuild the recipe arrives new; if the type and key are the same, the element is kept, and the state is kept with it.
- The width is always determined by the render object tree; being kept does not guarantee the width.
- In the same tree
Centergets 328, 448, and 288 whileLogostays at 96: a kept node can receive a different width when its constraint changes.
Next Step
In the render object tree, most of the widgets that pass the constraint on are layout widgets. The Layout Widgets lesson takes up column, row, and flexible children, and measures who gives the width to a widget inside a flexible child.
To keep your progress and take notes, Log in
My notes
Log in to take notes.