flutter::study — for iOS & Android devs
score 0/0

A study companion · Flutter for people who already ship native apps

Stop mutating views. Describe them instead.

On iOS and Android you build a screen once, keep references to its views, and reach back in to change them. Flutter takes that habit away — and gives you something better in return. The whole framework turns on one idea, so let's start with it, using code you could have written last week.

CartView.swiftthe imperative habit
// iOS — you hold the views and poke at them
func updateBadge() {
    countLabel.text = "\(count)"
    badge.isHidden = count == 0
    if count > 0 {
        checkoutButton.isEnabled = true
        checkoutButton.alpha = 1.0
    } else {
        checkoutButton.isEnabled = false
        checkoutButton.alpha = 0.4
    }
}

Every place the data can change, you must remember to call something like this — and touch each view by hand. Forget one branch and the screen quietly lies about the data. Guess what Flutter does instead before you continue.

Part 1 · Foundations — §1 of 4

Describe, don't mutate

Objective

Understand the one difference that makes Flutter feel foreign at first — and why, once it clicks, most of your UI bugs stop being possible.

You have spent years building a mental model where a view exists, you keep a handle to it, and you change it over time. A UILabel or a TextView is an object that lives on screen; you set its .text and the pixels follow. This is imperative UI: a sequence of commands that mutate a long-lived tree of view objects.

Flutter is declarative. You write a function that, given the current data, returns a description of what the screen should look like right now. You never hold a description and edit it later — when the data changes, you throw the old description away and build a fresh one. The framework figures out the minimal set of real changes to make on screen.

Native views (imperative)

The view tree is the source of truth. Your data and your views can drift apart, and keeping them in sync is your job — the classic source of "the label still shows the old value".

Flutter (declarative)

Your data is the source of truth. The UI is a pure function of it: UI = build(state). The screen cannot drift, because it is re-derived from the data every time.

If you have written SwiftUI or Jetpack Compose, you already know this feeling — Flutter is the same family of idea, and everything you learned there transfers. If you have not, the adjustment is real but short. The single sentence to hold onto: you describe the UI as a function of your data, and you change the UI only by changing the data.

The habit to unlearn

Your fingers will want to find a widget and change it: "grab the label, set its text." That reflex will fight you for about a week. There is no label to grab. There is only the next call to build, which will produce a new Text with the new value. When you feel the urge to mutate a widget, change a variable and rebuild instead.

Learn more — "rebuild everything" sounds slow. Why isn't it?

Rebuilding does not mean repainting. Your build method returns cheap, immutable description objects (widgets) — allocating them is fast, like building a small struct. Flutter then compares that new description to the previous one and computes the minimal set of changes to the actual on-screen objects (the render tree). A Text whose string didn't change costs almost nothing; only the parts that genuinely differ are re-laid-out and repainted.

This is exactly the trick that makes React, SwiftUI and Compose fast too. You get the simplicity of "re-describe the whole screen" with the performance of "only touch what changed" — because those are two different trees, which is the subject of §3.

Check yourself

In a declarative framework like Flutter, how do you change what's on screen?

You change the data and rebuild. Widgets are immutable descriptions — there is nothing to reach into and mutate (A, D). And you don't repaint by hand (C); Flutter diffs the new description against the old and repaints only the difference.

Part 1 · Foundations — §2 of 4

Everything is a widget

Objective

See that Flutter has essentially one building block, and that UI is built by nesting it — composition, not inheritance and not layout files.

On native platforms you have a zoo of concepts: views, view controllers, layout constraints, storyboards or XML, and a separate world for padding and centring. Flutter collapses almost all of it into a single noun: the widget. A button is a widget. So is the text on it. So is the padding around it, the centre alignment, the row it sits in, and the whole screen.

Crucially, structure and styling that you think of as properties on other platforms are widgets here. You don't set a padding attribute — you wrap your widget in a Padding widget. You don't set an alignment flag — you wrap it in Center. UI is built by composition: small widgets nested inside bigger ones, forming a tree.

hello.darta tiny widget tree
Center(                         // centres its child
  child: Padding(               // adds space around its child
    padding: EdgeInsets.all(16),
    child: Text(                // draws a string
      'Hello, Flutter',
      style: TextStyle(fontSize: 24),
    ),
  ),
)

Read that as a tree: Center contains Padding contains Text. Each widget has exactly one job. On iOS or Android you might express "centred, padded text" with constraints or a layout file; in Flutter you express it by nesting three single-purpose widgets. This feels verbose at first and becomes strangely pleasant: everything is explicit and local.

A widget tree Center contains Padding, which contains Text. Each node is a single-purpose widget nested inside its parent. Center Padding Text 'Hello, Flutter' one job: centre the child one job: add 16px around it one job: paint a string nesting widgets = building UI
Composition over configuration. Instead of a widget with a hundred properties, Flutter gives you many small widgets you stack. The tree is your layout.

There are two flavours of widget, and the distinction runs through the whole framework: StatelessWidget (a description that never changes once built — an icon, a label, a static row) and StatefulWidget (a widget that keeps some mutable data and can rebuild itself when that data changes — a checkbox, a form field, an animating spinner). Modules 1 and 2 are dedicated to each; for now just know both exist.

Learn more — where do styling and theming live, then?

Some styling is a parameter you pass (like TextStyle above). App-wide styling — colours, fonts, component defaults — lives in a Theme, which is itself a widget near the top of the tree. Descendant widgets read from it with Theme.of(context). So even "the app's design system" is just a widget wrapping the rest of the tree. Module 10 covers this properly.

Check yourself

You want to add 16 logical pixels of space around a button. In Flutter you would…

Spacing is a widget, not a property. You compose a Padding around the button. Most buttons have no margin property (A), Flutter doesn't use constraint files (B), and you compose rather than subclass (D).

Which best describes how a Flutter screen is constructed?

Composition: a tree of small widgets. Flutter has no controllers of note (B), no inflated markup (C), and Flutter draws its own widgets rather than positioning platform controls (D) — which is why an app looks identical on both platforms.

Part 1 · Foundations — §3 of 4

The three trees

Objective

Learn the one piece of internal machinery worth knowing early: how Flutter can throw away descriptions on every build yet keep your state and stay fast.

Here is the apparent paradox. Widgets are immutable and get rebuilt constantly. Yet a text field keeps what you typed, a scroll list keeps its position, and an animation doesn't restart on every frame. If the widgets are thrown away, where does that memory live?

The answer is that "the UI" is not one tree but three, and they play different roles. You only ever write the first one directly.

Tree one

Widgets — the blueprint

Immutable, cheap, disposable. What your build methods return. Rebuilt freely. Think of a widget as a configuration: "a red text saying hello". It describes; it does not persist.

Tree two

Elements — the living tree

Long-lived. For each widget currently on screen there is one Element that holds its place in the tree, links to parent and children, and — for stateful widgets — owns the State object. This is what BuildContext actually is.

Tree three

Render objects — the pixels

Also long-lived. These do layout, painting and hit-testing. You rarely touch them directly. Flutter mutates these in place when a rebuild produces a different description.

The flow: you produce a new widget tree. Flutter walks it against the existing element tree, and for each position asks a cheap question — "is the new widget the same kind as the old one here?" If yes, it keeps the element (and its State, and its render object) and just updates them with the new configuration. If no, it tears that element down and builds a fresh one. Your typed text survives because its element survived, even though the widget describing it was replaced.

Three trees, side by side A widget tree of disposable blueprints maps to a persistent element tree, which owns state and drives a render-object tree that produces pixels. WIDGETS rebuilt each time ELEMENTS persist · own State RENDER layout & paint a widget updates its element… …which drives its render object
You write the blue tree. Flutter maintains the purple and amber ones for you. State lives with the element, which is why rebuilding disposable widgets never loses it.

You will not manipulate elements or render objects for a long time — possibly ever. But this model pays off the moment something surprises you: a form that mysteriously resets, an animation that jumps, a list item showing the wrong data after reordering. Every one of those is Flutter matching the wrong element to a widget. The fix is a key, and it will make sense because you know these trees exist (module 11).

Learn more — so what exactly is the context in build(context)?

BuildContext is the element. When Flutter calls your build method it hands you a handle to this widget's slot in the live element tree. That's why context can answer questions about location — "what's the theme here?", "what's the nearest Navigator above me?" — via calls like Theme.of(context). It's a pointer into tree two.

This also explains a classic beginner error: using a context to look up something that lives above a widget from inside that same widget's build, before it's mounted. The element isn't in the tree yet, so there's nothing to walk up to. We'll meet this concretely in module 4.

Check yourself

You type into a TextField, then an unrelated setState higher up rebuilds the screen. Why doesn't your text vanish?

The new widget is the same kind in the same position, so Flutter keeps the existing element and the State it owns, just updating it with the new config. Widgets don't store your text (B), and rebuilds don't skip widgets for those reasons (A, C).

Part 1 · Foundations — §4 of 4

Dart in a hurry

Objective

Pick up just enough Dart to read every snippet on this page, by mapping it onto the Swift or Kotlin you already know.

Dart will feel immediately familiar — it sits squarely between Swift and Kotlin. Curly braces, classes, async/await, sound null safety. You can read it on day one; here are the handful of things worth pointing at explicitly.

DartSwift / KotlinNote
var x = 3;varType inferred, reassignable.
final x = 3;let / valSet once. Your default.
const x = 3;static let-ishCompile-time constant. Matters a lot for widgets — see below.
String?String?Nullable. Non-nullable by default, like both.
x?.foo, x!sameNull-aware access; ! asserts non-null.
latelateinit (Kotlin)"I'll set this before first use, trust me."
Future<T>async fn / DeferredA value arriving later. await it.
Stream<T>AsyncSequence / FlowMany values over time.

Two Dart-specific things earn their keep. First, named parameters in braces. Most widget constructors take named arguments, often required ones — this is why widget code reads like a labelled form rather than a positional guessing game:

dartnamed & required parameters
class Badge {
  final String label;
  final int count;
  Badge({required this.label, this.count = 0});
}

// call site — order-free, self-documenting
Badge(label: 'Inbox', count: 12);
Badge(label: 'Drafts');   // count defaults to 0

Second, const constructors. If a widget's configuration is fully known at compile time, you can construct it with const. Flutter then knows that widget can never differ between builds and skips rebuilding its subtree entirely. Sprinkling const on your static widgets is the cheapest performance win in the framework — the analyzer will even nag you to add it.

No const

Icon(Icons.star)
// rebuilt & re-compared
// every single build

With const

const Icon(Icons.star)
// built once, reused,
// subtree rebuild skipped
Learn more — arrow functions, cascades, and trailing commas

=> is shorthand for a one-expression function body: int square(int x) => x * x;. You'll see it constantly for short callbacks like onPressed: () => print('tap').

The cascade .. calls several methods on the same object without repeating it: controller..forward()..repeat(). Handy but optional.

The trailing comma after the last argument isn't noise — Dart's formatter uses it to decide whether to keep a call on one line or expand it vertically, one argument per line. In deeply nested widget trees, always leave the trailing comma; it makes dart format lay the tree out readably. This is a real convention, not a matter of taste.

Check yourself

Why prefer const Text('Hi') over Text('Hi') when the text is fixed?

A compile-time-constant widget can't change between builds, so Flutter reuses the instance and skips rebuilding it — a free performance win. Non-const widgets compile fine (B); it has nothing to do with selection (A) or rendering pipeline (D).

Part 2 · Module 1 of 12  ·  gentle

Your first widget: StatelessWidget

Objective

Write a widget that renders purely from the data passed into it, and understand exactly when its build runs.

A StatelessWidget is a widget whose appearance depends only on the configuration it was given. Give it the same inputs and it always looks the same; it has no memory of its own. Think of a component that shows a user's name and avatar — hand it a User, it draws that user, done. Most of the widgets you write will be stateless.

greeting.darta stateless widget
class Greeting extends StatelessWidget {
  final String name;
  const Greeting({super.key, required this.name});

  @override
  Widget build(BuildContext context) {
    return Text('Hello, $name');
  }
}

// used like any other widget
const Greeting(name: 'Ada')

Three things to notice. The data (name) is final — a stateless widget's fields never change; to show a different name you build a new Greeting. The constructor is const, so this widget is cheap. And build is the one method that matters: it returns a description, and Flutter calls it whenever this widget is inserted or its parent hands it new configuration.

Compare it to a native custom view. There, you'd have an initialiser, outlets, and an update() you call by hand when the model changes. Here there is no update method and no outlets — just build, re-run for you. The mental weight of "did I refresh every view after the model changed?" is simply gone.

Don't do work in build

build can run many times per second (during animations, scrolling, parent rebuilds). It must be cheap and free of side effects: no network calls, no writing to disk, no starting timers. Think of it as "draw the current data", nothing more. Where do those things go? That's module 5.

Glossary

StatelessWidget
A widget with no mutable state; its look is a pure function of its constructor arguments.
build()
The method returning this widget's description. Called by the framework, never by you.
@override
An annotation confirming you're replacing a superclass method — Dart warns if you aren't.
super.key
Passes an optional identity key up to the base class. Ignore it until module 11.

Exercise

A price tag widget

Write a stateless PriceTag widget that takes a double price and a String currency, and renders a Text like "$12.50 USD". Make it const-constructible.

class PriceTag extends StatelessWidget {
  final double price;
  final String currency;
  const PriceTag({
    super.key,
    required this.price,
    required this.currency,
  });

  @override
  Widget build(BuildContext context) {
    return Text('\$${price.toStringAsFixed(2)} $currency');
  }
}

toStringAsFixed(2) forces two decimals. Everything is final and the constructor is const — textbook stateless widget.

Check yourself

When does Flutter call a StatelessWidget's build method?

It can run many times — on insertion and every time a parent rebuild hands it fresh config. Not once (A), never by your hand (C), and not on a timer (D). This is why build must stay cheap.

Part 2 · Module 2 of 12  ·  core

State that changes: StatefulWidget

Objective

Hold data that changes over time, mutate it correctly with setState, and understand why the widget and its State are two separate classes.

When a widget must remember something between builds — a counter, whether a switch is on, the text in a field — it becomes a StatefulWidget. This comes as a pair of classes, which trips everyone up for a minute, so let's be precise about why.

The StatefulWidget itself is still immutable and disposable, exactly like a stateless one — it's rebuilt constantly. The mutable data can't live there. So it lives in a separate, long-lived State object, which Flutter keeps attached to the element (tree two, from §3) across rebuilds. The widget is the blueprint; the State is the memory.

counter.dartthe two-class pair
class Counter extends StatefulWidget {
  const Counter({super.key});
  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;          // the mutable memory

  void _increment() {
    setState(() {        // tell Flutter state changed
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: _increment,
      child: Text('Count: $_count'),
    );
  }
}

The critical rule: mutate state only inside setState. Writing _count++ on its own changes the variable but never tells the framework to rebuild, so the screen freezes on the old value — the number-one beginner bug. setState does two things: it runs the closure (where you change the data) and it marks this element as needing a rebuild, so Flutter re-runs build before the next frame.

The setState loop A tap calls a handler, which calls setState to mutate data and mark the element dirty; Flutter reruns build and repaints the difference. user taps setState(() { mutate data ) build() re-runs new description diff & repaint only the delta
The full update cycle. You only ever write the amber box; Flutter does the rest.

Why the two-class split, again? Because the widget is thrown away and rebuilt, but your _count must survive that. Putting it in State, which Flutter pins to the durable element, is what lets the number persist while the Counter widget describing it is replaced on every frame.

Glossary

StatefulWidget
An immutable widget that spawns a companion State object to hold mutable data.
State<T>
The long-lived object holding mutable fields and the build method for a stateful widget.
createState()
Called once by Flutter to make the State object when the widget is first mounted.
setState()
Run a closure that mutates state, then flag this element to rebuild. The only sanctioned way to change state.

Exercise

A toggle

Turn a stateless "favourite" icon into a stateful one: tapping it flips between a filled and an outlined heart. Sketch the State class and its build.

class _FavState extends State<Fav> {
  bool _on = false;

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(_on ? Icons.favorite
                       : Icons.favorite_border),
      onPressed: () => setState(() => _on = !_on),
    );
  }
}

The ternary in build chooses the icon from the current state — no imperative "swap the image" call anywhere. Flip the bool, rebuild, done.

Check yourself

You write _count++; in a handler, without wrapping it in setState. What happens?

It compiles and the data does change — but nothing tells Flutter to rebuild, so build never re-runs and the UI is stale. It's not a compile error or crash (B, D); the data, not the screen, is what's correct (opposite of C).

Why is mutable state kept in a separate State object rather than on the StatefulWidget itself?

Widgets are disposable descriptions; the State is pinned to the durable element so your data survives rebuilds. Dart doesn't forbid mutable widget fields (B) — the convention exists precisely because widgets shouldn't hold changing data. It's not about sharing (A) or isolates (C).

Part 2 · Module 3 of 12  ·  the tricky one

Layout: Row, Column, and constraints

Objective

Learn Flutter's whole layout model in one sentence, then use it to place widgets — and to read the overflow error you will hit.

Flutter has no auto-layout, no constraint solver, no flexbox engine to reason about. Its layout is a single pass with one rule, repeated down and up the tree: constraints go down, sizes go up, and the parent sets position. Memorise that sentence; it explains every layout you'll ever build.

Concretely: a parent tells each child the minimum and maximum width and height it may take ("you may be 0–360 wide and 0–800 tall"). The child picks its own size within that, and tells the parent. The parent then decides where to put the child. A widget never chooses its own position and never sees anything outside its own constraints.

The two workhorses are Row (lay children out horizontally) and Column (vertically). They come from the same family as flexbox, with two alignment axes:

toolbar.darta row with alignment
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,  // along the row
  crossAxisAlignment: CrossAxisAlignment.center,    // across it
  children: [
    const Icon(Icons.menu),
    const Text('Inbox'),
    Icon(Icons.search),
  ],
)

When a child should absorb the leftover space, wrap it in Expanded (take all you can) or Flexible (take up to what you need). A Row with a fixed icon and an Expanded title is the bread and butter of app bars.

Now the rite of passage. Put a very long Text, or a fixed-width box, in a Row that isn't wide enough, and you meet this:

════════ Exception caught by rendering library ════════ The following assertion was thrown during layout: A RenderFlex overflowed by 137 pixels on the right. The overflowing RenderFlex has an orientation of Axis.horizontal. The edge of the RenderFlex is stuck; its children asked for more room than the parent's constraints allow. The relevant error-causing widget was: Row lib/toolbar.dart:12:14 Consider: Expanded, Flexible, or a scrolling parent.

Read it the way you now read a compiler error. A Row tried to place children wider than the width it was handed. The Row can't grow (its parent didn't allow it), so it reports the overflow rather than silently clipping. The fix is always one of: let a child flex (Expanded), let the content wrap or shrink, or make the parent scrollable. Overflow isn't a bug in Flutter — it's Flutter refusing to guess what you meant.

Learn more — the two "unbounded constraints" traps

Some parents impose unbounded constraints on an axis — a Column gives its children unlimited height, a horizontal ListView gives unlimited width. Put a widget that also wants to be "as big as allowed" (another ListView, an Expanded in the wrong place) inside one, and Flutter throws "unbounded constraints", because infinity isn't a size anything can render to.

The cure is to introduce a bound: wrap the inner widget in a SizedBox with a fixed height, or an Expanded, so it has an actual number to work with. When you hit this, ask "who, above me, forgot to say how big I'm allowed to be?" — that's where the fix goes.

Glossary

Constraints
Min/max width and height a parent hands a child. The child must size itself within them.
main / cross axis
For a Row, main is horizontal; for a Column, vertical. Cross is the other one.
Expanded
Wrap a child so it fills the free space along the main axis.
SizedBox
A box of an exact size — used for fixed dimensions or as fixed-size spacing.
RenderFlex overflow
A Row/Column whose children exceed its size. Solved with flex, wrapping, or scrolling.

Exercise

A safe list tile

Build a Row with a leading icon, a title that can be arbitrarily long, and a trailing chevron — such that a long title truncates with "…" instead of overflowing. Which widget wraps the title?

Row(
  children: [
    const Icon(Icons.folder),
    const SizedBox(width: 12),
    Expanded(                       // take the leftover width
      child: Text(
        title,
        overflow: TextOverflow.ellipsis,  // truncate with …
      ),
    ),
    const Icon(Icons.chevron_right),
  ],
)

Expanded gives the title a bounded width; ellipsis tells it to truncate rather than demand more room. Icons stay their natural size on the ends.

Check yourself

Flutter's layout algorithm, in one line, is:

That's the whole model, and a single pass. There's no global solver (A), widgets never see the screen or place themselves (B), and siblings don't talk to each other — only parent and child do (D).

You see "A RenderFlex overflowed by 24 pixels". The most likely fix is to…

Overflow means children want more space than the axis allows; give one a way to flex, wrap, or scroll. It's not a font issue (A), rebuilding doesn't change the sizes (C), and you can't catch a layout assertion into working layout (D).

Part 2 · Module 4 of 12  ·  core

BuildContext and reading down the tree

Objective

Use context to reach shared data that lives above a widget — theme, media size, navigator — and understand the .of(context) pattern.

From §3 you know context is a handle to this widget's spot in the element tree. Its main day-to-day use is looking upward: "what theme applies here?", "how tall is the screen?", "which navigator should I push onto?". Flutter answers by walking up the element tree from your context until it finds the nearest ancestor of the right kind. That's the SomeThing.of(context) idiom.

card.dartreading inherited data
Widget build(BuildContext context) {
  final theme = Theme.of(context);        // nearest Theme above
  final size  = MediaQuery.of(context).size; // screen metrics

  return Container(
    width: size.width * 0.9,
    color: theme.colorScheme.surface,
    child: Text('Adapts to theme & screen',
      style: theme.textTheme.titleMedium),
  );
}

This is Flutter's answer to dependency lookup: instead of threading the theme through every constructor, you put it once near the top (inside a MaterialApp, which you'll see in module 10) and any descendant reads it via context. The mechanism underneath is the InheritedWidget — a widget that exposes data efficiently to everything below it, and rebuilds only the widgets that actually read it when the data changes. You rarely write one by hand early on, but Theme, MediaQuery, and most state-management libraries are built on it.

The "no ancestor found" error

Calling SomeThing.of(context) with a context that sits above the thing you're looking for throws "No SomeThing found in context". The classic case: opening a dialog or snackbar using the context from the build that created the Scaffold, rather than a context below it. The fix is to use a context from a child widget — often by extracting the child into its own widget, so its context is genuinely beneath the ancestor.

Glossary

BuildContext
A reference to a widget's location in the element tree; the argument to build.
.of(context)
Idiom to fetch the nearest ancestor of a given type (Theme, MediaQuery, Navigator…).
InheritedWidget
A widget that shares data with its whole subtree efficiently; the engine behind .of.
MediaQuery
Ancestor exposing screen size, orientation, text-scale, safe-area insets, and more.

Check yourself

What does Theme.of(context) actually do under the hood?

It walks upward from your position via context to the closest Theme. That's why it's location-sensitive — a global singleton (B) or a full scan (C) wouldn't be. It reads an existing widget, it doesn't rebuild the theme (D).

Part 2 · Module 5 of 12  ·  core

The State lifecycle & handling input

Objective

Know where to start work (subscriptions, controllers, timers) and where to clean it up — the native viewDidLoad/onDestroy equivalents.

build is for describing, not for doing. So the side-effecting work — subscribing to a stream, creating an animation controller, starting a timer — belongs in the State lifecycle methods, which mirror the native ones you already use.

FlutteriOS / AndroidUse it to…
initState()viewDidLoad / onCreateSet up controllers, subscriptions. Runs once.
didChangeDependencies()React when an inherited dependency (theme, locale) changes.
build()viewWillLayoutSubviews-ishDescribe UI. Often. Keep it pure.
didUpdateWidget()The parent rebuilt with new config; reconcile against old.
dispose()deinit / onDestroyCancel subscriptions, dispose controllers. Runs once.

The pairing that matters most is initState/dispose. Anything you create in the first you must tear down in the second, or you leak. Text fields make this concrete: they use a TextEditingController, which you own.

search_field.dartcreate in initState, free in dispose
class _SearchState extends State<Search> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController();
  }

  @override
  void dispose() {
    _controller.dispose();   // or you leak the controller
    super.dispose();
  }

  @override
  Widget build(BuildContext context) =>
      TextField(controller: _controller);
}

Note late final: the controller is set exactly once, in initState, not at the declaration — because you want it created when the State mounts, not before. Also note that both initState and dispose call super; forgetting that is a common early slip.

Glossary

initState()
First method called on a State. Set up long-lived resources here.
dispose()
Last method called. Release everything you created in initState.
TextEditingController
Owns the text and selection of a field. You create and dispose it.
controller
General pattern: an object you own that drives a widget (text, scroll, animation, tabs).

Exercise

Spot the leak

A teammate subscribes to a location Stream in initState with _sub = stream.listen(...), but the widget causes battery drain long after it leaves the screen. What did they forget, and where does it go?

They never cancelled the subscription. It must be cancelled in dispose:

@override
void dispose() {
  _sub.cancel();      // stop listening when we leave
  super.dispose();
}

Every resource created in initState needs a matching teardown in dispose. Streams, controllers, timers, listeners — all of them.

Check yourself

Where should you create an AnimationController or subscribe to a stream?

initState runs once when the State mounts — the right home for setup — paired with dispose for teardown. In build you'd recreate it constantly (A). The widget's constructor runs on every rebuild and holds no mutable state (C); globals leak and don't scope to the screen (D).

Part 2 · Module 6 of 12  ·  core

Lists that scroll: ListView.builder

Objective

Render long, efficient, scrolling lists the way Flutter intends — the direct analogue of UITableView / RecyclerView.

You could build a list by dumping every item into a Column inside a SingleChildScrollView. For ten items, fine. For ten thousand, you'd build ten thousand widgets up front and run out of memory. The native answer is cell recycling; Flutter's is lazy building.

ListView.builder takes an item count and a builder callback, and only calls the builder for items near the viewport — building them as they scroll into view and letting off-screen ones go. No delegates, no reuse identifiers, no dequeue. Just a function from index to widget.

message_list.dartlazy, recycled by construction
ListView.builder(
  itemCount: messages.length,
  itemBuilder: (BuildContext context, int i) {
    final m = messages[i];
    return ListTile(
      leading: CircleAvatar(child: Text(m.initials)),
      title: Text(m.sender),
      subtitle: Text(m.preview),
      onTap: () => _open(m),
    );
  },
)

Compare the mental overhead. A UITableView needs a data source, cell registration, and careful reuse to avoid showing stale content in recycled cells. Here the builder is a pure function of the index — there's no recycled cell to accidentally show the wrong data, because each visible item is described fresh. The performance is equivalent; the footguns are gone.

Learn more — separators, slivers, and infinite scroll

ListView.separated adds a builder for the dividers between items. For headers that pin, parallax effects, or grids and lists that share one scroll view, you graduate to slivers (CustomScrollView with SliverList, SliverAppBar). Slivers are the low-level scrollable protocol; ListView is a friendly wrapper over one.

Infinite scroll is just: watch a ScrollController, and when you near the bottom, fetch the next page and setState with the longer list. No special API needed.

Glossary

ListView.builder
A lazily-built scrolling list; calls its builder only for items near the screen.
itemBuilder
Callback from an index to the widget for that row.
ListTile
A ready-made row with leading/title/subtitle/trailing slots and a tap handler.
Sliver
A lower-level scrollable region; the building block of advanced scroll effects.

Check yourself

Why prefer ListView.builder over a Column of all items in a scroll view for a 5,000-row list?

Lazy building keeps memory and startup cost proportional to what's visible, not to the whole list. A Column can go in a scroll view (A) — it's just eager. There's no disk caching (B), and taps work anywhere (C).

Part 2 · Module 7 of 12  ·  core

Navigation: pushing and popping screens

Objective

Move between screens with the Navigator stack, pass data forward, and get a result back — the analogue of pushViewController / starting an Activity for a result.

A screen in Flutter is called a route, and routes live on a stack managed by the Navigator. It's exactly the push/pop model you know from UINavigationController and the Android back stack. Pushing a route slides a new screen on top; popping removes it and reveals the one beneath.

navigation.dartpush a screen, await a result
// go to a detail screen, passing data in via its constructor
final picked = await Navigator.push<Color>(
  context,
  MaterialPageRoute(
    builder: (context) => ColorPickerScreen(title: 'Pick'),
  ),
);

// back on the first screen, `picked` is whatever the second popped with
if (picked != null) setState(() => _color = picked);
color_picker_screen.dartreturn a value by popping
Navigator.pop(context, Colors.teal);  // pops & returns teal

Two things stand out for a native developer. First, you pass data into a screen the obvious way — as constructor arguments to its widget — not through a segue or an Intent extra. Second, getting a result back is just awaiting the push: the future completes when the pushed route pops, with whatever value it popped. No delegate protocol, no onActivityResult. It reads like a function call that happens to draw a screen.

Learn more — named routes and the case for a router package

You can register named routes (Navigator.pushNamed(context, '/settings')) for a central table of destinations. But for anything with deep links, web URLs, or nested navigation, the community standard is a declarative router package such as go_router, which maps URL patterns to screens and handles the browser's address bar on Flutter web. Learn the raw Navigator first — go_router sits on top of it — then adopt a router when your navigation grows real structure.

Glossary

Route
One screen on the navigation stack.
Navigator
The widget managing the route stack; you call push and pop on it.
MaterialPageRoute
A route with the platform's standard screen transition.
pop result
The value passed to pop, delivered to whoever awaited the push.

Check yourself

How do you get a value back from a screen you pushed (like a picked date)?

push returns a Future that resolves when the route pops, carrying the pop value. Flutter needs no delegate protocol (A), no shared globals (B), and has no onActivityResult (D) — the async return replaces all of that.

Part 2 · Module 8 of 12  ·  trickier

Async UI: Future, Stream, and builders

Objective

Render UI that depends on data arriving later — a network fetch, a live stream — without manually juggling loading and error states.

Most screens show something that isn't ready yet: a profile being fetched, a price ticking over a socket. Because your UI is a function of your data, the question becomes "what's the description while the data is still loading, or if it failed?" Flutter answers with builder widgets that hand you the current async status and let you return a widget for each case.

profile.dartFutureBuilder — one value, later
FutureBuilder<User>(
  future: _fetchUser(id),          // a Future<User>
  builder: (context, snapshot) {
    if (snapshot.connectionState != ConnectionState.done) {
      return const CircularProgressIndicator();  // loading
    }
    if (snapshot.hasError) {
      return Text('Failed: ${snapshot.error}'); // error
    }
    return Text(snapshot.data!.name);              // success
  },
)

The snapshot is a little bundle describing "where is this async operation right now" — still waiting, done with data, or done with an error. You branch on it and return the appropriate widget. There's no manual "hide the spinner, show the content" toggling of view visibility; you describe all three states declaratively and Flutter shows whichever matches.

For a sequence of values over time — a websocket, sensor readings, a database query that re-emits on change — use StreamBuilder with a Stream. Same shape; it just rebuilds each time a new value arrives.

Don't create the future in build

Passing future: _fetchUser(id) directly in build means a new fetch starts on every rebuild — every parent rebuild refires your network call. Create the future once in initState and store it in a field, then pass that field to the FutureBuilder. (This is why some teams skip the builders for a state-management solution — module 9.)

Glossary

Future<T>
A single value that arrives later — a network response, a file read.
Stream<T>
A sequence of values over time — socket messages, location updates.
FutureBuilder
Rebuilds based on a Future's state: waiting, done-with-data, or error.
snapshot
The object describing an async operation's current status and value/error.

Exercise

Three states, one widget

You have a Stream<int> of live temperatures. Describe a StreamBuilder that shows a spinner until the first reading, then the temperature, and handles error. Which builder, and what do you branch on?

StreamBuilder<int>(
  stream: _temperatures,
  builder: (context, snap) {
    if (snap.hasError) return const Text('—');
    if (!snap.hasData)  return const CircularProgressIndicator();
    return Text('${snap.data}°C');
  },
)

hasData is false until the first value, so the spinner covers the initial wait; each new emission rebuilds with the latest reading. Store _temperatures in a field, not inline.

Check yourself

You put future: fetch() inline in build and the network call fires over and over. Why?

build runs often, and each run calls fetch() afresh, so a new request starts each time. The fix is to create the Future once (in initState) and hold it in a field. It's not auto-retry (B), GC (C), or looping (D).

Part 2 · Module 9 of 12  ·  trickier

State beyond one widget

Objective

Share state across many widgets without threading it through every constructor — and know how the ecosystem's solutions relate, so the library zoo stops being intimidating.

setState is perfect for state a single widget owns. But real apps have state many screens care about: the signed-in user, the cart, a theme choice. Passing it down by constructor through ten layers ("prop drilling") gets miserable fast. You need a way to lift state up and let interested widgets read it from anywhere below.

The techniques form a ladder, and every rung is the same core idea — put the state above the widgets that need it, and expose it through the tree:

Rung 1

Lift state up

Move the state into the nearest common ancestor and pass values down plus callbacks up. No libraries. The right first move, and often enough.

Rung 2

InheritedWidget

The built-in mechanism (from module 4) to expose data to a whole subtree efficiently. Powerful, a little verbose to write by hand — which is why libraries wrap it.

Rung 3

A package

Provider, Riverpod, Bloc. Each is ergonomics over the same idea: hold state somewhere central, rebuild only the widgets that read the parts that changed.

Don't reach for a big library on day one. Build a screen or two with setState and lifted state; feel where it hurts. The pain — "I'm passing this callback through five widgets that don't care about it" — is what the packages remove, and you'll appreciate them far more once you've felt the problem they solve. When you do choose one, Riverpod is the common modern default; Bloc suits teams that want a strict, event-driven structure.

Learn more — the one rule that keeps state manageable

Whatever tool you use, keep a clean split between ephemeral UI state and app state. Ephemeral state — the current tab, whether a dropdown is open, an animation's progress — belongs in a local State with setState; no library needed and none wanted. App state — anything more than one screen reads, or that should outlive a screen — is what you lift and share.

Most "which state management should I use?" agonising evaporates once you sort each piece of state into one of those two buckets. Ephemeral stays local; shared goes up. The library only governs the second bucket.

Check yourself

A checkbox's "is it ticked" state is read by nothing but the checkbox itself. Where should it live?

That's ephemeral, single-widget state — local State with setState is exactly right, and reaching for a global tool (A, B) or persistence (D) is overkill that adds coupling for nothing. Lift state up only when something else needs to read it.

Part 2 · Module 10 of 12  ·  gentle

Material, Cupertino & theming

Objective

Scaffold a real screen with the Material widgets, understand what MaterialApp and Scaffold give you, and theme the whole app from one place.

Flutter draws every pixel itself, so it ships two complete design systems as widget libraries: Material (Google's, the default, works on both platforms) and Cupertino (Apple's look). Most teams build in Material for both platforms; you reach for Cupertino widgets when you specifically want iOS-native styling.

Two widgets anchor almost every Material app. MaterialApp sits at the very top and provides theming, navigation, and localisation to everything below. Scaffold gives a single screen its standard furniture: an app bar, a body, a floating action button, a drawer, bottom navigation.

app.dartthe standard skeleton
MaterialApp(
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: Colors.indigo,   // whole palette from one colour
    ),
    useMaterial3: true,
  ),
  home: Scaffold(
    appBar: AppBar(title: const Text('Inbox')),
    body: const MessageList(),
    floatingActionButton: FloatingActionButton(
      onPressed: _compose,
      child: const Icon(Icons.edit),
    ),
  ),
)

Note ColorScheme.fromSeed: give it one seed colour and Material 3 derives a full, accessible light-and-dark palette. Because every Material widget reads its colours from the theme via Theme.of(context) (module 4), changing that seed restyles the entire app — buttons, app bars, switches, all of it — from a single line. That's the theming payoff of "everything reads from an ancestor".

Glossary

MaterialApp
Root widget wiring up theme, navigation and localisation for a Material app.
Scaffold
The layout for one screen: app bar, body, FAB, drawer, bottom bar.
ThemeData
The app's design tokens — colours, typography, component styling.
Cupertino
The widget set styled to match iOS, for when you want Apple's native look.

Check yourself

You change the seedColor in ThemeData and the whole app restyles. Why does one line reach everything?

Widgets don't hardcode colours — they look up the nearest Theme through context, so replacing it re-colours them all. It's not recompilation (A), a global (C), or polling (D); it's the same inherited-data mechanism from module 4.

Part 2 · Module 11 of 12  ·  the subtle one

Keys: when identity matters

Objective

Understand the one situation where Flutter's element-matching guesses wrong, and how a Key tells it which state belongs to which widget.

Recall §3: when rebuilding, Flutter matches each new widget to an existing element by position and type, and preserves the element's State when they match. That heuristic is right almost always — and wrong in one specific, memorable case: reordering or removing items in a list of stateful widgets of the same type.

Picture three TodoTiles, each stateful (say each holds a half-typed edit). You delete the first. The list now has two widgets; Flutter matches them by position to the first two elements — so the second tile's in-progress edit stays attached to the first position. The widgets moved but the state didn't follow, because position was the only thing Flutter had to match on. Visually: the wrong row keeps the wrong state.

No keys — state sticks to position

Column(children: [
  TodoTile(todo: a),
  TodoTile(todo: b),
])
// reorder → State stays put,
// data swaps under it. Bug.

Keys — state follows identity

Column(children: [
  TodoTile(key: ValueKey(a.id), todo: a),
  TodoTile(key: ValueKey(b.id), todo: b),
])
// reorder → State follows its key.
// Correct.

A Key gives a widget a stable identity independent of position. With ValueKey(item.id), Flutter matches new widgets to old elements by key first, so each tile's state travels with its data no matter how the list is reordered. The rule of thumb: add keys when you have a list of stateful widgets of the same type that can reorder, insert, or delete. Static layouts almost never need them.

Don't cargo-cult keys

Keys are not a general "make it work" charm, and sprinkling them everywhere can cause bugs by defeating the reuse you actually want. If your widgets are stateless, or the list never changes order, you almost certainly don't need a key. Add one when you can name the exact reorder/insert scenario it fixes.

Glossary

Key
A stable identity for a widget, used during matching to pair it with the right element/state.
ValueKey
A key derived from a value you already have, like an item's id.
GlobalKey
A heavier key giving direct access to a widget's state or element from elsewhere. Use sparingly.

Check yourself

You reorder a list of stateful tiles and their internal state ends up on the wrong rows. The fix is to…

A stable key makes Flutter match state to identity rather than position, so each tile's state follows its data. setState (A) triggers the very rebuild that misbehaves; const (B) and inherited widgets (C) address unrelated concerns.

Part 2 · Module 12 of 12  ·  wrap-up

Tooling, testing & where to go next

Objective

Meet the daily tools that make Flutter pleasant — hot reload, DevTools, widget tests — and get an honest map of what's still ahead.

Flutter's headline feature for day-to-day work is hot reload. Save a file and your running app updates in well under a second, keeping its current state — you stay on the same screen, with the same data, and just see your change. After years of rebuild-and-relaunch cycles on native, this alone changes how you work: you tweak a padding, glance, tweak again, in a tight loop.

When something's off, Flutter DevTools gives you a live widget inspector (tap a pixel, see its widget tree), a layout explorer that visualises those constraints from module 3, plus performance, memory, and network views. The inspector especially is worth opening early — seeing your three trees rendered live cements everything in Part 1.

Testing comes in three tiers, and the middle one is Flutter's sweet spot:

TierWhat it checksSpeed
Unit testPlain Dart logic — no widgets.Instant.
Widget testA widget's behaviour: pump it, tap, assert what's on screen.Fast, no device.
Integration testThe whole app on a real device or emulator.Slow.
counter_test.darta widget test — no device needed
testWidgets('tapping increments the counter', (tester) async {
  await tester.pumpWidget(const MyApp());
  expect(find.text('0'), findsOneWidget);

  await tester.tap(find.byIcon(Icons.add));
  await tester.pump();               // let the rebuild happen

  expect(find.text('1'), findsOneWidget);
});

Widget tests run in seconds with no emulator, and because your whole UI is a function of state, they're unusually reliable — you pump a widget, simulate taps, and assert on what's rendered. Coming from native UI testing's flakiness, this is a genuine upgrade.

The honest difficulty map

Now that the vocabulary means something, here's what's still ahead — ranked so you can tell a genuinely hard topic from a gap in your understanding.

TopicWhy it takes time
Layout & constraintsThe model is simple; applying it to real designs takes reps. tier 1
State managementNot the libraries — deciding what state goes where. tier 1
Async & rebuild timingWhere futures are created, when builders re-fire. tier 2
AnimationsControllers, tweens, and the implicit/explicit split. tier 2
Keys & the element treeRarely needed, confusing when it is. tier 2
Slivers & custom renderThe deep end of scrolling and painting. tier 3
Platform channelsCalling native code. Only when you need a native API. tier 3

Where to go next

ResourceReach for it when
docs.flutter.devThe canonical docs, cookbook, and samples. Start here.
dart.dev/languageYou want the language properly, beyond §4.
The widget catalogYou need "what widget does X" — browsable with pictures.
Widget of the WeekTwo-minute videos, one widget each. Great habit.
api.flutter.devDaily. The API reference is thorough and full of examples.
pub.devFinding and vetting packages — check popularity and maintenance.

One closing thought, and it's the most useful sentence here. Nearly every Flutter surprise — the stale label, the overflow stripe, the form that resets, the wrong row's state — comes back to one of the ideas in Part 1: UI is a function of data, and there are three trees. When something baffles you, don't reach for a workaround. Ask which of those two ideas you've bumped into. That reframe is what turns the first fortnight from a fight into a conversation.

Check yourself

What makes hot reload different from a normal rebuild-and-relaunch?

Hot reload injects updated code into the live app and rebuilds from your existing state, so you stay on the same screen — the tight loop that makes Flutter pleasant. It's not about release-build speed (A), doesn't run tests (B), and applies to logic too, not just styling (D).