1. Stateless vs. Stateful
Every widget you write extends one of two base classes, and the choice between them is the first decision you make for any new widget:
StatelessWidget— has no mutable state of its own. Given the same constructor arguments, it always builds the same output. Most of your UI should be this by default.StatefulWidget— paired with a matchingStateobject that can hold mutable fields and callsetState()to trigger a rebuild whenever that state changes.
import 'package:flutter/material.dart';
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $_count', style: const TextStyle(fontSize: 24)),
ElevatedButton(
onPressed: _increment,
child: const Text('Increment'),
),
],
);
}
}
setState() does exactly one thing: it tells Flutter "the data this
widget's build() depends on has changed, call build()
again." Nothing on screen updates from just assigning _count++ alone —
the mutation has to happen inside the setState callback, or
Flutter never learns a rebuild is needed.
Counter itself is immutable and gets thrown away and recreated on every rebuild — it's _CounterState (note the leading underscore, marking it private to the file) that survives across rebuilds and actually holds _count. This separation is why StatefulWidget always needs two classes, never just one.
2. Row, Column & Stack
Flutter's layout model is constraint-based, not flow-based like the web: a parent passes down size constraints, a child picks its own size within them, then reports that size back up so the parent can position it. In practice, three widgets cover most layouts:
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Column(
children: const [
Text('128', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text('Followers'),
],
),
Column(
children: const [
Text('42', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text('Following'),
],
),
],
)
Row lays children out horizontally, Column vertically —
each has a main axis (the direction it lays children out in) and a
cross axis (perpendicular to it), controlled independently with
mainAxisAlignment and crossAxisAlignment. Stack
is the third core layout widget, for layering children on top of each other (a badge
over an avatar, text over an image) using Positioned to place them:
Stack(
children: [
const CircleAvatar(radius: 32),
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 16,
height: 16,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
),
],
)
Expanded and Flexible solve the same problem
flex: 1 solves on the web — telling a child to grow and fill the
remaining space along its parent's main axis:
Column(
children: [
Container(height: 56, color: Colors.blue), // fixed-height header
Expanded( // fills all remaining vertical space
child: Container(color: Colors.white),
),
],
)
A Row or Column whose children's natural sizes exceed the space available throws this exact error with a yellow-and-black striped overlay. Wrapping the child that should grow or shrink to fit in Expanded (or Flexible) is the fix in the large majority of cases.
3. Container & Common Widgets
A handful of widgets show up in almost every screen you'll build from here on:
Container— a general-purpose box for padding, margin, sizing, background color, borders and decoration, all in one widgetPadding/SizedBox— dedicated single-purpose widgets for spacing, when a fullContaineris overkillImage—Image.asset()for a bundled file,Image.network()for a remote URLIcon— Material or Cupertino icon glyphs, sized and colored like textListView— a scrollable, vertically laid-out list, withListView.builder()lazily building only the items currently on screen
Container(
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 8)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const CircleAvatar(radius: 28),
const SizedBox(height: 8), // a fixed-size spacer -- no CSS margin here
const Text('Ada Lovelace', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Text('Mobile Engineer', style: TextStyle(color: Colors.grey)),
],
),
)
ListView.builder is the equivalent of a virtualized list on the web —
for a list with hundreds or thousands of items, only building the widgets currently
visible on screen matters enormously for performance:
ListView.builder(
itemCount: activities.length,
itemBuilder: (context, index) {
final activity = activities[index];
return ListTile(
leading: const Icon(Icons.check_circle_outline),
title: Text(activity.title),
);
},
)
4. Styling with Theme
There's no CSS file, no class names, no cascade or specificity. Styling is plain Dart objects passed as widget arguments — either inline per-widget, or centrally through a theme:
// Works, but repeats the same TextStyle everywhere it's needed
const Text('Hello', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600));
// Preferred -- defined once in MaterialApp, referenced from any widget
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
textTheme: const TextTheme(
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
home: const HomeScreen(),
);
// Reading the current theme from any widget's build method
Text('Hello', style: Theme.of(context).textTheme.titleLarge);
ColorScheme.fromSeed() is the modern Material 3 way to theme an entire
app from one seed color — it generates a full, accessible palette (primary,
secondary, surface, error, and their "on" text-color counterparts) automatically,
rather than you picking every shade by hand. Theme.of(context) is how
any widget, anywhere in the tree, reads that app-wide theme back out.
5. Handling Taps
Flutter gives you two levels of touch handling, and which one to reach for depends on whether you want a built-in visual response:
// A built-in button -- styling, ripple effect and touch target all handled
ElevatedButton(
onPressed: () => debugPrint('tapped'),
child: const Text('Follow'),
)
// InkWell -- adds Material's ripple effect to any custom widget
InkWell(
onTap: () => debugPrint('tapped'),
child: Container(
padding: const EdgeInsets.all(12),
child: const Text('Custom tappable row'),
),
)
// GestureDetector -- raw touch handling, no visual feedback of its own
GestureDetector(
onTap: () => debugPrint('tapped'),
onLongPress: () => debugPrint('long pressed'),
child: Container(color: Colors.blue, width: 100, height: 100),
)
Reach for a built-in widget like ElevatedButton, IconButton
or TextButton whenever a standard Material control fits. Use
InkWell when you want a custom-looking tappable area that still gets
Material's ripple feedback. Drop to bare GestureDetector only when you
need gestures a themed widget doesn't expose, like long-press or drag.
6. Hands-on Exercise
Build a scrollable profile screen
Apply this week's layout widgets and styling to a real, complete screen.
Requirements:
- Build a
StatelessWidgetscreen with a profile header: aCircleAvatar, name and subtitleText, laid out in aColumnwithcrossAxisAlignment: CrossAxisAlignment.center. - Below the header, add a horizontal stats row (at least 3 stats) using
RowwithMainAxisAlignment.spaceAround. - Below that, add a vertical list of at least 6 "activity" items using
ListView.builderinside anExpanded, and confirm the list actually scrolls once it overflows the screen. - Add an
ElevatedButton"Follow" button that callssetStateto toggle its label between "Follow" and "Following" — this requires converting the screen to aStatefulWidget. - Define an app-wide
ThemeDatawithColorScheme.fromSeed()inMaterialApp, and read at least one style from it withTheme.of(context)instead of hardcoding it inline.
If a "RenderFlex overflowed" error appears once the activity list is added, wrap the ListView.builder in Expanded so it claims only the remaining vertical space instead of trying to take an unbounded amount.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
A widget's _count field is incremented directly, without wrapping the change in setState(). What happens on screen?
A widget's _count field is incremented directly, without wrapping the change in setState(). What happens on screen?
Nothing visibly changes. The underlying value does update in memory, but Flutter has no way of knowing the widget's build() output might now be stale unless setState() is called — that call is what schedules the rebuild. Mutating state outside setState is a common and confusing early bug, since the app doesn't crash, it just silently fails to update.
Q2
A Row's children overflow the screen width with a striped "RenderFlex overflowed" warning. What's the most likely fix?
A Row's children overflow the screen width with a striped "RenderFlex overflowed" warning. What's the most likely fix?
Wrap the child that should grow or shrink to fit in Expanded (or Flexible). Without it, each child tries to take its own natural size along the main axis, which can add up to more than the parent actually has available — Expanded tells that child to instead claim a share of whatever space remains.
Q3
Why does StatefulWidget always require two classes — the widget itself and a separate State class — instead of just one?
Why does StatefulWidget always require two classes — the widget itself and a separate State class — instead of just one?
The widget object itself is immutable and gets discarded and recreated on every rebuild, exactly like a StatelessWidget. The paired State object is what actually persists across those rebuilds, holding the mutable fields (like _count) and the setState() method that triggers them.
Q4
When should you reach for GestureDetector instead of ElevatedButton or InkWell?
When should you reach for GestureDetector instead of ElevatedButton or InkWell?
Only when you need a gesture a themed widget doesn't expose, such as long-press or drag, or when you specifically don't want any built-in visual feedback. ElevatedButton covers standard buttons with styling included, and InkWell adds Material's ripple effect to a custom widget — both are preferred whenever they fit, since GestureDetector gives you raw touch handling with no visual response of its own.