Lesson 04 / 19
Stateless and Stateful Widgets
A stateless widget gives its recipe once; a stateful widget carries its own state in a separate object. The lesson measures that the recipe is rebuilt when the state changes and who gives a search box its width after a relayout.
Published Updated
In this lesson
- Explain the difference between a stateless and a stateful widget through where the state lives
- Show that the recipe is rebuilt when the state changes and that the new recipe gives a new measurement
- Measure that the same search box is chosen when closed and stretched when open
Contents
A widget is an immutable recipe. So what changes when a search box opens and closes, or when a counter increases? Since the recipe does not change, whatever changes must live somewhere else. That place is the state, and widgets split into two kinds according to where they keep it.
The Stateless Widget
A stateless widget is built only from the parameters it receives. With the same parameters it gives the same recipe on every call, and it remembers nothing on its own. A title label, an icon, and the text of a row are like this. The core of the lesson is again the first lesson’s sizing.dart file.
// 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}');
}
In the model we can write a stateless widget as an ordinary function that takes parameters and returns a tree. The function below produces the recipe for a search box; if the box is open, its width spreads over the empty space, and if it is closed, it reports a fixed request.
DC1. In the model, state is a field of a class, and the change is made with an ordinary method such as toggle. The framework’s state-notification pattern (setState) is not measured; it is shown as a SOURCE piece.
// main.dart
import 'sizing.dart';
class SearchModel {
bool expanded = false;
void toggle() {
expanded = !expanded;
}
}
Part buildSearch(bool expanded) {
final search = expanded ? fill('Search') : leaf('Search', 120);
return pad('Screen', 16, column('Body', [search]));
}
void main() {
final model = SearchModel();
print('closed');
printSizes(measure(buildSearch(model.expanded), 360));
model.toggle();
print('open');
printSizes(measure(buildSearch(model.expanded), 360));
}
closed forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 chosen: /Screen/Body/Search width=120 constraint=0..328 open forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 stretched: /Screen/Body/Search width=328 constraint=0..328
buildSearch is stateless: if the expanded value is the same, every call gives the same tree. The state lives in SearchModel and is outside the function. When the box is closed, Search requests 120 and receives 120 under a loose constraint. When open, there is no request, the upper bound is finite, and Search takes all 328.
The Stateful Widget
A stateful widget consists of two objects: the widget that carries the immutable recipe, and the state object that carries the state and keeps living in the tree. Each time the framework calls the state object’s build method, it gets a new recipe. To change the state, setState is called; this call announces that the state has changed and directs the framework to call build again.
// Platform code: stateful widget
class SearchBox extends StatefulWidget {
const SearchBox({super.key});
@override
State<SearchBox> createState() => _SearchBoxState();
}
class _SearchBoxState extends State<SearchBox> {
bool expanded = false;
void toggle() {
setState(() {
expanded = !expanded;
});
}
@override
Widget build(BuildContext context) {
return expanded ? const TextField() : const SizedBox(width: 120);
}
}
In this piece the recipe (SearchBox) never changes; the expanded value lives in the state object. When toggle is called, build returns a new subtree. Its counterpart in the model is the pair of SearchModel and buildSearch.
What Changes When the Recipe Changes
When the state changes, what is rebuilt is the recipe. In the model, when a state that does not affect size changes, for example the hint text inside the box, the structure and requests of the tree stay the same. In that case the measurement stays the same too.
// main.dart
import 'sizing.dart';
Part buildSearch(bool expanded, String hint) {
final search = expanded ? fill('Search') : leaf('Search', 120);
return pad('Screen', 16, column('Body', [search]));
}
void main() {
for (final hint in ['Search', 'Search lessons']) {
print('hint: $hint');
printSizes(measure(buildSearch(true, hint), 360));
}
}
hint: Search forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 stretched: /Screen/Body/Search width=328 constraint=0..328 hint: Search lessons forced: /Screen width=360 constraint=360..360 forced: /Screen/Body width=328 constraint=328..328 stretched: /Screen/Body/Search width=328 constraint=0..328
hint is given to the function but does not enter the tree; the output of the two calls is identical. A state change triggers a rebuild, but if the rebuilt recipe reports the same request, none of the widths moves.
DC2. In the model, the hint text does not affect the width; in a real box, a very long hint text could change the box’s request. That case is not modeled here, because the model does not measure text.
In the UI Development with SwiftUI course, what triggers the recalculation of the interface when state changes is asked: the changed value, the views that depend on it, and the chain of recalculation. In this lesson the question is not that one. Here the question “why was it rebuilt” is not asked; in the rebuilt tree, the question is who gives the width. Even when the trigger is the same, the owner of the result can change: the box above follows its own request when closed and its parent’s constraint when open.
The Bounding Measurement
The focus node is the search box: /Screen/Body/Search. When open, the box reports no request, and the range above it is finite. We measure at two screen widths.
// main.dart
import 'sizing.dart';
Part buildSearch(bool expanded) {
final search = expanded ? fill('Search') : leaf('Search', 120);
return pad('Screen', 16, column('Body', [search]));
}
void main() {
for (final width in [480, 320]) {
print('screen: $width');
printSizes(measure(buildSearch(true), width));
}
}
screen: 480 forced: /Screen width=480 constraint=480..480 forced: /Screen/Body width=448 constraint=448..448 stretched: /Screen/Body/Search width=448 constraint=0..448 screen: 320 forced: /Screen width=320 constraint=320..320 forced: /Screen/Body width=288 constraint=288..288 stretched: /Screen/Body/Search width=288 constraint=0..288
This lesson’s sizing: stretched. When open, /Screen/Body/Search reports no request, the upper bound is 448 or 288, and the node fills it. The box’s width changes with the screen width; in its closed state it is 120, which moves on no screen. The two states of the same node correspond to two different owners of the result: when closed, its own request (chosen); when open, the parent’s upper bound (stretched).
Summary
- This lesson’s sizing: stretched. The open search box reports no request, fills the parent’s upper bound, and its width is 448 on a 480 screen and 288 on a 320 screen.
- A stateless widget is built only from parameters and gives the same recipe with the same parameters.
- A stateful widget carries its immutable recipe and its changing state in two separate objects; when the state changes, the recipe is rebuilt.
- If the rebuilt recipe reports the same request, the widths do not move; even when the hint text changes, the two measurements are the same.
- The same box is chosen when closed and stretched when open: when the state changes, the owner of the result can change too.
Next Step
When a widget’s state changes, the framework rebuilds the tree, but it does not create the objects on screen from scratch. The Three-Tree Model lesson explains how the recipe, the living object, and the layout on screen connect to one another as three separate trees.
To keep your progress and take notes, Log in
My notes
Log in to take notes.