- Phân biệt Material (Android-style) vs Cupertino (iOS-style).
- Sử dụng
ThemeDataMaterial 3 vớiColorScheme.fromSeed. - Hiểu 15 text style Material 3.
- Apply theme qua
Theme.of(context).colorScheme.primary. - Dark mode:
themeMode: ThemeMode.system, define cảtheme+darkTheme. - Custom
ThemeExtensioncho semantic color không có trong Material spec. - Typography với
google_fontspackage. - Custom CupertinoTheme cho phần iOS-style.
1. Material vs Cupertino — chọn cái nào?
| Material (Google) | Cupertino (Apple) | |
|---|---|---|
| Look | Android-style, Google design system | iOS-style native |
| Cross-platform | ✅ Giống nhau iOS/Android | ❌ Chỉ iOS look |
| Widget | Button, AppBar, Drawer, Snackbar, ... | CupertinoButton, CupertinoNavigationBar, ... |
| Animation | Ripple, slide | iOS-style spring, swipe back |
| App phổ biến | Đa số Flutter app | App muốn match iOS native (banking, gov) |
3 lựa chọn thực tế:
- Material 100% — most common. App look giống nhau iOS/Android. Đơn giản nhất.
- Cupertino 100% — app iOS-only hoặc bắt buộc iOS feel trên cả 2.
- Adaptive mix — Material làm khung, một số widget swap sang Cupertino ở iOS qua
Theme.of(context).platform. Phức tạp, ít team làm.
Dùng Material 100% cho 14/15 chương Flutter. Chương 4 này có section Cupertino nhỏ để biết khi cần. App professional gần như đều Material 3 hiện tại.
2. MaterialApp properties chính
MaterialApp(
title: 'My App', // dùng cho task switcher
theme: ThemeData(/*...*/),
darkTheme: ThemeData(/*...*/),
themeMode: ThemeMode.system, // auto theo OS
home: const HomePage(),
// Hoặc routing (chương 5 đi sâu)
routes: {'/login': (ctx) => const LoginPage()},
onGenerateRoute: (settings) => null,
// Localization
localizationsDelegates: const [/*...*/],
supportedLocales: const [Locale('vi'), Locale('en')],
// Debug
debugShowCheckedModeBanner: false,
)
3. Material 3 vs Material 2
Material 3 (M3) là mặc định từ Flutter 3.16+. Đặc điểm:
- Dynamic color (theo wallpaper user trên Android 12+).
- Component lớn hơn, rounded corner đậm.
- Typography scale mới (15 style).
- Color slot 25+ thay vì primary/accent cũ.
ThemeData(
useMaterial3: true, // bắt buộc cho M3 (default từ 3.16)
// ... rest
)
4. ColorScheme.fromSeed — M3 way
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
)
fromSeed nhận 1 màu gốc + sinh tự động 25+ color slot match design system. 3 seed khác nhau → 3 palette khác nhau.
25 color slot chính
| Slot | Use case |
|---|---|
primary | Primary action — button background, header |
onPrimary | Content (text/icon) trên primary |
primaryContainer | Tinted background cho primary content |
onPrimaryContainer | Content trên primaryContainer |
secondary | Less prominent action |
onSecondary | Content trên secondary |
tertiary | Contrasting accent |
error | Error state, destructive action |
surface | Card, sheet background |
onSurface | Body text, icon trên surface |
background | App background |
outline | Border, divider |
inverseSurface | Snackbar background |
onX
Mỗi background color (primary, surface, ...) có pair onX — màu content đặt LÊN nó.
Đảm bảo contrast accessibility. Khi gán: color: scheme.primary phải kèm foregroundColor: scheme.onPrimary.
5. Text theme — 15 style M3
| Tier | Style | Use case |
|---|---|---|
| Display | displayLarge (57) | Hero number, marketing |
| displayMedium (45) | Section hero | |
| displaySmall (36) | Compact hero | |
| Headline | headlineLarge (32) | Page title |
| headlineMedium (28) | Section heading | |
| headlineSmall (24) | Card title | |
| Title | titleLarge (22) | Dialog title |
| titleMedium (16, w500) | List item title | |
| titleSmall (14, w500) | Small card title | |
| Body | bodyLarge (16) | Body text |
| bodyMedium (14) | Body default | |
| bodySmall (12) | Caption | |
| Label | labelLarge (14, w500) | Button label |
| labelMedium (12, w500) | Chip, tab | |
| labelSmall (11, w500) | Badge |
6. Apply theme trong widget
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final text = theme.textTheme;
return Container(
color: scheme.primary, // background
padding: const EdgeInsets.all(16),
child: Text(
'Hello',
style: text.headlineMedium?.copyWith(color: scheme.onPrimary),
),
);
}
// ❌ Hardcode — không follow theme
Container(color: Color(0xFF1A73E8), ...)
Text('Hi', style: TextStyle(fontSize: 24, color: Colors.blue))
// ✅ Theme-aware — follow theme, swap dark mode auto
Container(color: Theme.of(context).colorScheme.primary, ...)
Text('Hi', style: Theme.of(context).textTheme.headlineMedium)
7. google_fonts package
flutter pub add google_fonts
import 'package:google_fonts/google_fonts.dart';
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
textTheme: GoogleFonts.interTextTheme(), // Inter cho mọi style
)
// Hoặc mix display + body khác font
ThemeData(
textTheme: TextTheme(
displayLarge: GoogleFonts.fraunces(fontSize: 57),
bodyLarge: GoogleFonts.inter(fontSize: 16),
),
)
Mặc định google_fonts fetch font runtime (cần internet). Production nên:
- Tải font file (
.ttf) từ fonts.google.com. - Đặt vào
assets/fonts/, declare trongpubspec.yaml. GoogleFonts.config.allowRuntimeFetching = false;để force dùng bundled.
8. Dark mode
MaterialApp(
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
),
themeMode: ThemeMode.system, // light/dark/system
)
Toggle runtime:
final _themeMode = ValueNotifier(ThemeMode.system);
ValueListenableBuilder<ThemeMode>(
valueListenable: _themeMode,
builder: (context, mode, _) => MaterialApp(
theme: ThemeData(/*...*/),
darkTheme: ThemeData(/*...*/),
themeMode: mode,
home: HomePage(onToggle: () => _themeMode.value = mode == ThemeMode.dark
? ThemeMode.light : ThemeMode.dark),
),
)
9. ThemeExtension — custom slot
Material không có success/warning. Cách thêm type-safe:
class BrandColors extends ThemeExtension<BrandColors> {
final Color success;
final Color warning;
final Color info;
const BrandColors({required this.success, required this.warning, required this.info});
@override
BrandColors copyWith({Color? success, Color? warning, Color? info}) =>
BrandColors(
success: success ?? this.success,
warning: warning ?? this.warning,
info: info ?? this.info,
);
@override
BrandColors lerp(ThemeExtension<BrandColors>? other, double t) {
if (other is! BrandColors) return this;
return BrandColors(
success: Color.lerp(success, other.success, t)!,
warning: Color.lerp(warning, other.warning, t)!,
info: Color.lerp(info, other.info, t)!,
);
}
}
// Apply
ThemeData(
extensions: const [
BrandColors(
success: Color(0xFF22C55E),
warning: Color(0xFFEAB308),
info: Color(0xFF3B82F6),
),
],
)
// Access
final brand = Theme.of(context).extension<BrandColors>()!;
Container(color: brand.success);
10. Cupertino — iOS-style
import 'package:flutter/cupertino.dart';
CupertinoApp(
theme: const CupertinoThemeData(
primaryColor: CupertinoColors.activeBlue,
brightness: Brightness.light,
),
home: CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
middle: Text('Cupertino App'),
),
child: Center(
child: CupertinoButton(
onPressed: () {},
child: const Text('Press me'),
),
),
),
)
Adaptive widget — auto switch theo platform
.adaptive() // suffix cho một số widget
Switch.adaptive(value: true, onChanged: (v) {}) // iOS: CupertinoSwitch, Android: Switch
CircularProgressIndicator.adaptive()
Icons.adaptive.arrow_back // icon khác iOS/Android
11. Ví dụ đầy đủ — Coffee app theme
final coffeeTheme = ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6F4E37), // nâu cà phê
brightness: Brightness.light,
),
textTheme: GoogleFonts.manropeTextTheme(),
appBarTheme: const AppBarTheme(centerTitle: true),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
extensions: const [
CoffeeColors(
espresso: Color(0xFF3E2723),
latte: Color(0xFFFFF8E1),
cappuccino: Color(0xFFD7CCC8),
),
],
);
12. Bài tập
Coffee shop theme
Setup ThemeData cho app shop coffee: seed color brown, Google Fonts Manrope (body) + Lora (display).
Apply vào MaterialApp. Demo trên 2 page (home + detail).
💡 Gợi ý đáp án
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6F4E37)),
textTheme: GoogleFonts.manropeTextTheme().copyWith(
displayLarge: GoogleFonts.lora(fontSize: 57, fontWeight: FontWeight.w600),
displayMedium: GoogleFonts.lora(fontSize: 45, fontWeight: FontWeight.w600),
headlineLarge: GoogleFonts.lora(fontSize: 32),
),
)
Mix Manrope (sans-serif body) + Lora (serif display) — phổ biến cho cafe brand.
CoffeeColors extension
Add ThemeExtension CoffeeColors với 5 custom color (espresso, latte, cappuccino, macchiato, mocha).
Use trong card menu.
💡 Gợi ý đáp án
Implement theo pattern section 9. Apply qua Theme.of(context).extension<CoffeeColors>()!.
Dark mode toggle persistent
Implement toggle dark mode bằng ValueNotifier<ThemeMode> + button trong AppBar.
Persistent qua SharedPreferences (chương 11 sẽ học chi tiết, có thể skip persistent ở đây).
💡 Gợi ý đáp án
final themeMode = ValueNotifier<ThemeMode>(ThemeMode.system);
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<ThemeMode>(
valueListenable: themeMode,
builder: (_, mode, __) => MaterialApp(
theme: ThemeData.light(useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
themeMode: mode,
home: HomePage(),
),
);
}
}
// Trong AppBar:
IconButton(
icon: const Icon(Icons.brightness_6),
onPressed: () {
themeMode.value = themeMode.value == ThemeMode.dark
? ThemeMode.light
: ThemeMode.dark;
},
)
Refactor hardcode → theme
Cho 1 màn hình có hardcode color/textStyle. Refactor thành dùng Theme.of(context).
Demo: đổi seedColor → màn hình tự update.
💡 Gợi ý đáp án
// Trước
Container(
color: const Color(0xFF1A73E8),
child: Text('Hi', style: TextStyle(color: Colors.white, fontSize: 24)),
)
// Sau
final scheme = Theme.of(context).colorScheme;
final text = Theme.of(context).textTheme;
Container(
color: scheme.primary,
child: Text('Hi', style: text.headlineMedium?.copyWith(color: scheme.onPrimary)),
)
AppButton variant
Tạo widget AppButton(label, onPressed, variant) đọc theme, support 3 variant: primary / secondary / text.
Demo 3 button cạnh nhau.
💡 Gợi ý đáp án
enum AppButtonVariant { primary, secondary, text }
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final AppButtonVariant variant;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.variant = AppButtonVariant.primary,
});
@override
Widget build(BuildContext context) {
switch (variant) {
case AppButtonVariant.primary:
return ElevatedButton(onPressed: onPressed, child: Text(label));
case AppButtonVariant.secondary:
return OutlinedButton(onPressed: onPressed, child: Text(label));
case AppButtonVariant.text:
return TextButton(onPressed: onPressed, child: Text(label));
}
}
}
13. Quiz
Material 3 vs Material 2 — flag nào enable M3?
Xem đáp án
Đáp án: useMaterial3: true trong ThemeData. Default từ Flutter 3.16+.
Colors.blue vs scheme.primary — chênh ở đâu?
Xem đáp án
Đáp án: Colors.blue hardcode. scheme.primary follow theme — thay theme là đổi color toàn app.
ThemeExtension giúp gì?
Xem đáp án
Đáp án: Thêm custom field type-safe (vd success/warning color) không có trong ColorScheme. Access qua Theme.of(context).extension<T>().
GoogleFonts.inter() production có ổn không?
Xem đáp án
Đáp án: Mặc định fetch runtime — có offline issue. Production: tải font về assets + GoogleFonts.config.allowRuntimeFetching = false.
Switch.adaptive khác Switch thế nào?
Xem đáp án
Đáp án: iOS render CupertinoSwitch (look iOS), Android render Material Switch. Adaptive = platform-aware auto.
ColorScheme onPrimary nghĩa là gì?
Xem đáp án
Đáp án: Color của content (text, icon) đặt TRÊN background primary. Pair đảm bảo contrast accessibility.
Theme đổi runtime — widget có rebuild không?
Xem đáp án
Đáp án: Có. Widget dùng Theme.of(context) register dependency, theme đổi trigger rebuild.
CupertinoApp có Scaffold không?
Xem đáp án
Đáp án: Không. Cupertino dùng CupertinoPageScaffold (cùng pattern nhưng iOS-style).
14. Tổng kết
- ✅ Material 3 mặc định,
useMaterial3: true. - ✅
ColorScheme.fromSeedsinh 25+ slot từ 1 seed. - ✅ Pattern
onXcho content trên background. - ✅ 15 text style M3: display/headline/title/body/label × 3 size.
- ✅ Apply qua
Theme.of(context).colorScheme+.textTheme. - ✅
ThemeExtensioncho custom slot type-safe. - ✅
google_fonts— convenient nhưng production bundle file. - ✅ Dark mode:
theme+darkTheme+themeMode. - ✅ Cupertino cho iOS-style, adaptive widget switch platform.
- ✅ Đừng hardcode color/textStyle — luôn qua theme.
15. Kết nối
- Chương 5 — Navigation: theme apply qua mọi route.
- Chương 12 — Animation: animation cũng nên dùng color from theme.