Lesson 03 / 19
The Widget Concept
In an interface model where everything is a widget, padding is a widget too. The lesson measures that a widget is an immutable recipe and why a card, despite the same request, meets a different owner depending on its position.
Published Updated
In this lesson
- Explain that a widget is an immutable recipe and that padding, alignment, and size are widgets too
- Measure on a tree that constraints go down and sizes go up
- Show that the same widget is forced under a tight constraint and chosen under a loose one
Contents
The previous two lessons used the widget as a tree node: pad, column, and leaf calls nested inside one another and built a screen. This lesson says what that node is. In this interface model nearly everything you see on screen is a widget: text, image, button; but also padding, alignment, fixed size, and column. Widgets take other widgets as children, and the tree is formed from this nesting.
Nearly Everything Is a Widget
In many interface tools, padding is a property of a widget: a card has a margin or padding setting. Here, padding is a widget in its own right and wraps its child. Alignment and fixed size are the same. First comes the core of the lesson; this is the first lesson’s sizing.dart file, and it has not changed.
// 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 piece below puts a card inside a padding widget. The piece is not measured; the model does not run the framework’s code.
// Platform code: padding widget
Widget buildCard() {
return const Padding(
padding: EdgeInsets.all(16),
child: Card(child: Text('Summary')),
);
}
Padding wraps the card and leaves 16 units of space on every side; the card is Padding’s child parameter. In the model, this piece corresponds to the call pad('Screen', 16, leaf('Card', 200)).
DC1. The model builds padding, alignment, and fixed size as nodes (pad, center, fixed). It does not offer the framework’s whole widget list; the model is not the framework itself.
DC2. In the model, Card is only a leaf that requests a width. The card has no content, shadow, or corners; what remains is the one thing that can be measured, the request of 200 units.
Because padding is a widget, it can be stacked. The block below nests two paddings and shows how much each narrows the card’s constraint.
// main.dart
import 'sizing.dart';
void main() {
final tree = pad('Outer', 8, pad('Inner', 8, leaf('Card', 200)));
printSizes(measure(tree, 360));
}
forced: /Outer width=360 constraint=360..360 forced: /Outer/Inner width=344 constraint=344..344 forced: /Outer/Inner/Card width=328 constraint=328..328
Each pad subtracts its own value from both sides: 16 is taken from the tight constraint of 360 to give 344, and from that again to give 328. None of the widgets “computes” anything on its own; each takes the constraint, gives its child a modified version, and processes the child’s answer.
A Widget Is a Recipe
A widget object is not the thing standing on screen but its recipe. The object is built once and does not change: its fields are final and its constructor is const. If something is to change, a new widget is built and the old one is discarded. This choice has two consequences.
The first is that building a widget is cheap. Because the recipe does not carry the screen itself, rebuilding a screen’s recipe does not mean repainting the screen. The second is that the same widget object can stand in several places. The object carries no position information; the position is given by its place in the tree, and the constraint of that place comes down to it everywhere. The measurements below show this second consequence.
Constraints Down, Sizes Up
A tree has two flows, and they go in opposite directions. Constraints go down: the parent gives the child the range it will accept. Sizes go up: the child picks a width in that range and reports it to the parent. The parent takes this size, determines its own width accordingly, and positions the child.
// main.dart
import 'sizing.dart';
void main() {
final tree = pad('Screen', 16, center('Center', column('Body', [
leaf('A', 100),
leaf('B', 250),
])));
printSizes(measure(tree, 360));
}
forced: /Screen width=360 constraint=360..360 forced: /Screen/Center width=328 constraint=328..328 chosen: /Screen/Center/Body width=250 constraint=0..328 chosen: /Screen/Center/Body/A width=100 constraint=0..328 chosen: /Screen/Center/Body/B width=250 constraint=0..328
A tight constraint of 328 goes down from Screen to Center. Center loosens this constraint and gives Body 0..328; the same loose range reaches A and B too. The sizes that go up are these: A reports 100 and B reports 250; the column makes the widest of them, 250, its own width. The constraint has moved on by decreasing downward, and the size by growing upward.
In the UI Development with Jetpack Compose course, the question “which direction is this data going in the UI tree?” is asked, and the direction is the answer to the question. In this course the direction is fixed: the constraint goes down and the size goes up. Because the direction is the same in every tree, it does not tell the nodes apart; sizing does. Sizing classifies who determines the width at the end of those two flows. In the output above, Body is 250 wide because the widest of its children chose it; Center is 328 because its parent forced 328 on it.
The Bounding Measurement
The focus node is the card inside the padding: /Screen/Card. The card requests 200. Screen receives the tight constraint of 360; the padding narrows it by 16 on each side and passes it to the child so that it stays tight.
// main.dart
import 'sizing.dart';
void main() {
final tree = pad('Screen', 16, leaf('Card', 200));
printSizes(measure(tree, 360));
}
forced: /Screen width=360 constraint=360..360 forced: /Screen/Card width=328 constraint=328..328
This lesson’s sizing: forced. /Screen/Card requests 200 and receives 328..328. The constraint is tight, meaning the smallest and largest width are equal; the card’s request does not change the result. The card is 328 wide, not 200, and it is Screen and the padding that gave it this width.
Next we put the same card inside a column. The column loosens the constraint, and the card can state its own request for the first time.
// main.dart
import 'sizing.dart';
void main() {
final tree = pad('Screen', 16, column('Body', [leaf('Card', 200)]));
printSizes(measure(tree, 360));
}
forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Card width=200 constraint=0..328
The card is the same card, its request the same 200. The Body column receives the tight constraint of 328 but gives its child 0..328; in this range 200 is a valid choice, and Card chooses the width. The same widget, the same request, two different owners of the result: the position has determined the result.
Summary
- This lesson’s sizing: forced.
/Screen/Cardrequests 200, the padding passes the tight constraint on as 328..328, and the constraint determines the width. - In this interface model, padding, alignment, and fixed size are widgets; the tree is built by nesting widgets.
- A widget is an immutable recipe: its fields are
finaland its constructor isconst. The same object can stand in several places and receives that place’s constraint everywhere. - Constraints go down, sizes go up. Sizing does not classify the direction but the owner of the width at the end of these two flows.
- The same card is forced inside the padding and chosen inside the column: the result is determined not by the widget’s request but by its position.
Next Step
A widget is an immutable recipe; so how is something that changes on screen shown? The Stateless and Stateful Widgets lesson separates the widget whose recipe never changes from the widget that carries its own state, and measures who gives a search box its width when the state changes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.