🌙
☀️ Dark

Volume 2: Flutter Foundations

Beginner ⏱ 20 min read

Volume 2: Flutter Foundations

Learning Objectives

Why Does This Exist?

In traditional Android (XML) or iOS (Storyboards), UI elements were heavy, mutable objects. If you wanted to change the text of a button, you found the button in memory and called button.setText("Clicked"). As applications grew, tracking which part of the code modified which UI element became a chaotic web of state mutations, leading to bugs where the UI didn't match the underlying data.

Flutter introduces a radical concept: Declarative UI. You don't mutate the UI. You describe what the UI should look like based on current data. When the data changes, Flutter throws away the old description and asks you for a new one.

Mental Model: The Blueprint and the Builder

Imagine you are an architect. A Widget is not a brick. A Widget is a lightweight paper blueprint. You hand this blueprint to the builder (the Flutter framework). The builder reads the blueprint and physically constructs the actual house (the Render Tree). If you want to paint the walls blue instead of red, you don't go paint the house. You draw a brand new blueprint with blue walls and hand it to the builder. The builder compares the new blueprint to the old one, sees only the paint color changed, and repaints just the walls.

Internal Working: The Three Trees

To understand Flutter, you must understand it operates on three trees:

  1. Widget Tree: The blueprints you write. They are cheap, immutable, and rebuilt constantly.
  2. Element Tree: The managers. Elements hold the state and the position in the tree. When a widget is thrown away, the Element sticks around and simply grabs the new widget.
  3. RenderObject Tree: The actual pixels. These are the heavy objects that calculate size, position, and paint to the screen.

Syntax: The Building Blocks

dart
// 1. StatelessWidget (A blueprint that never changes on its own)
class Greeting extends StatelessWidget {
  final String name;
  
  const Greeting({super.key, required this.name});

  @override
  Widget build(BuildContext context) {
    // We return a description of the UI
    return Text('Hello, $name!');
  }
}

The Golden Rule of Layouts

In Flutter, layout behavior can be summarized in one unbreakable rule:

Constraints go down. Sizes go up. Parent sets position.

A child cannot choose its position. It can only choose its size within the constraints given by its parent.

Tiny Example: The Core of Interactivity

dart
class CounterApp extends StatefulWidget {
  @override
  _CounterAppState createState() => _CounterAppState();
}

class _CounterAppState extends State<CounterApp> {
  int _count = 0; // State

  void _increment() {
    // setState tells the Element to throw away the old Widget 
    // and ask for a new one by calling build() again.
    setState(() {
      _count++; 
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $_count'),
        ElevatedButton(
          onPressed: _increment,
          child: Text('Add'),
        ),
      ],
    );
  }
}

Break It & Debug It

The Bug: You press the "Add" button, but the number on the screen stays at 0. You check your logs, and _count is definitely increasing to 1, 2, 3...

dart
// BAD EXAMPLE
void _increment() {
  _count++; // Modifying state without setState!
}

The Reason: You updated the data, but you didn't tell the builder (the framework) to ask for a new blueprint. The framework has no idea _count changed. By wrapping the change in setState(), you are explicitly flagging this widget as "dirty", forcing Flutter to schedule a rebuild on the very next frame (within 16ms).

🔧 Mini Project ⏱ 20 min

Goal: The StatefulCounter with Lifecycle Logs

Build a Counter app but instrument every lifecycle method. Override initState, didChangeDependencies, build, didUpdateWidget, and dispose. Print a timestamped log in each. Add a button that increments the counter. Add a second button that navigates away and comes back, so you can observe dispose and initState firing.

💡 See One Approach (Mini Project)

One valid solution — yours may differ.

dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const CounterScreen(),
      routes: {'/other': (ctx) => const OtherScreen()},
    );
  }
}

class CounterScreen extends StatefulWidget {
  const CounterScreen({super.key});
  @override
  State<CounterScreen> createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  @override
  void initState() {
    super.initState();
    print('[${DateTime.now().millisecond}ms] initState — widget mounted for first time');
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    print('[${DateTime.now().millisecond}ms] didChangeDependencies — InheritedWidget changed');
  }

  @override
  void didUpdateWidget(CounterScreen oldWidget) {
    super.didUpdateWidget(oldWidget);
    print('[${DateTime.now().millisecond}ms] didUpdateWidget — parent rebuilt us');
  }

  @override
  void dispose() {
    print('[${DateTime.now().millisecond}ms] dispose — widget leaving tree, CLEAN UP HERE');
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    print('[${DateTime.now().millisecond}ms] build — count=$_count');
    return Scaffold(
      appBar: AppBar(title: const Text('Lifecycle Logger')),
      body: Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          Text('Count: $_count', style: const TextStyle(fontSize: 48)),
          const SizedBox(height: 20),
          ElevatedButton(onPressed: () => setState(() => _count++), child: const Text('Increment')),
          ElevatedButton(
            onPressed: () async {
              await Navigator.pushNamed(context, '/other');
              // When we return, initState does NOT fire — the State is preserved!
            },
            child: const Text('Navigate Away & Back'),
          ),
        ]),
      ),
    );
  }
}

class OtherScreen extends StatelessWidget {
  const OtherScreen({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Other Screen')),
      body: Center(child: ElevatedButton(
        onPressed: () => Navigator.pop(context),
        child: const Text('Go Back'),
      )),
    );
  }
}

🏗 Bigger Project ⏱ 1.5 hrs

Goal: Flutter Profile Card Builder

Build a multi-screen Flutter app. Screen 1: A form with TextFields for name, job title, and bio (use TextEditingController, dispose them properly). Screen 2: A preview screen showing a beautiful profile card built from the entered data. The card should have a gradient header, avatar initials widget, and contact info. Pass data between screens using constructor arguments (no state management). Validate that name is non-empty before allowing navigation.

💡 See One Approach (Bigger Project)

One valid solution — yours may differ.

dart
import 'package:flutter/material.dart';

void main() => runApp(const ProfileApp());

class ProfileApp extends StatelessWidget {
  const ProfileApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'Profile Builder',
    theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
    home: const ProfileFormScreen(),
  );
}

// ─── Form Screen ───────────────────────────────────────────
class ProfileFormScreen extends StatefulWidget {
  const ProfileFormScreen({super.key});
  @override
  State<ProfileFormScreen> createState() => _ProfileFormScreenState();
}

class _ProfileFormScreenState extends State<ProfileFormScreen> {
  final _nameCtrl  = TextEditingController();
  final _titleCtrl = TextEditingController();
  final _bioCtrl   = TextEditingController();
  final _formKey   = GlobalKey<FormState>();

  @override
  void dispose() {
    // ALWAYS dispose controllers — they hold native resources!
    _nameCtrl.dispose();
    _titleCtrl.dispose();
    _bioCtrl.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Build Your Profile')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Form(
          key: _formKey,
          child: Column(children: [
            TextFormField(
              controller: _nameCtrl,
              decoration: const InputDecoration(labelText: 'Full Name *', border: OutlineInputBorder()),
              validator: (v) => (v == null || v.isEmpty) ? 'Name is required' : null,
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _titleCtrl,
              decoration: const InputDecoration(labelText: 'Job Title', border: OutlineInputBorder()),
            ),
            const SizedBox(height: 16),
            TextFormField(
              controller: _bioCtrl, maxLines: 3,
              decoration: const InputDecoration(labelText: 'Short Bio', border: OutlineInputBorder()),
            ),
            const SizedBox(height: 24),
            FilledButton.icon(
              onPressed: () {
                if (_formKey.currentState!.validate()) {
                  Navigator.push(context, MaterialPageRoute(
                    builder: (_) => ProfilePreviewScreen(
                      name: _nameCtrl.text,
                      title: _titleCtrl.text,
                      bio: _bioCtrl.text,
                    ),
                  ));
                }
              },
              icon: const Icon(Icons.preview),
              label: const Text('Preview Card'),
            ),
          ]),
        ),
      ),
    );
  }
}

// ─── Preview Screen ─────────────────────────────────────────
class ProfilePreviewScreen extends StatelessWidget {
  final String name, title, bio;
  const ProfilePreviewScreen({super.key, required this.name, required this.title, required this.bio});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Your Profile Card')),
      body: Center(
        child: Card(
          margin: const EdgeInsets.all(24),
          elevation: 8,
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Container(
                height: 120, width: double.infinity,
                decoration: const BoxDecoration(
                  gradient: LinearGradient(colors: [Color(0xFF0284c7), Color(0xFF38bdf8)]),
                  borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
                ),
                child: Center(
                  child: CircleAvatar(
                    radius: 40, backgroundColor: Colors.white,
                    child: Text(name.isNotEmpty ? name[0].toUpperCase() : '?',
                      style: const TextStyle(fontSize: 36, color: Color(0xFF0284c7), fontWeight: FontWeight.bold)),
                  ),
                ),
              ),
              Padding(
                padding: const EdgeInsets.all(24),
                child: Column(children: [
                  Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
                  if (title.isNotEmpty) Text(title, style: TextStyle(color: Colors.grey[600])),
                  if (bio.isNotEmpty) ...[
                    const Divider(height: 24),
                    Text(bio, textAlign: TextAlign.center, style: const TextStyle(height: 1.5)),
                  ],
                ]),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

🎯 Interview Questions

Answer these before revealing. These appear in real Flutter/Dart interviews.

🔍 Easy: What is the difference between a StatelessWidget and a StatefulWidget? When should you use each?

StatelessWidget is immutable — it describes a piece of UI that depends only on its constructor arguments. Once built, it never changes on its own. Use it for static content like labels, icons, or layouts. StatefulWidget has a separate mutable State object that can call setState() to trigger a rebuild. Use it when the widget needs to change over time based on user interaction, timers, or data changes.

🔍 Medium: Explain the Widget, Element, and RenderObject trees in Flutter. Why are there three trees instead of one?

Widget tree is the blueprint — lightweight, immutable Dart objects that describe the UI. They are cheap to create and recreate. Element tree is the instantiated, live representation — it manages the relationship between widgets and their state, and performs the reconciliation (diffing) between old and new widget trees. RenderObject tree is the actual layout and painting layer — it computes sizes, positions, and handles hit testing. Three trees allow Flutter to be efficient: recreating widgets (cheap) doesn't necessarily recreate elements (expensive) or render objects (most expensive). The element diffing algorithm minimizes actual rendering work.

🔍 Hard: What is the BuildContext and why does it cause so many errors when used across async gaps?

BuildContext is a reference to a specific Element in the Element tree. It gives widgets access to inherited data (Theme, Navigator, MediaQuery) by walking up the tree. The danger with async gaps: between `await` calls, a widget might be disposed (user navigated away). After the await, the BuildContext is no longer mounted — calling `Navigator.of(context)` on an unmounted context throws. The fix: check `if (!mounted) return;` after every await before using context. In newer Flutter versions, use `context.mounted` property. This is why async methods in State should always guard with mounted checks.

✅ I understand Flutter's widget tree, the difference between StatelessWidget and StatefulWidget, and how setState triggers a rebuild.