contentintech
Intermediate~20 min read

Flutter

Build cross-platform apps with Flutter and Dart 3 — the everything-is-a-widget model, StatelessWidget vs StatefulWidget and setState, Material 3 layout and list widgets, navigation with go_router, state management with Provider and Riverpod, and async data with Future/FutureBuilder and HTTP.

DartWidgetsState ManagementNavigation

What is Flutter?

Flutter is Google's open-source UI toolkit for building natively compiled, cross-platform apps — iOS, Android, web, desktop, and embedded — from a single codebase. Instead of wrapping platform components, Flutter renders every pixel itself with its own engine (Impeller on modern versions), so the UI looks and behaves identically everywhere and stays consistent as the OS changes.

Apps are written in Dart, a typed, object-oriented language. In development, Flutter uses a JIT compiler to enable hot reload; for release it compiles Dart ahead-of-time to native machine code for fast startup and smooth 60/120fps rendering.

Dart basics

Dart 3 is sound null-safe: a type like String can never be null, while String? can. Use final for run-time constants and const for compile-time constants.

// Variables and null safety
final String name = 'Ada';       // inferred + immutable
int? age;                        // nullable, defaults to null
const pi = 3.14159;              // compile-time constant

// Functions with named + optional params
String greet(String who, {String greeting = 'Hi'}) => '$greeting, $who!';
print(greet('Ada', greeting: 'Hello'));

// Collections + spread + collection-if
final nums = <int>[1, 2, 3];
final doubled = [for (final n in nums) n * 2];   // [2, 4, 6]
final tags = ['dart', if (age != null) 'has-age'];

// Records (Dart 3) and pattern matching
(int, String) pair = (200, 'OK');
final (code, msg) = pair;        // destructure
switch (code) {
  case 200: print('ok');
  case >= 400: print('error');
}

// Classes
class User {
  final String name;
  final int age;
  const User(this.name, this.age);
  bool get isAdult => age >= 18;
}

Everything is a Widget

In Flutter the entire UI is a tree of widgets — not just buttons and text, but also padding, alignment, and even the app itself. Widgets are immutable descriptions of part of the UI; Flutter builds an element tree from them and reconciles changes efficiently. You compose complex UIs by nesting small widgets rather than subclassing.

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(
      title: 'My App',
      theme: ThemeData(
        colorSchemeSeed: Colors.teal,
        useMaterial3: true,        // Material 3 by default
      ),
      home: const HomePage(),
    );
  }
}

Hot reload

Save a file (or press r in the terminal) and Flutter injects updated source into the running app in under a second — preserving app state. Use hot restart (R) to reset state when you change main() or top-level fields.

StatelessWidget vs StatefulWidget

A StatelessWidget is immutable — given the same inputs it always renders the same way (a label, an icon, a static card). A StatefulWidget pairs an immutable widget with a mutable State object that persists across rebuilds and can change over time.

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0;                // mutable state lives here

  void _increment() {
    setState(() => _count++);     // schedules a rebuild of build()
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('Count: $_count', style: Theme.of(context).textTheme.headlineMedium),
        FilledButton(onPressed: _increment, child: const Text('Add')),
      ],
    );
  }
}

setState and the build method

setState() marks the widget dirty and tells Flutter to re-run build(). The build() method must be pure and fast — it can be called many times per second, so never do network calls or heavy work inside it. Mutate fields inside the setState callback, then let build read them.

Use const widgets

Marking a widget subtree const lets Flutter skip rebuilding and re-diffing it entirely. Prefer const constructors wherever a widget's inputs never change — it is one of the cheapest performance wins available.

Layout Widgets

Layout in Flutter is composition. Column and Row arrange children vertically/horizontally; Expanded and Flexible divide remaining space; Stack overlaps children; Container and Padding add decoration and spacing.

Padding(
  padding: const EdgeInsets.all(16),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      const Text('Dashboard', style: TextStyle(fontSize: 22)),
      const SizedBox(height: 12),
      Row(
        children: [
          Expanded(                       // takes 2/3 of the row
            flex: 2,
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.teal.shade50,
                borderRadius: BorderRadius.circular(12),
              ),
              child: const Text('Main'),
            ),
          ),
          const SizedBox(width: 8),
          Expanded(child: Container(color: Colors.grey.shade200, height: 60)),
        ],
      ),
    ],
  ),
)

// Overlap with Stack
Stack(
  alignment: Alignment.bottomRight,
  children: [
    Image.network('https://picsum.photos/300'),
    const Padding(padding: EdgeInsets.all(8), child: Icon(Icons.favorite)),
  ],
)

Material 3 Widgets

With useMaterial3: true, Flutter ships the latest Material Design components. A typical screen uses Scaffold as the structural shell, with an AppBar, body, and optional navigation.

Scaffold(
  appBar: AppBar(title: const Text('Home')),
  body: Center(
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        FilledButton(onPressed: () {}, child: const Text('Filled')),
        OutlinedButton(onPressed: () {}, child: const Text('Outlined')),
        Card(child: ListTile(
          leading: const Icon(Icons.person),
          title: const Text('Ada Lovelace'),
          subtitle: const Text('Engineer'),
          trailing: const Icon(Icons.chevron_right),
        )),
      ],
    ),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
  bottomNavigationBar: NavigationBar(
    destinations: const [
      NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
      NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
    ],
  ),
)

Lists with ListView.builder

For long or dynamic lists, use ListView.builder, which lazily builds only the items currently on screen instead of all of them at once — essential for performance with large data sets.

final items = List<String>.generate(1000, (i) => 'Item #$i');

ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      leading: CircleAvatar(child: Text('$index')),
      title: Text(items[index]),
      onTap: () => debugPrint('tapped ${items[index]}'),
    );
  },
)

// Separated variant adds dividers between items
ListView.separated(
  itemCount: items.length,
  separatorBuilder: (_, __) => const Divider(height: 1),
  itemBuilder: (_, i) => Text(items[i]),
)

Navigation

The imperative Navigator works with a stack of routes. It is fine for simple apps, but for deep links, web URLs, and nested navigation, the go_router package is the modern, declarative standard.

// Imperative Navigator
Navigator.of(context).push(
  MaterialPageRoute(builder: (_) => const DetailPage(id: 42)),
);
Navigator.of(context).pop();          // go back

// Declarative with go_router
import 'package:go_router/go_router.dart';

final router = GoRouter(
  routes: [
    GoRoute(path: '/', builder: (_, __) => const HomePage()),
    GoRoute(
      path: '/user/:id',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return UserPage(id: id);
      },
    ),
  ],
);

// Wire into MaterialApp.router
MaterialApp.router(routerConfig: router);

// Navigate by URL
context.go('/user/42');       // replace stack
context.push('/settings');    // push on top

State Management

setState handles local, ephemeral UI state. For state shared across screens, use a dedicated solution. Provider exposes objects down the tree via InheritedWidget; Riverpod is its compile-safe successor with no BuildContext requirement.

Provider

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;
  void increment() { _count++; notifyListeners(); }
}

// Provide at the top
ChangeNotifierProvider(
  create: (_) => CounterModel(),
  child: const MyApp(),
);

// Read + rebuild on change
final count = context.watch<CounterModel>().count;
context.read<CounterModel>().increment();   // call without listening

Riverpod

import 'package:flutter_riverpod/flutter_riverpod.dart';

// A Notifier holds and mutates state
class Counter extends Notifier<int> {
  @override
  int build() => 0;                 // initial state
  void increment() => state++;
}

final counterProvider = NotifierProvider<Counter, int>(Counter.new);

// Wrap the app once
void main() => runApp(const ProviderScope(child: MyApp()));

// Consume inside a ConsumerWidget
class CounterView extends ConsumerWidget {
  const CounterView({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return TextButton(
      onPressed: () => ref.read(counterProvider.notifier).increment(),
      child: Text('$count'),
    );
  }
}

Async, Future & FutureBuilder

Dart handles asynchrony with Future (a single value later) and Stream (many values over time), using async/await. In the UI, FutureBuilder rebuilds based on a Future's state.

Future<String> loadName() async {
  await Future.delayed(const Duration(seconds: 1));
  return 'Ada';
}

FutureBuilder<String>(
  future: loadName(),
  builder: (context, snapshot) {
    if (snapshot.connectionState != ConnectionState.done) {
      return const CircularProgressIndicator();
    }
    if (snapshot.hasError) return Text('Error: ${snapshot.error}');
    return Text('Hello, ${snapshot.data}');
  },
)

Fetching data over HTTP

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<List<Map<String, dynamic>>> fetchPosts() async {
  final res = await http.get(Uri.parse('https://api.example.com/posts'));
  if (res.statusCode != 200) {
    throw Exception('Failed: ${res.statusCode}');
  }
  final data = jsonDecode(res.body) as List;
  return data.cast<Map<String, dynamic>>();
}

Forms & TextField

Use a TextField for standalone input, or wrap TextFormFields in a Form with a GlobalKey for validation.

final _formKey = GlobalKey<FormState>();
final _controller = TextEditingController();

Form(
  key: _formKey,
  child: Column(children: [
    TextFormField(
      controller: _controller,
      decoration: const InputDecoration(
        labelText: 'Email',
        border: OutlineInputBorder(),
      ),
      validator: (value) {
        if (value == null || !value.contains('@')) return 'Invalid email';
        return null;
      },
    ),
    FilledButton(
      onPressed: () {
        if (_formKey.currentState!.validate()) {
          debugPrint('Submitting ${_controller.text}');
        }
      },
      child: const Text('Submit'),
    ),
  ]),
)

Flutter vs Alternatives

AspectFlutterReact NativeNative (Kotlin/Swift)
LanguageDartJS/TSKotlin / Swift
RenderingOwn engine (Impeller)Native components via bridgeNative components
CodebaseSingle, all platformsSingle (mostly)One per platform
UI consistencyIdentical everywhereFollows platform lookFully native
Best forCustom, branded UIsJS-heavy teamsDeep platform features

Practice Exercises

  1. Build a counter app with a StatefulWidget that increments and decrements, disabling the decrement button at zero.
  2. Create a profile card using Column, Row, Expanded, and Container decoration, wrapped in a Material 3 Card.
  3. Render a 500-item list with ListView.builder and navigate to a detail screen on tap using go_router path parameters.
  4. Fetch posts from a public REST API with the http package and display them with a FutureBuilder, showing loading and error states.
  5. Refactor a counter from setState to a Riverpod NotifierProvider shared across two screens.
  6. Build a login Form with email and password validation, and print the values only when validation passes.

Section navigation