Chương 02 · Widget Tree & First App

Widget Tree & First App

Đây là chương mental model cốt lõi nhất của Flutter. Hiểu sai chỗ này thì 13 chương sau lệch hết. "Everything is a Widget" không phải slogan — phải nội hóa: padding là widget, margin là widget, even visibility là widget.

Độ dài: ~1050 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 1 + Dart class/function
🎯 Mục tiêu chương
  • Hiểu mental model "everything is a Widget" — kể cả padding, margin, alignment.
  • Phân biệt StatelessWidget vs StatefulWidget — khi nào dùng cái nào.
  • Hiểu vòng đời build() method và rebuild trigger.
  • Sử dụng MaterialApp, Scaffold, AppBar, Text, Container, Center cơ bản.
  • Hiểu Widget Tree, Element Tree, RenderObject Tree (3 cây).
  • Sử dụng setState cho local state.
  • Biết widget thường dùng: Image, Icon, ElevatedButton, Card, ListTile.

1. Mental model — Everything is a Widget

Câu mantra của Flutter. Nó nghĩa là gì trong thực tế?

Padding(
  padding: const EdgeInsets.all(16),       // padding là widget?
  child: Center(                       // căn giữa là widget
    child: Container(                   // box decoration là widget
      width: 100,
      height: 100,
      color: Colors.blue,
      child: const Text('Hello'),   // text là widget
    ),
  ),
)

So với HTML/CSS, mỗi thuộc tính style là một attribute. Flutter thì style là widget:

🔀 So với HTML/CSS, SwiftUI
<!-- HTML/CSS: style là attribute -->
<div style="padding: 16px; display: flex; align-items: center;">
  <div style="width: 100px; height: 100px; background: blue;">Hello</div>
</div>
// SwiftUI: style là modifier
Text("Hello")
  .frame(width: 100, height: 100)
  .background(Color.blue)
  .padding(16)
// Flutter: style là widget wrap quanh child
Padding(
  padding: EdgeInsets.all(16),
  child: Container(
    width: 100, height: 100, color: Colors.blue,
    child: const Text('Hello'),
  ),
)
🧠 Tại sao Flutter chọn cách này?
  • Composition mạnh — combine widget tự do, không bị giới hạn modifier có sẵn.
  • Uniform API — không có phân biệt "container element" vs "style attribute". Mọi thứ build qua constructor.
  • Tree là everything — debug bằng Flutter Inspector cực dễ vì tree đầy đủ.

Tradeoff: code dài hơn (nesting nhiều). Có shortcut với extension (Dart chương 8) hoặc helper widget custom.

2. Widget Tree

UI là một function của state: UI = f(state). runApp(root) đẩy widget root vào engine. Engine render widget tree mỗi frame (60-120 FPS).

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: 'Demo',
      home: Scaffold(
        appBar: AppBar(title: const Text('IT Basic Demo')),
        body: const Center(child: Text('Hello Flutter')),
      ),
    );
  }
}
Widget Tree của Counter App MaterialApp Scaffold AppBar Center (body) Text "IT Basic" Text "Hello" Root widget root → tree expand → leaf widget (Text, Icon, ...)
Widget tree của Counter app — 5 widget nested.

3. Ba cây của Flutter

Khi runApp chạy, Flutter tạo và quản lý 3 cây song song:

CâyVai tròMutable?Tương ứng
Widget TreeConfiguration / blueprintImmutable — tái tạo mỗi buildClass bạn viết
Element TreeInstance trong tree, giữ state, lifecycleMutableBuildContext = Element
RenderObject TreeLayout + paint thực sự lên canvasMutableChương 13 deep dive
🧠 Vì sao cần 3 cây?
  • Widget immutable → cheap to create/recreate mỗi build. Hot reload work được nhờ vậy.
  • Element giữ state + identity → state không mất khi parent rebuild (nếu type widget giữ nguyên).
  • RenderObject heavy (layout, paint, hit test) → reuse giữa builds để performance.

Hiểu sơ thôi ở chương này. Chương 13 sẽ đào sâu khi cần debug performance hoặc custom RenderObject.

4. StatelessWidget

Widget không có state nội bộ — UI chỉ phụ thuộc constructor parameter (immutable config).

class Greeting extends StatelessWidget {
  final String name;
  final int? age;

  const Greeting({super.key, required this.name, this.age});

  @override
  Widget build(BuildContext context) {
    return Text('Xin chào $name, ${age ?? 0} tuổi');
  }
}

// Use
const Greeting(name: 'Việt', age: 25);

Khi nào dùng StatelessWidget:

  • UI thuần — chỉ render dựa input từ constructor.
  • Reusable component không có local state.
  • Wrap component có state khác (lift state up).
💡 const constructor là performance hint

Stateless widget nên có const constructor khi mọi field là final + giá trị compile-time. Caller gọi const Greeting(name: 'Việt') → Flutter biết widget không đổi, skip rebuild luôn.

5. StatefulWidget

Widget có state nội bộ thay đổi theo thời gian (counter, toggle, input controller, animation).

class Counter extends StatefulWidget {
  const Counter({super.key, this.initialValue = 0});

  final int initialValue;

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

class _CounterState extends State<Counter> {
  late int _count;

  @override
  void initState() {
    super.initState();
    _count = widget.initialValue;
  }

  void _increment() {
    setState(() {
      _count++;
    });
  }

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

Tại sao StatefulWidget cần 2 class?

StatefulWidgetconfig immutable (có thể const). State là instance mutable, giữ field, có lifecycle. Tách 2 class → framework swap state khi widget rebuild mà state vẫn nguyên.

State lifecycle

Vòng đời State<T> createState() initState() build() didChangeDependencies setState() build() didUpdateWidget dispose()
Vòng đời State: tạo → init → build lặp → dispose khi unmount.

Method chính:

  • createState() — gọi 1 lần khi widget được mount.
  • initState() — gọi 1 lần sau create. Subscribe stream, fetch initial data ở đây.
  • build() — gọi nhiều lần. Phải pure — không side effect.
  • didUpdateWidget() — gọi khi widget parent rebuild với config mới.
  • dispose() — cleanup: cancel stream, dispose controller, etc.

6. setState()

Khi state thay đổi, gọi setState(callback) để báo Flutter "rebuild đi". Callback đổi field state.

// ✅ Đúng
setState(() {
  _count++;
  _label = 'Updated';
});

// ❌ Sai — đổi field nhưng không gọi setState
_count++;  // UI không refresh

// ❌ Sai — gọi async logic trong setState callback
setState(() {
  final data = await fetchData();  // callback không được async
});

// ✅ Đúng — async ngoài, setState sau khi có data
final data = await fetchData();
if (!mounted) return;        // kiểm tra widget còn alive
setState(() {
  _data = data;
});
🔥 mounted check sau async

Nếu user nav away khi đang await, widget dispose. Gọi setState sau đó → exception "setState() called after dispose()".

Pattern: if (!mounted) return; trước setState async. Đặc biệt sau await.

7. MaterialApp & Scaffold

MaterialApp

Wrap root app với Material Design (Google's design system). Provide theme, navigator, localization.

MaterialApp(
  title: 'My App',
  theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
    useMaterial3: true,
  ),
  home: const HomePage(),
  routes: {
    '/details': (ctx) => const DetailsPage(),
  },
  debugShowCheckedModeBanner: false,  // ẩn banner DEBUG góc phải
)

Scaffold

Skeleton của 1 page với các slot: appBar, body, FAB, drawer, bottomNavigationBar, snackBar.

Scaffold(
  appBar: AppBar(
    title: const Text('My Page'),
    actions: [
      IconButton(icon: const Icon(Icons.search), onPressed: () {}),
    ],
  ),
  body: const Center(child: Text('Body content')),
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: const Icon(Icons.add),
  ),
  drawer: const Drawer(/* ... */),
  bottomNavigationBar: BottomNavigationBar(items: [/*...*/]),
)

8. Widget cơ bản — hiển thị

// Text
const Text('Hello')
Text('Hello', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold))

// Icon
const Icon(Icons.favorite, color: Colors.red, size: 32)

// Image
Image.network('https://example.com/cat.jpg')
Image.asset('assets/images/logo.png')

// Container — kitchen sink widget
Container(
  width: 200,
  height: 100,
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Colors.amber,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 8)],
  ),
  child: const Text('Card'),
)

// SizedBox — kích thước cố định, hay dùng cho spacing
const SizedBox(height: 16),   // space dọc 16px
const SizedBox(width: 8),     // space ngang 8px

// Padding
const Padding(
  padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  child: Text('Padded text'),
)

// Center — wrap child căn giữa parent
const Center(child: Text('Centered'))

9. Widget interactive cơ bản

// ElevatedButton — primary action
ElevatedButton(
  onPressed: () => print('tap'),
  child: const Text('Submit'),
)

// onPressed: null → button disabled (mờ + không tap)
const ElevatedButton(
  onPressed: null,
  child: Text('Disabled'),
)

// TextButton — secondary, không elevation
TextButton(onPressed: () {}, child: const Text('Cancel'))

// OutlinedButton — viền
OutlinedButton(onPressed: () {}, child: const Text('Learn more'))

// IconButton
IconButton(
  icon: const Icon(Icons.favorite),
  onPressed: () {},
)

// Card — Material card với elevation, rounded corner
Card(
  child: ListTile(
    leading: const Icon(Icons.person),
    title: const Text('Việt'),
    subtitle: const Text('Software engineer'),
    trailing: const Icon(Icons.arrow_forward_ios),
    onTap: () {},
  ),
)

10. BuildContext

BuildContext là handle vào Element Tree. Mỗi widget có một BuildContext riêng tại vị trí của nó.

Dùng để:

  • Lookup ancestorTheme.of(context), MediaQuery.of(context).
  • NavigationNavigator.of(context).push(...).
  • Show overlayScaffoldMessenger.of(context).showSnackBar(...).
  • Read statecontext.read<Bloc>() (chương 7).
@override
Widget build(BuildContext context) {
  final theme = Theme.of(context);
  final screenWidth = MediaQuery.of(context).size.width;

  return Text(
    'Width: $screenWidth',
    style: theme.textTheme.bodyLarge,
  );
}
⚠️ BuildContext sau await

Nếu lưu context vào biến rồi await, sau đó dùng — context có thể không còn hợp lệ (widget unmount). Cần check mounted trước khi dùng context async:

onPressed: () async {
  final r = await _loadData();
  if (!context.mounted) return;
  Navigator.of(context).push(MaterialPageRoute(...));
}

11. const widget — performance hint chính

// Không const — tạo widget mới mỗi build
Text('Hello')

// Const — canonical, share instance, không rebuild
const Text('Hello')

// Tận dụng const cho mọi widget có thể
return const Padding(
  padding: EdgeInsets.all(16),
  child: Text('Hello'),
);

// Chỉ const được khi mọi nested widget là const + mọi field final + const constructor

Lint prefer_const_constructors + prefer_const_literals_to_create_immutables highlight chỗ có thể const. Sử dụng tối đa cho perf.

12. Counter app — từ flutter create

Mở lib/main.dart sau khi flutter create:

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: 'Counter',
      theme: ThemeData(useMaterial3: true),
      home: const CounterPage(),
    );
  }
}

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

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  void _increment() => setState(() => _count++);
  void _decrement() => setState(() => _count--);
  void _reset() => setState(() => _count = 0);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Số đếm:'),
            Text('$_count', style: Theme.of(context).textTheme.displayMedium),
            const SizedBox(height: 24),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                ElevatedButton(onPressed: _decrement, child: const Text('-1')),
                OutlinedButton(onPressed: _reset, child: const Text('Reset')),
                ElevatedButton(onPressed: _increment, child: const Text('+1')),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

13. Bài tập

Profile screen Stateless

Recreate màn hình profile đơn giản: avatar tròn + name + email + 3 stat (posts/followers/following). Chỉ widget cơ bản, không cần state. Tận dụng const tối đa.

💡 Gợi ý đáp án
class ProfilePage extends StatelessWidget {
  const ProfilePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: Column(
        children: [
          const SizedBox(height: 24),
          const CircleAvatar(
            radius: 50,
            backgroundImage: NetworkImage('https://i.pravatar.cc/200'),
          ),
          const SizedBox(height: 16),
          const Text('Việt Nguyễn', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
          const Text('viet@example.com'),
          const SizedBox(height: 32),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: const [
              _Stat(label: 'Posts', value: '42'),
              _Stat(label: 'Followers', value: '1.2k'),
              _Stat(label: 'Following', value: '200'),
            ],
          ),
        ],
      ),
    );
  }
}

class _Stat extends StatelessWidget {
  final String label;
  final String value;
  const _Stat({required this.label, required this.value});

  @override
  Widget build(BuildContext context) => Column(
    children: [
      Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
      Text(label),
    ],
  );
}

Counter với 3 button

Viết Counter widget với 3 button: +1, -1, reset. Hiển thị value với Text lớn. Style theo Material 3 default.

💡 Gợi ý đáp án

Tham khảo section 12 ("Counter app"). Hoặc thử variation: thêm step param cho phép +N/-N.

Const audit

Mở counter app sau flutter create. Audit toàn file lib/main.dart: bao nhiêu widget có thể convert sang const? Bao nhiêu không thể (vì có biến runtime)?

💡 Gợi ý đáp án

const Text('Counter') được — string literal. Text('$_count') không — vì _count runtime. const Icon(Icons.add) được. ElevatedButton(onPressed: _increment, child: ...) không — vì _increment là tear-off của instance method.

Lint prefer_const_constructors highlight chỗ thiếu — bật trong analysis_options.yaml.

Clock với Timer

Viết Stateful Clock hiển thị thời gian hiện tại (giờ:phút:giây). Dùng Timer.periodic trong initState, cancel ở dispose. Cập nhật mỗi giây.

💡 Gợi ý đáp án
import 'dart:async';

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

class _ClockState extends State<Clock> {
  late Timer _timer;
  DateTime _now = DateTime.now();

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      setState(() => _now = DateTime.now());
    });
  }

  @override
  void dispose() {
    _timer.cancel();   // CRITICAL: nếu không, leak + setState sau dispose
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = _now;
    return Text(
      '${t.hour.toString().padLeft(2, '0')}:'
      '${t.minute.toString().padLeft(2, '0')}:'
      '${t.second.toString().padLeft(2, '0')}',
      style: Theme.of(context).textTheme.displayLarge,
    );
  }
}

WelcomeCard Stateless

Tạo widget WelcomeCard(name, role) Stateless với Card chứa avatar tròn + 2 text. Sử dụng BoxDecoration cho avatar tròn nếu cần. Demo với 3 instance khác nhau.

💡 Gợi ý đáp án
class WelcomeCard extends StatelessWidget {
  final String name;
  final String role;
  const WelcomeCard({super.key, required this.name, required this.role});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            CircleAvatar(child: Text(name[0])),
            const SizedBox(width: 16),
            Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(name, style: const TextStyle(fontWeight: FontWeight.bold)),
                Text(role),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

14. Quiz

Q1

Stateful widget cần 2 class — vì sao?

Xem đáp án

Đáp án: Để widget config immutable (có thể const), state mutable riêng. Framework swap state khi widget tree thay đổi, instance state vẫn giữ field nguyên.

Q2

build() gọi nhiều lần — có ổn không?

Xem đáp án

Đáp án: Có, build PHẢI pure — không side effect (không fetch HTTP, không setState, không Future.delayed). Framework tự optimize qua const + Element diff.

Q3

setState(() { fetch(); }) — bug ở đâu?

Xem đáp án

Đáp án: setState callback chỉ để đổi state field, không phải gọi async. Fetch nên gọi ngoài, sau đó setState(() => _data = result) với kết quả.

Q4

const SizedBox(height: 16) vs SizedBox(height: 16) — runtime khác gì?

Xem đáp án

Đáp án: const canonicalize — 2 lần dùng cùng instance memory. Không trigger rebuild khi parent rebuild. const > không const luôn nếu có thể.

Q5

BuildContext truyền vào async function rồi dùng sau await — vấn đề?

Xem đáp án

Đáp án: Context có thể invalid (widget unmount). Check if (!context.mounted) return; trước khi dùng. Hoặc lint use_build_context_synchronously.

Q6

Widget có Key — vai trò chính là gì?

Xem đáp án

Đáp án: Giúp Flutter reconcile (match widget mới với element cũ) khi tree đổi shape — đặc biệt list reorder. 99% case không cần dùng explicit Key.

Q7

Scaffold cần thiết cho mọi page không?

Xem đáp án

Đáp án: Không bắt buộc. Scaffold cung cấp skeleton (AppBar, body, FAB, drawer, SnackBar host). Bỏ qua được — dùng Container thuần hoặc SafeArea.

Q8

Khác biệt Image.networkImage.asset?

Xem đáp án

Đáp án: Network = fetch từ URL runtime (cần internet permission, có placeholder/error builder). Asset = file local declare trong pubspec.yaml assets:, bundle vào app.

15. Tổng kết

  • ✅ Mental model "everything is a Widget" — padding, alignment, decoration đều là widget.
  • UI = f(state) declarative model.
  • ✅ 3 cây: Widget (immutable spec) / Element (instance + state) / RenderObject (layout + paint).
  • ✅ StatelessWidget — UI thuần từ constructor.
  • ✅ StatefulWidget — 2 class, lifecycle initState → build → didUpdateWidget → dispose.
  • setState schedule rebuild, không sync.
  • mounted check sau async.
  • ✅ MaterialApp + Scaffold = skeleton chuẩn.
  • ✅ Widget cơ bản: Text, Icon, Image, Container, SizedBox, Padding, Center, Button family, Card, ListTile.
  • ✅ BuildContext = handle Element, lookup ancestor.
  • const constructor = perf hint chính.

16. Kết nối

  • Chương 3 — Layout: constraints flow down, Row/Column/Stack/Expanded.
  • Chương 6 — State management cơ bản: lifting state up, InheritedWidget.
  • Chương 13 — Custom Paint: 3 cây deep dive.
  • Dart chương 3 — named param + required là pattern của mọi widget constructor.
  • Dart chương 4 — class extends StatelessWidget / StatefulWidget.