Lesson 01 / 19
Dart Fundamentals
A small Dart core that classifies who decides a widget's width. The lesson teaches Dart's types, null safety, and classes through that core and introduces the four sizings: forced, chosen, clamped, stretched.
Published Updated
In this lesson
- Read Dart's types, null safety, and class structures on a layout core
- Explain the layout contract; constraints go down, sizes go up, the parent sets the position
- Classify a widget's width as forced, chosen, clamped, or stretched
- Measure that the same tree gets different sizings at different screen widths
Contents
This course opens a UI framework with a single question: who determines this widget’s width? Here a widget is the piece of interface that takes up space on screen: a text, an image, a button, or a container that holds other widgets. The word keeps this meaning throughout the course; it does not mean a module or a service in software architecture.
Every widget receives a constraint from its parent: the smallest and largest width it may take. The widget may or may not have a request of its own. The combination of the constraint and the request produces the width, and the owner of the width is one of four parties. The names stay the same throughout the course; the output prints them in lowercase, and the prose uses the same words in bold.
- forced: the constraint is tight, meaning the smallest and largest width are equal. The widget’s request cannot change the result.
- chosen: the widget’s request stays inside the constraint, and the widget chooses the width.
- clamped: the request falls outside the constraint, or the constraint is unbounded and there is no request. The constraint cuts the width.
- stretched: there is no request, the constraint has an upper bound, and the widget grows up to that bound.
In every sizing except chosen, the width is determined not by the widget but by the constraint from above. The total of those three is called the parent count; the last line of the output counts it.
The Layout Contract
Three sentences say where the constraint comes from. Constraints go down: the parent gives the child the range it will accept. Sizes go up: the child picks a width inside that range and reports it to the parent. The parent sets the position: the parent, not the child, decides where the child will sit. This course deals only with the first two sentences, on the width axis.
DC1. The model builds the layout contract on the width axis only: each node receives a single min..max range and returns a single integer width. Height, position, and painting are not in the model; the model is not the framework itself.
The Core Model
The classifier is a single Dart file, sizing.dart. Throughout the course every lesson carries it as its first Dart block, and its content never changes; in later lessons, instead of rereading it, we look only at the trees added on top. This lesson opens the file line by line, because Dart’s basic constructs work together here.
// 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}');
}
Types and null safety. In the Limit class, the min field has type int and the max field has type int?. In Dart 3.13.5, a type does not accept null; if it does, it carries a ? at the end of its name. A null value for max means “no upper bound”, and bounds() writes it as inf. Giving a nullable value to a place that is not nullable is a compile error. An exclamation mark after a value tells the compiler “this value is not null here”; the core uses it only where a null check has already been made. max ?? 'inf' picks the right-hand side if max is null.
Classes and immutability. The fields of the Limit and Part classes are final and their constructors are const, so the objects cannot change after they are built. loosen() and deflate() do not change the existing constraint; they return a new one. bool get tight => max == min; is a getter: it takes no parentheses when called and is read as limit.tight.
Parameters. The first four parameters of the Part constructor are positional. factor and tightFlex, inside the braces, are named parameters; they have default values and are given by name in the call: flex('Note', 1, fill('Text'), tight: false). Helper functions such as leaf, fill, and pad build the tree with short names. fill leaves the width request as null: “I grow as wide as I can get”.
Collections and expressions. [for (final _ in part.children) <Sizing>[]] builds an empty list for each child. log..add(own)..addAll(below) is a cascade call; each .. applies a new operation to the same object. ~/ is integer division. The case branches inside switch do not carry break; each branch ends in its own block.
Rule order
The classify function is the only place where sizing is decided, and it asks its questions in this order:
- If the constraint is tight, the result is forced.
- If there is no request: stretched if the upper bound is finite, clamped if it is unbounded.
- If the request is below the smallest width or above the upper bound, clamped.
- Otherwise, chosen.
DC2. The tight constraint is asked first, because if the constraint allows a single width, the widget’s request cannot change the result. This order is the model’s decision; it is possible to build the classification in another order, but the name in the output carries meaning only with this one.
The Opening Layout
The tree below is the course’s binding opening example. The root Screen leaves 16 of padding on every side and holds a Body column. The column has Title, which requests 120, Banner, which requests 400, and a row named Toolbar. The row has Back and Menu, which request 48 each, and between them Search with a flex factor of 1; Field inside Search requests no width.
// main.dart
import 'sizing.dart';
void main() {
final screen = pad('Screen', 16, column('Body', [
leaf('Title', 120),
leaf('Banner', 400),
row('Toolbar', [
leaf('Back', 48),
flex('Search', 1, fill('Field')),
leaf('Menu', 48),
]),
]));
final log = measure(screen, 360);
printSizes(log);
countSizings(log);
}
forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Title width=120 constraint=0..328 clamped: /Screen/Body/Banner width=328 constraint=0..328 stretched: /Screen/Body/Toolbar width=328 constraint=0..328 chosen: /Screen/Body/Toolbar/Back width=48 constraint=0..inf forced: /Screen/Body/Toolbar/Search width=232 constraint=232..232 forced: /Screen/Body/Toolbar/Search/Field width=232 constraint=232..232 chosen: /Screen/Body/Toolbar/Menu width=48 constraint=0..inf forced: 4 chosen: 3 clamped: 1 stretched: 1 parent: 6
The output applies the rules in order. Screen receives a tight constraint of 360, so it is forced. pad subtracts 16 from each side; a tight constraint of 328 goes down to Body, and Body is forced as well. The column loosens the constraint: the children receive 0..328. Title requests 120 and stays inside the range, so it is chosen. Banner requests 400 while the upper bound is 328; the constraint cuts its width and it is clamped. Toolbar requests no width, and since its upper bound is 328, it is stretched. The row gives the non-flexible children 0..inf; in this range Back and Menu choose 48. The remaining 328 - 48 - 48 = 232 goes down to Search, whose flex factor is 1, as a tight constraint, and Search and Field become forced.
The count gives the four sizings and the parent total: 4 forced, 3 chosen, 1 clamped, 1 stretched; the parent count is 4 + 1 + 1 = 6. One detail should be noted now: Search is a flexible child, but it is forced, not stretched. The stretched one is Toolbar. The two are not the same thing; the topic’s sixth lesson opens this distinction.
The Widget Class
In Dart, a widget is a class. The piece below shows the smallest widget, derived from the framework’s stateless widget class. The piece is not measured; the model does not run the framework’s code.
// Platform code: widget class
class TitleLabel extends StatelessWidget {
const TitleLabel({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(text);
}
}
The class derives from StatelessWidget (extends). required makes the named parameter mandatory; the widget cannot be built without text. The build method returns what the widget will show as another widget, here a Text. @override declares that it redefines a method of the superclass.
The class itself says nothing about width. build only describes what will be shown; the width is determined by where the widget stands in the tree and the constraint that comes down to it. This course’s job is to measure that second half.
The Absence of a Request
The int? in the core is not only a convenience; it carries the distinction between “no request” and “a request of zero”. The block below builds two leaves, prints their requests, and measures the two in a column.
// main.dart
import 'sizing.dart';
void main() {
final label = leaf('Label', 120);
final space = fill('Space');
print('Label request: ${label.width}');
print('Space request: ${space.width}');
final screen = pad('Screen', 16, column('Body', [label, space]));
printSizes(measure(screen, 360));
}
Label request: 120 Space request: null forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Label width=120 constraint=0..328 stretched: /Screen/Body/Space width=328 constraint=0..328
Label requests 120 and is chosen. Space requests nothing; because its request is null, with the constraint at 0..328 it grows up to the upper bound and is stretched. If null and 0 were the same thing, these two lines could not be told apart.
The Bounding Measurement
A principle is tested only by a measurement that could refute it. This time we measure the opening tree at two screen widths, and we filter and print only the focus node, /Screen/Body/Banner. The tree does not change; only the root constraint does. The last lines give the count at 480.
// main.dart
import 'sizing.dart';
Part buildScreen() {
return pad('Screen', 16, column('Body', [
leaf('Title', 120),
leaf('Banner', 400),
row('Toolbar', [
leaf('Back', 48),
flex('Search', 1, fill('Field')),
leaf('Menu', 48),
]),
]));
}
void main() {
for (final screenWidth in [480, 360]) {
print('screen=$screenWidth');
final log = measure(buildScreen(), screenWidth);
printSizes(log.where((entry) => entry.path == '/Screen/Body/Banner').toList());
}
print('count screen=480');
countSizings(measure(buildScreen(), 480));
}
screen=480 chosen: /Screen/Body/Banner width=400 constraint=0..448 screen=360 clamped: /Screen/Body/Banner width=328 constraint=0..328 count screen=480 forced: 4 chosen: 4 clamped: 0 stretched: 1 parent: 5
This lesson’s sizing: clamped. At a width of 480, Banner’s request of 400 stays inside the range 0..448, and Banner chooses the width. At 360 the same request of 400 does not fit in the range 0..328, and the constraint cuts the width. The widget, the tree, and the request are the same; the owner of the result changes only because the root constraint changes. The count at 480 confirms this: clamped is zero, and chosen rises to four.
DC3. Banner’s request of 400 is a ready-made number in the model. The width of a real image is often known only after it has loaded; the model treats it as a request known in advance. The case where the width arrives later is the subject of the next lesson.
Summary
- This lesson’s sizing: clamped.
/Screen/Body/Bannerrequests 400, the constraint gives0..328on a 360 screen, and the constraint cuts the width. - In Dart,
int?writes the possibility of “no value” into the type; givingnullto a non-nullable type is a compile error, and the core carries the “no request” case withint?. - The layout contract is three sentences: constraints go down, sizes go up, the parent sets the position.
- The owner of a width is one of four parties: forced, chosen, clamped, stretched. The opening tree has 4 forced, 3 chosen, 1 clamped, 1 stretched; the parent count is 6.
- The same tree gives a chosen result at 480 and a clamped result at 360: the owner of a width is not a property of the widget alone, but the combination of the widget and the constraint.
Next Step
Banner’s width was a ready-made number in this lesson. In real applications such a value often does not arrive right away. The Asynchronous Dart lesson builds the case where the width arrives later with Future and await, and measures which sizing the same widget gets before the value arrives and after.
To keep your progress and take notes, Log in
My notes
Log in to take notes.