Week 2: Widgets, Layout & Styling

Week 1 ended with one static screen proving the setup works. This week covers the actual building blocks every Flutter screen is made from — the two widget base classes, constraint-based layout with Row/Column/Stack, and how styling works through ThemeData without any separate stylesheet language.

Phase 1 of 8 Week 2 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Choose correctly between StatelessWidget and StatefulWidget, and use setState
  • Lay out screens with Row, Column, Stack & understand Flutter's constraint model
  • Style widgets with ThemeData & handle taps with GestureDetector

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 matching State object that can hold mutable fields and call setState() to trigger a rebuild whenever that state changes.
a Counter -- the canonical StatefulWidget example
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.

The widget class and its state are deliberately split in two

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:

a horizontal stats row
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:

a badge layered over an avatar
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:

a typical screen shell
Column(
  children: [
    Container(height: 56, color: Colors.blue),   // fixed-height header
    Expanded(                                     // fills all remaining vertical space
      child: Container(color: Colors.white),
    ),
  ],
)
"RenderFlex overflowed" almost always means a missing Expanded

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 widget
  • Padding / SizedBox — dedicated single-purpose widgets for spacing, when a full Container is overkill
  • ImageImage.asset() for a bundled file, Image.network() for a remote URL
  • Icon — Material or Cupertino icon glyphs, sized and colored like text
  • ListView — a scrollable, vertically laid-out list, with ListView.builder() lazily building only the items currently on screen
a simple profile card
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:

a lazily-built list
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:

inline vs. app-wide 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:

ElevatedButton vs. GestureDetector
// 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

Hands-on

Build a scrollable profile screen

Apply this week's layout widgets and styling to a real, complete screen.

Requirements:

  1. Build a StatelessWidget screen with a profile header: a CircleAvatar, name and subtitle Text, laid out in a Column with crossAxisAlignment: CrossAxisAlignment.center.
  2. Below the header, add a horizontal stats row (at least 3 stats) using Row with MainAxisAlignment.spaceAround.
  3. Below that, add a vertical list of at least 6 "activity" items using ListView.builder inside an Expanded, and confirm the list actually scrolls once it overflows the screen.
  4. Add an ElevatedButton "Follow" button that calls setState to toggle its label between "Follow" and "Following" — this requires converting the screen to a StatefulWidget.
  5. Define an app-wide ThemeData with ColorScheme.fromSeed() in MaterialApp, and read at least one style from it with Theme.of(context) instead of hardcoding it inline.
Hint

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?

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?

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?

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?

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.