Day 2: Widgets and the tree
Today is about the one idea everything else in Flutter rests on: the UI is a tree of
immutable widget objects, rebuilt from scratch by build() methods whenever something they
depend on changes. You read the root widget, the theme it installs, and the leaderboard, the
simplest real screen in the app, from route to pixel. Then you look at one stateful widget,
the permissions sheet, to see where mutable state lives when a widget needs it.
Before you start
Section titled “Before you start”Day 1 covered the Dart you need here:
const constructors, named parameters, final fields, library privacy with _, and
package: imports. You also saw runApp(const ProviderScope(child: FootraceApp())) in
app/lib/main.dart:15-20. Today starts at FootraceApp.
The Flutter mental model primer explains widgets, elements and render objects at more length than fits here. Read it after today if the three-tree idea below does not click.
The root widget: app.dart
Section titled “The root widget: app.dart”Open app/lib/app/app.dart. The whole file:
class FootraceApp extends ConsumerWidget { const FootraceApp({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { // Keeps the socket connected for as long as the user is signed in. ref.watch(socketLifecycleProvider); return MaterialApp.router( title: 'Footrace', theme: footraceTheme(), routerConfig: ref.watch(routerProvider), ); }}(app/lib/app/app.dart:9-22). Four things to see.
A widget is a description, not a view. FootraceApp has no fields to mutate, no
view property, no lifecycle callbacks. It has a constructor and a build method that
returns other widgets. Flutter calls build, compares the result to what it built last
time, and updates the real UI underneath. If you know React, build() is a render function
and this is a function component. It is nothing like viewDidLoad or onCreate: it runs
whenever an input changes, possibly many times a second, so it must be cheap and must not
start work. Side effects go elsewhere (Day 5 shows where).
Three trees, briefly. Widgets are throwaway configuration objects. Flutter keeps a
parallel tree of elements, long-lived, one per widget position, which hold state and know
their parent. Elements own render objects, which do layout and painting. When you rebuild,
new widgets are created and the existing elements are updated with them. UIKit has one tree
(UIViews) that you mutate; Flutter has a tree you rebuild and two it reconciles for you.
The three widget base classes you will see everywhere:
| Base class | Has | You know it as |
|---|---|---|
StatelessWidget |
build(context) |
a React function component without hooks |
StatefulWidget + State<T> |
a separate, long-lived State object with build, initState, dispose, setState |
a React class component; a view controller that owns a few fields |
ConsumerWidget (Riverpod) |
build(context, ref) |
a function component that reads from a store via ref.watch |
FootraceApp is a ConsumerWidget, so its build also gets a WidgetRef. Each
ref.watch(...) subscribes the widget to a provider and rebuilds it when the provider
changes. ref.watch(socketLifecycleProvider) (line 15) is there only for its side effect:
watching the provider keeps it alive, and the provider keeps the Phoenix socket connected.
Day 7 opens it.
super.key (line 10) forwards an optional key parameter to the base class. Keys tell
Flutter which old element a new widget corresponds to when siblings move around; you will
see explicit ones in the leaderboard.
MaterialApp.router (lines 16-20) is the app’s frame: it installs the theme, localisation,
the navigator and the router. routerConfig comes from routerProvider, which is
Day 4.
The theme: theme.dart
Section titled “The theme: theme.dart”Open app/lib/app/theme.dart. footraceTheme() (line 12) is a plain top-level function
that returns a ThemeData. Material widgets read their colours, fonts and shapes from that
object, so setting it once here styles every FilledButton, AppBar and TextField in the
app.
It starts with a ColorScheme (lines 13-37): Material’s fixed set of colour roles
(primary, onPrimary, surface, error, …) mapped to Footrace’s tokens. primary is
FrColors.accent, the orange; onSurface is FrColors.ink. A Material widget never says
“orange”; it says “primary”, and this table decides.
Then the ThemeData itself (lines 42-247): global settings first (fontFamily,
textTheme, scaffoldBackgroundColor), then one sub-theme per component. Look at the
filled button:
filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( backgroundColor: FrColors.accent, foregroundColor: FrColors.onAccent, // ... minimumSize: const Size.fromHeight(52), // ... ).copyWith( overlayColor: WidgetStateProperty.resolveWith( (states) => states.contains(WidgetState.pressed) ? FrColors.accentPressed : null, ), ),),(app/lib/app/theme.dart:77-94, trimmed). WidgetStateProperty.resolveWith is a function
from the control’s current states (pressed, hovered, disabled, selected) to a value. That is
an honest match for UIKit’s setTitleColor(_:for: .highlighted) or an Android
ColorStateList selector, written as a closure. Size.fromHeight(52) means “52 tall, as wide
as you are allowed”, which is why every filled button in the app spans the screen.
Two lines at the top deserve a note. export '../design/tokens.dart'; and
export '../design/typography.dart'; (lines 6-7) re-export those files, so importing
theme.dart also gives you FrColors, FrSpacing and friends, like an index.ts that
re-exports. And extensions: const [FrTheme.light] (line 51) attaches Footrace’s own
theme object to the Material one. Day 3 is about that.
How a widget finds the theme. MaterialApp puts a Theme widget above every route.
Any widget below it calls Theme.of(context), which walks up the element tree from
context to the nearest Theme and returns its data. It also registers a dependency, so if
the theme changes the widget rebuilds. This is React context (useContext), and it is the
reason build takes a BuildContext: the context is the widget’s element, its position in
the tree. You cannot ask for the theme without saying where you are standing.
A screen end to end: the leaderboard
Section titled “A screen end to end: the leaderboard”The leaderboard is a list of runners with a row of filter chips above it. It is reached from
the leaderboard icon button (tooltip “Leaderboard”) in the Race tab’s app bar: onPressed: () => context.push(AppRoutes.leaderboard)
(app/lib/features/race_tab/race_tab.dart:51), and the router maps that path to
const LeaderboardScreen() (app/lib/app/router.dart:86-89).
The screen and its inputs
Section titled “The screen and its inputs”Open app/lib/features/leaderboard/leaderboard_screen.dart.
class LeaderboardScreen extends ConsumerWidget { const LeaderboardScreen({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { final slug = ref.watch(leaderboardFilterProvider); final async = ref.watch(leaderboardProvider(slug ?? '')); final types = ref.watch(raceTypesProvider).valueOrNull ?? const <RaceType>[]; final myId = ref.watch(authProvider).valueOrNull?.user?.id;
return Scaffold( appBar: AppBar(title: const Text('Leaderboard')), body: Column( children: [ _Filter(types: types), Expanded( child: async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => MessageState(/* ... */), data: (page) => _List(page: page, myId: myId, slug: slug), ), ), ], ), ); }}(app/lib/features/leaderboard/leaderboard_screen.dart:22-56, error branch trimmed.)
The first four lines of build (27-31) gather inputs: the selected distance, the page of
standings for it, the list of race types for the chips, and your user id. Each ref.watch
is a subscription. Pick another chip and leaderboardFilterProvider changes, build runs
again, slug is new, and leaderboardProvider(slug) fetches that distance. You never call
“reload”. Where the data comes from is Day 5
and Day 6; today treat the providers as
inputs.
async.when(loading:, error:, data:) (lines 39-50) is how the app renders anything that
loads. AsyncValue is a sealed type with three cases, and when makes you handle all three:
the Kotlin when over a sealed class, with a closure per case.
Scaffold (line 33) is the screen frame: app bar, body, optional bottom bar and floating
button. It is the closest thing to a UIViewController’s root view with a navigation bar
attached, except it is just another widget.
Layout: Column, Expanded and constraints
Section titled “Layout: Column, Expanded and constraints”Flutter layout is one rule, applied recursively: constraints go down, sizes go up, the
parent sets the position. A parent tells each child “you may be between this min and max
width and height”; the child picks a size inside that; the parent places it. There is no
Auto Layout solver and no LayoutParams negotiation. It is closer to CSS flexbox done in a
single pass.
Column (line 35) lays children out vertically. It gives non-flexible children unbounded
height (“as tall as you like”) and then sizes itself. That is a problem for a list, which
would like to be infinitely tall. So the list goes inside Expanded (line 38), which says
“give this child whatever height is left after the others”. Remove the Expanded and Flutter
throws the most famous error in the framework, “Vertical viewport was given unbounded
height”. Expanded is flex: 1 in flexbox, or a weight="1" child in an Android
LinearLayout.
The filter row solves the same problem the other way. A horizontal ListView inside a
Column has a bounded width but unbounded height, so _Filter gives it a fixed box:
return SizedBox( height: 60, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric( horizontal: FrSpacing.gutter, vertical: FrSpacing.md, ), children: [ _Chip(label: 'All distances', slug: null, selected: selected == null), for (final type in types) _Chip( label: formatter.distance(type.meters), // ... ), ], ),);(app/lib/features/leaderboard/leaderboard_screen.dart:70-92, trimmed.) Here padding is a
parameter of ListView; elsewhere it is a Padding widget wrapping its child, as in
_Chip (line 108). Note the collection for from Day 1 building one chip per race
type, and formatter.distance from units.dart producing the chip labels.
Small private widgets
Section titled “Small private widgets”The file defines five private widgets: _Filter, _Chip, _List, _Row, _Rank. Splitting
a screen into small widget classes is the normal style, not over-engineering: each one is a
rebuild boundary, and the underscore keeps them out of every other file.
_List (lines 120-189) has the interesting logic:
final mine = page.rowFor(myId);final myIndex = mine == null ? -1 : page.entries.indexOf(mine);// Pin my row only when it is far enough down to be off screen anyway.final pinned = mine != null && myIndex >= 8;
return Column( children: [ Expanded( child: RefreshIndicator( onRefresh: refresh, child: ListView.separated( // ... itemCount: page.entries.length, separatorBuilder: (_, __) => const SizedBox(height: FrSpacing.listGap), itemBuilder: (context, index) => _Row( entry: page.entries[index], isMe: page.entries[index].userId == myId, ), ), ), ), if (pinned) Container( // ... child: _Row(entry: mine, isMe: true), ), ],);(app/lib/features/leaderboard/leaderboard_screen.dart:143-187, trimmed.)
ListView.separatedwith anitemBuilderbuilds rows lazily as they scroll into view: thetableView(_:cellForRowAt:)orRecyclerView.Adapter.onBindViewHolderof Flutter, minus the cell reuse bookkeeping.RefreshIndicatorwraps the list to add pull-to-refresh;onRefreshtakes a function returning aFuture, and the spinner stays until it completes.refreshis a local function defined at lines 130-131.(_, __)names two parameters the closure ignores.if (pinned)is a collectionifinsidechildren: your own row appears a second time, pinned at the bottom, only when you are ninth or lower.Containeris a convenience widget that combines padding, decoration and size. The decoration reads colours fromcontext.fr, which is Day 3.
_Row (lines 191-227) returns an FrListRow from the design system and wires the tap:
onTap: isMe ? null : () => context.push(AppRoutes.user(entry.userId)) (line 217). A null
callback is how Flutter says “disabled”. The ValueKey('leaderboard-row-...') at line 205
gives the row a stable identity, and the widget tests find rows by it.
_Rank (lines 230-258) is a plain StatelessWidget: it needs no providers, so it does not
take a ref. A 44 by 44 circle with the rank number, accent-filled when it is yours.
A stateful widget: the permissions sheet
Section titled “A stateful widget: the permissions sheet”Most screens in the app are ConsumerWidgets because their state lives in providers. When
a widget needs state of its own, or lifecycle hooks, it becomes stateful. Open
app/lib/features/permissions/permissions_screen.dart.
class PermissionsSheet extends ConsumerStatefulWidget { // ... @override ConsumerState<PermissionsSheet> createState() => _PermissionsSheetState();}
class _PermissionsSheetState extends ConsumerState<PermissionsSheet> with WidgetsBindingObserver { @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); }
@override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); }
@override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { ref.read(permissionGateProvider.notifier).refresh(); } }(app/lib/features/permissions/permissions_screen.dart:9-56, trimmed.) The widget class is
still immutable; it only knows how to create its State. The State object is long-lived,
survives rebuilds, and has the lifecycle: initState once when it enters the tree,
dispose once when it leaves. That pair is your viewDidLoad/deinit or
onCreate/onDestroy; build is still the render function. with WidgetsBindingObserver
mixes in an interface with default methods (a Dart mixin, close to a Swift protocol
extension or a Kotlin interface with bodies), and didChangeAppLifecycleState is
applicationDidBecomeActive or onResume: coming back from the system settings re-checks
the permissions.
Things to notice in build (lines 58-121):
Theme.of(context).textTheme(line 61) andTheme.of(context).colorScheme.secondary(line 88): theInheritedWidgetlookup from earlier.- Navigation cannot happen in the middle of a build. When permissions are granted, the sheet
schedules its own close for after the frame:
addPostFrameCallbackat lines 64-66, with anif (mounted)check because the widget may be gone by then. Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch)(lines 83-86): as short as its children, and each child as wide as the column.- The copy comes from a
switchexpression over an enum (lines 123-172). Every case returns a value and the compiler checks the enum is covered, like a Kotlinwhenused as an expression.
The static show (lines 13-20) opens the sheet with showModalBottomSheet<bool>, which
returns a Future<bool?> that completes when the sheet is popped with a value, and ?? false
turns “dismissed” into a no. ensureGranted (lines 24-29) checks context.mounted after
an await: after any async gap, the widget that owned context may have left the tree.
You will see that check after almost every await in a widget.
One thing this file does not do: it uses literal numbers (EdgeInsets.fromLTRB(24, 0, 24, 24)
at line 71, SizedBox(height: 16) at line 89) where the leaderboard uses FrSpacing tokens.
CLAUDE.md says widgets should not use literal sizes. Treat the leaderboard as the house
style and this file as older code that predates it.
At the bottom, PermissionBanner (lines 191-224) shows a Row with Expanded around the
Text (lines 206-220). Same rule as the column, sideways: without Expanded, a long message
would overflow instead of wrapping, and you would see the yellow-and-black overflow stripes.
Try it
Section titled “Try it”1. A device-free change with a test. The pinned-row rule is myIndex >= 8 at
app/lib/features/leaderboard/leaderboard_screen.dart:146. Read the two tests that cover it,
app/test/features/leaderboard/leaderboard_screen_test.dart:107-138: one puts you eleventh
(index 10), one second (index 1). Change the threshold to >= 1 and predict which test
fails. Then run:
flutter test test/features/leaderboard/leaderboard_screen_test.dart“a row near the top is not pinned twice” should fail: it expects Runner 2 (you) once and
now finds it twice, once in the list and once pinned. Revert.
2. Hot reload (needs an emulator or simulator). Start an Android emulator or iOS
simulator, then the backend (cd ../footrace && PORT=4001 mix phx.server), then the app:
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 --dart-define=DEV_TOOLS=trueOn an iOS simulator use http://localhost:4001. Sign in, open the leaderboard from the
icon in the Race tab’s app bar. Now edit _Rank (leaderboard_screen.dart:240-241) to
width: 56, height: 56, save, and press r in the terminal. The circles grow in under a
second and you stay on the same screen with the same filter selected: hot reload re-runs
build with the new code while keeping every State and provider. Then change the
AppBar title at line 34 and reload again. Finally press R (hot restart): the app
restarts from main() and the auth gate sends you through the splash. Changes to
initState, main() or a --dart-define need a restart, because hot reload does not
re-run code that already ran.
Without the backend the leaderboard shows the error card with a Retry button instead; the hot-reload part still works on that.
Check yourself
Section titled “Check yourself”- Why must
build()be cheap and free of side effects, and how is that different fromviewDidLoad? - What does
Theme.of(context)actually do, and why does it needcontext? - Remove the
Expandedaroundasync.when(...)inLeaderboardScreen. What happens, and why? - In
PermissionsSheet, which object survives a rebuild:PermissionsSheetor_PermissionsSheetState? Where doesinitStatelive? - Why does the sheet close itself inside
addPostFrameCallbackinstead of callingNavigator.of(context).pop(true)directly inbuild?
Answers
- Flutter calls
buildevery time a dependency changes, possibly many times a second, and may call it for reasons you do not control.viewDidLoadruns once per view controller. Work that should happen once goes ininitState, a provider, or a callback. - It walks up the element tree from the widget’s position (the
BuildContextis the element) to the nearestTheme, returns itsThemeData, and registers a dependency so the widget rebuilds if the theme changes. Withoutcontextthere is no position to start from. - A runtime layout error (“Vertical viewport was given unbounded height”).
Columngives non-flexible children unbounded height, and a scrolling list cannot size itself to infinity.Expandedgives it the remaining height as a tight constraint. _PermissionsSheetState. The widget is a new immutable object on every rebuild; theStateis created once bycreateStateand kept by the element.initStateanddisposeare on theState.- Navigating changes the tree, and you cannot change the tree while it is being built.
The post-frame callback runs after the current frame is done, and
mountedguards against the sheet having already gone.
Day 3: The design system and Widgetbook opens
context.fr, the tokens and the components the leaderboard is built from.