Tampilan
Bab 9 — Theming, Styling & Aset
Aplikasi yang memakai warna bawaan Flutter terlihat seperti latihan. Bab ini membahas cara memberinya identitas — warna, tipografi, ikon, gambar, dan animasi — tanpa menaburkan nilai gaya di setiap widget.
Ide utamanya satu: tetapkan gaya sekali di satu tempat, lalu rujuk dari mana-mana.
Masalah dengan gaya inline
dart
Text(
'Judul',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color.fromARGB(255, 78, 13, 151),
),
)Ini bekerja. Tetapi ketika kamu punya empat puluh judul di seluruh aplikasi dan klien meminta warnanya diubah, kamu harus mengedit empat puluh tempat — dan pasti ada yang terlewat.
Tema menyelesaikan ini.
ColorScheme: satu benih, seluruh palet
Material 3 memperkenalkan cara yang sangat efisien: kamu memberi satu warna benih, dan Flutter menurunkan seluruh palet yang harmonis darinya.
dart
// lib/main.dart
import 'package:flutter/material.dart';
final skemaWarna = ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 96, 59, 181),
);
void main() {
runApp(const AplikasiPengeluaran());
}
class AplikasiPengeluaran extends StatelessWidget {
const AplikasiPengeluaran({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData().copyWith(
useMaterial3: true,
colorScheme: skemaWarna,
scaffoldBackgroundColor: skemaWarna.surface,
),
home: const Beranda(),
);
}
}Flutter menghasilkan lusinan warna dari satu benih itu, masing-masing dengan peran yang jelas:
| Peran | Kegunaan |
|---|---|
primary | Warna utama merek — tombol, tab aktif, penanda |
onPrimary | Warna teks/ikon di atas primary |
primaryContainer | Versi lebih lembut, untuk latar penekanan |
onPrimaryContainer | Teks di atas primaryContainer |
secondary | Warna aksen pendamping |
surface | Latar kartu dan permukaan |
onSurface | Teks utama di atas permukaan |
error / onError | Keadaan kesalahan |
outline | Garis tepi dan pembatas |
Pola penamaan on* sangat membantu: setiap kali kamu memakai warna latar X, warna teks yang benar di atasnya adalah onX. Kontrasnya sudah dijamin.
Membaca tema dari widget
dart
@override
Widget build(BuildContext context) {
final skema = Theme.of(context).colorScheme;
return Container(
color: skema.primaryContainer,
padding: const EdgeInsets.all(16),
child: Text(
'Teks yang kontrasnya terjamin',
style: TextStyle(color: skema.onPrimaryContainer),
),
);
}Theme.of(context) menelusuri widget tree ke atas sampai menemukan Theme terdekat. Karena MaterialApp menyediakan satu di puncak, ini selalu berhasil.
Simpan hasilnya di variabel
Menulis Theme.of(context).colorScheme.primary berkali-kali dalam satu build() itu berulang dan sedikit boros. Ambil sekali di awal:
dart
final skema = Theme.of(context).colorScheme;
final teks = Theme.of(context).textTheme;TextTheme: tipografi terpusat
Sama seperti warna, ukuran dan berat teks sebaiknya ditetapkan sekali.
Material 3 menyediakan lima belas gaya teks bawaan yang tersusun dalam tiga keluarga:
display → displayLarge displayMedium displaySmall (sangat besar)
headline → headlineLarge headlineMedium headlineSmall (judul halaman)
title → titleLarge titleMedium titleSmall (judul bagian)
body → bodyLarge bodyMedium bodySmall (isi teks)
label → labelLarge labelMedium labelSmall (tombol, kapsi)Memakainya:
dart
Text('Judul Besar', style: Theme.of(context).textTheme.headlineMedium)
Text('Isi paragraf', style: Theme.of(context).textTheme.bodyMedium)Menyesuaikannya secara global:
dart
theme: ThemeData().copyWith(
useMaterial3: true,
colorScheme: skemaWarna,
textTheme: ThemeData().textTheme.copyWith(
titleLarge: TextStyle(
fontWeight: FontWeight.bold,
color: skemaWarna.onSecondaryContainer,
fontSize: 16,
),
bodyMedium: TextStyle(
color: skemaWarna.onSurface,
fontSize: 15,
height: 1.5,
),
),
),Menyesuaikan sebagian gaya di satu tempat, dengan copyWith:
dart
Text(
'Judul dengan warna berbeda',
style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Colors.white,
fontSize: 24,
),
)copyWith mengambil gaya yang sudah ada dan hanya mengganti properti yang kamu sebutkan. Ini jauh lebih baik daripada menulis TextStyle dari nol, karena sisanya tetap mengikuti tema.
Tema komponen
Kamu juga bisa menetapkan gaya bawaan untuk jenis widget tertentu, sehingga tidak perlu mengulanginya:
dart
theme: ThemeData().copyWith(
useMaterial3: true,
colorScheme: skemaWarna,
// Semua AppBar di aplikasi
appBarTheme: const AppBarTheme().copyWith(
backgroundColor: skemaWarna.onPrimaryContainer,
foregroundColor: skemaWarna.primaryContainer,
centerTitle: true,
elevation: 0,
),
// Semua Card
cardTheme: const CardThemeData().copyWith(
color: skemaWarna.secondaryContainer,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
// Semua ElevatedButton
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: skemaWarna.primaryContainer,
foregroundColor: skemaWarna.onPrimaryContainer,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
// Semua kolom input
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: skemaWarna.surfaceContainerHighest,
),
),Sekarang setiap ElevatedButton di aplikasimu otomatis membulat dan berwarna sesuai merek, tanpa satu pun style: di widget-nya.
Nama kelas tema berubah
Sejak Flutter 3.29, CardTheme diganti CardThemeData, DialogTheme menjadi DialogThemeData, dan beberapa lainnya mengikuti pola serupa. Kalau kamu menemukan error tipe ketika menyalin kode dari tutorial lama, periksa apakah kelasnya sudah berakhiran ThemeData.
Mode gelap
Menyediakan tema gelap hanya butuh satu parameter tambahan:
dart
final skemaTerang = ColorScheme.fromSeed(
seedColor: const Color.fromARGB(255, 96, 59, 181),
);
final skemaGelap = ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: const Color.fromARGB(255, 5, 99, 125),
);
MaterialApp(
theme: ThemeData().copyWith(
useMaterial3: true,
colorScheme: skemaTerang,
// ... tema komponen
),
darkTheme: ThemeData.dark().copyWith(
useMaterial3: true,
colorScheme: skemaGelap,
cardTheme: const CardThemeData().copyWith(
color: skemaGelap.secondaryContainer,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
themeMode: ThemeMode.system, // ikuti pengaturan perangkat
home: const Beranda(),
)Nilai themeMode yang tersedia:
| Nilai | Perilaku |
|---|---|
ThemeMode.system | Mengikuti pengaturan perangkat — bawaan |
ThemeMode.light | Selalu terang |
ThemeMode.dark | Selalu gelap |
Kalau kamu ingin pengguna bisa memilih sendiri, themeMode perlu disimpan sebagai state aplikasi. Solusi sederhananya memakai ValueNotifier dari Bab 5; solusi yang lebih rapi memakai Riverpod (Bab 18) atau BLoC (Bab 21).
Untuk memeriksa mode aktif dari dalam widget:
dart
final gelap = Theme.of(context).brightness == Brightness.dark;
Icon(
gelap ? Icons.dark_mode : Icons.light_mode,
)Font kustom
Ada dua cara memakai font di luar bawaan sistem.
Cara pertama: google_fonts
Paling cepat, tanpa mengunduh berkas apa pun:
yaml
# pubspec.yaml
dependencies:
google_fonts: ^6.2.1dart
import 'package:google_fonts/google_fonts.dart';
MaterialApp(
theme: ThemeData().copyWith(
colorScheme: skemaWarna,
textTheme: GoogleFonts.latoTextTheme(), // seluruh tema teks
),
)Atau untuk satu gaya saja:
dart
Text(
'Judul',
style: GoogleFonts.lato(
textStyle: Theme.of(context).textTheme.titleLarge,
fontWeight: FontWeight.bold,
),
)Font diunduh saat runtime
Secara bawaan google_fonts mengunduh berkas font pada penggunaan pertama. Ini berarti teks bisa berkedip pada peluncuran pertama, dan aplikasi butuh koneksi internet. Untuk aplikasi produksi, unduh berkas fontnya dan daftarkan sebagai aset — seperti cara kedua di bawah.
Cara kedua: berkas font lokal
Unduh berkas .ttf, taruh di proyek, lalu daftarkan:
assets/
└── fonts/
├── Poppins-Regular.ttf
├── Poppins-Medium.ttf
├── Poppins-SemiBold.ttf
└── Poppins-Bold.ttfyaml
# pubspec.yaml
flutter:
uses-material-design: true
fonts:
- family: Poppins
fonts:
- asset: assets/fonts/Poppins-Regular.ttf
weight: 400
- asset: assets/fonts/Poppins-Medium.ttf
weight: 500
- asset: assets/fonts/Poppins-SemiBold.ttf
weight: 600
- asset: assets/fonts/Poppins-Bold.ttf
weight: 700dart
theme: ThemeData(
fontFamily: 'Poppins', // nama family, bukan nama berkas
colorScheme: skemaWarna,
),Setelah itu, FontWeight.w600 otomatis memakai berkas SemiBold. Nilai weight di YAML harus cocok dengan angka FontWeight di Dart.
Aset gambar
Mendaftarkan
yaml
flutter:
assets:
- assets/gambar/
- assets/ikon/Garis miring di akhir mendaftarkan seluruh isi folder. Untuk berkas tertentu:
yaml
- assets/gambar/logo.pngSetelah mengubah pubspec.yaml
Jalankan hot restart (R), bukan hot reload (r). Perubahan pada pubspec.yaml tidak terdeteksi oleh hot reload. Kalau masih tidak muncul, hentikan aplikasi dan jalankan flutter pub get lalu flutter run lagi.
Resolusi ganda
Untuk gambar yang tajam di layar beresolusi tinggi, sediakan beberapa ukuran:
assets/gambar/
├── logo.png ← 1x (misalnya 100×100)
├── 2.0x/
│ └── logo.png ← 2x (200×200)
└── 3.0x/
└── logo.png ← 3x (300×300)Kamu tetap menulis Image.asset('assets/gambar/logo.png'), dan Flutter memilih berkas yang sesuai dengan kepadatan piksel perangkat secara otomatis.
Menampilkan
dart
// Dari aset
Image.asset(
'assets/gambar/logo.png',
width: 120,
fit: BoxFit.contain,
)
// Dari internet, dengan penanganan muat & gagal
Image.network(
urlGambar,
height: 200,
width: double.infinity,
fit: BoxFit.cover,
loadingBuilder: (context, child, progres) {
if (progres == null) return child;
return SizedBox(
height: 200,
child: Center(
child: CircularProgressIndicator(
value: progres.expectedTotalBytes != null
? progres.cumulativeBytesLoaded / progres.expectedTotalBytes!
: null,
),
),
);
},
errorBuilder: (context, error, stack) => Container(
height: 200,
color: Colors.grey.shade200,
child: const Icon(Icons.broken_image, size: 48),
),
)Untuk gambar jaringan di daftar panjang, paket cached_network_image menyimpan gambar di cache sehingga tidak diunduh ulang setiap kali digulir:
yaml
dependencies:
cached_network_image: ^3.4.1dart
CachedNetworkImage(
imageUrl: urlGambar,
placeholder: (ctx, url) => const Center(
child: CircularProgressIndicator(),
),
errorWidget: (ctx, url, error) => const Icon(Icons.error),
fit: BoxFit.cover,
)CircleAvatar
Widget untuk foto profil bundar:
dart
// Dari jaringan
CircleAvatar(
radius: 32,
backgroundImage: NetworkImage(urlFoto),
)
// Dari aset
const CircleAvatar(
radius: 32,
backgroundImage: AssetImage('assets/gambar/profil.png'),
)
// Inisial sebagai cadangan
CircleAvatar(
radius: 32,
backgroundColor: Colors.indigo,
child: Text(
'FN',
style: const TextStyle(color: Colors.white, fontSize: 20),
),
)Pola yang aman: tampilkan foto kalau ada, inisial kalau tidak.
dart
CircleAvatar(
radius: 32,
backgroundColor: Colors.indigo,
backgroundImage: urlFoto != null ? NetworkImage(urlFoto!) : null,
child: urlFoto == null
? Text(
inisial,
style: const TextStyle(color: Colors.white, fontSize: 20),
)
: null,
)Ketika backgroundImage terisi, child disembunyikan di belakangnya — jadi memberi keduanya sekaligus tidak menyebabkan error, tetapi memberi null pada yang tidak dipakai lebih jelas maksudnya.
Animasi Lottie
Lottie memungkinkan kamu memakai animasi vektor yang dibuat di After Effects — jauh lebih ringan daripada GIF dan tetap tajam di ukuran berapa pun.
yaml
dependencies:
lottie: ^3.1.3dart
import 'package:lottie/lottie.dart';
// Dari aset lokal
Lottie.asset(
'assets/animasi/loading.json',
width: 200,
height: 200,
fit: BoxFit.contain,
repeat: true,
)
// Dari internet
Lottie.network(
'https://assets.lottiefiles.com/packages/contoh.json',
width: 200,
)Untuk mengendalikan pemutarannya secara manual:
dart
class _LayarSuksesState extends State<LayarSukses>
with SingleTickerProviderStateMixin {
late final AnimationController _pengendali;
@override
void initState() {
super.initState();
_pengendali = AnimationController(vsync: this);
}
@override
void dispose() {
_pengendali.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Lottie.asset(
'assets/animasi/sukses.json',
controller: _pengendali,
repeat: false,
onLoaded: (komposisi) {
// Sesuaikan durasi controller dengan durasi animasi,
// lalu putar sekali.
_pengendali
..duration = komposisi.duration
..forward();
},
),
);
}
}AnimationController dan SingleTickerProviderStateMixin dibahas tuntas di Bab 11.
Sumber animasi gratis: LottieFiles.
FittedBox
Widget kecil yang menyelamatkan banyak masalah teks meluber. Ia mengecilkan anaknya agar muat di ruang yang tersedia:
dart
SizedBox(
width: 120,
child: FittedBox(
fit: BoxFit.scaleDown, // hanya mengecilkan, tidak membesarkan
child: Text(
'Teks yang mungkin sangat panjang',
style: TextStyle(fontSize: 24),
maxLines: 1,
),
),
)Berbeda dari overflow: TextOverflow.ellipsis yang memotong teks, FittedBox mengecilkan ukurannya sehingga seluruh teks tetap terbaca. Berguna untuk angka yang panjangnya tidak bisa diprediksi — misalnya nominal harga.
Ikon peluncur aplikasi
Ikon yang muncul di layar utama ponsel bukan aset Flutter biasa; ia harus ada dalam berbagai ukuran di folder Android dan iOS. Paket flutter_launcher_icons mengurus semuanya.
yaml
# pubspec.yaml
dev_dependencies:
flutter_launcher_icons: ^0.14.1
flutter_launcher_icons:
android: true
ios: true
image_path: "assets/ikon/ikon_app.png"
min_sdk_android: 21
adaptive_icon_background: "#603BB5"
adaptive_icon_foreground: "assets/ikon/ikon_depan.png"Jalankan sekali:
bash
dart run flutter_launcher_iconsPersyaratan gambarnya: PNG persegi, minimal 1024×1024 piksel, tanpa transparansi di latar untuk hasil terbaik di iOS.
Contoh: tema aplikasi yang lengkap
Menggabungkan semuanya menjadi satu berkas tema yang bisa kamu jadikan titik awal.
dart
// lib/theme/tema_aplikasi.dart
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class TemaAplikasi {
TemaAplikasi._(); // kelas ini tidak untuk diinstansiasi
static const _warnaBenih = Color(0xFF603BB5);
static final skemaTerang = ColorScheme.fromSeed(seedColor: _warnaBenih);
static final skemaGelap = ColorScheme.fromSeed(
seedColor: _warnaBenih,
brightness: Brightness.dark,
);
static ThemeData terang() => _bangun(skemaTerang);
static ThemeData gelap() => _bangun(skemaGelap);
static ThemeData _bangun(ColorScheme skema) {
final dasar = ThemeData(
useMaterial3: true,
colorScheme: skema,
brightness: skema.brightness,
);
return dasar.copyWith(
scaffoldBackgroundColor: skema.surface,
textTheme: GoogleFonts.poppinsTextTheme(dasar.textTheme).copyWith(
headlineMedium: GoogleFonts.poppins(
fontSize: 24,
fontWeight: FontWeight.bold,
color: skema.onSurface,
),
titleLarge: GoogleFonts.poppins(
fontSize: 18,
fontWeight: FontWeight.w600,
color: skema.onSurface,
),
bodyMedium: GoogleFonts.poppins(
fontSize: 14,
height: 1.5,
color: skema.onSurface,
),
),
appBarTheme: AppBarTheme(
backgroundColor: skema.surface,
foregroundColor: skema.onSurface,
elevation: 0,
scrolledUnderElevation: 2,
centerTitle: false,
titleTextStyle: GoogleFonts.poppins(
fontSize: 20,
fontWeight: FontWeight.w600,
color: skema.onSurface,
),
),
cardTheme: CardThemeData(
color: skema.surfaceContainerLow,
elevation: 0,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: skema.outlineVariant),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: skema.primary,
foregroundColor: skema.onPrimary,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: GoogleFonts.poppins(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: skema.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: skema.primary, width: 2),
),
),
dividerTheme: DividerThemeData(
color: skema.outlineVariant,
thickness: 1,
space: 1,
),
);
}
}Pemakaiannya menjadi sangat ringkas:
dart
// lib/main.dart
MaterialApp(
theme: TemaAplikasi.terang(),
darkTheme: TemaAplikasi.gelap(),
themeMode: ThemeMode.system,
home: const Beranda(),
)Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Bersihkan Gaya Inline — ⭐⭐ · 30–45 menit
Tantangan: Ambil layar mana pun yang sudah pernah kamu buat, lalu hapus setiap TextStyle dan Colors.* yang ditulis langsung di dalam widget. Pindahkan semuanya ke satu objek ThemeData di main.dart, dan tambahkan mode gelap yang benar-benar terbaca.
Bayangan tampilan:
text
Terang Gelap
╭───────────────────╮ ╭───────────────────╮
│ 9:41 🔋 │ │ 9:41 🔋 │
│ AppBar (biru muda) │ │ AppBar (biru tua) │
│ ┌───────────────┐ │ │ ┌───────────────┐ │
│ │ Kartu (putih) │ │ │ │ Kartu (abu tua)│ │
│ │ Teks (hitam) │ │ │ │ Teks (putih) │ │
│ └───────────────┘ │ │ └───────────────┘ │
╰───────────────────╯ ╰───────────────────╯Kedua mode di atas dari satu ColorScheme.fromSeed yang sama — bukan dua ThemeData yang ditulis terpisah — dan tidak ada teks yang "hilang" karena warnanya menyatu dengan latar.
Kriteria selesai:
- Mencari
Colors.di dalam folderscreens/danwidgets/tidak menemukan satu pun hasil. - Seluruh palet diturunkan dari satu warna benih lewat
ColorScheme.fromSeed. - Gaya berulang seperti
AppBar,Card, dan tombol diatur lewat tema komponen, bukan propertistyle:yang ditulis di setiap pemakaian. - Menyalakan mode gelap dari pengaturan perangkat: tidak ada satu pun teks yang hilang karena warnanya sama dengan latarnya.
Petunjuk: Kalau ada warna yang terasa mustahil dipetakan ke ColorScheme, tanyakan dulu peran warna itu — apakah ia warna latar permukaan, warna aksen, atau warna peringatan. Hampir semua warna dalam sebuah aplikasi punya salah satu peran itu, dan ColorScheme sudah menyediakan slot untuk masing-masing beserta pasangan onX-nya yang dijamin cukup kontras. Untuk mode gelap, ColorScheme.fromSeed menerima brightness sehingga kamu tidak perlu menyusun palet kedua dari nol. Dan kalau kamu menyalin kode tema dari tulisan lama, perhatikan beberapa kelas kini berakhiran ThemeData — CardThemeData, bukan CardTheme.
Variasi 2: Identitas Kedai Kopi — ⭐⭐ · 30–45 menit
Tantangan: Bangun tema lengkap untuk aplikasi kedai kopi dari satu warna benih cokelat kehijauan: terang dan gelap, dengan tipografi yang terasa hangat. Buat satu layar demo berisi app bar, kartu menu, tombol, chip, dan bilah bawah untuk membuktikan seluruh komponen mengikuti tema tanpa satu pun gaya lokal.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ ═══ Kedai Kopi Nusantara ═══ │
│ ┌─────────────────────────────┐ │
│ │ Kopi Susu Gula Aren 25.000 │ │
│ └─────────────────────────────┘ │
│ [Pesan] [Favorit] [Kopi][Susu] │
│ ───────────────────────────────── │
│ 🏠 ☕ 👤 │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Ganti satu warna benih di kode → app bar, kartu, tombol, chip, dan bilah bawah di atas semuanya ikut berubah otomatis, tanpa menyentuh satu pun kode di layar demo ini.
Kriteria selesai:
- Seluruh palet terang dan gelap diturunkan dari satu warna benih yang sama.
- Setiap pasangan warna dan teks di atasnya memakai
XdanonXyang berpasangan, bukan warna teks yang ditebak. - Layar demo tidak mengandung satu pun
TextStyleatau warna literal. - Mengganti warna benih di satu tempat mengubah seluruh tampilan aplikasi.
Petunjuk: Kriteria terakhir adalah cara terbaik menguji apakah temamu benar-benar terpusat: ubah warna benihnya menjadi sesuatu yang mencolok seperti ungu terang, jalankan, dan lihat apakah ada bagian yang tetap cokelat. Setiap bagian yang tidak berubah adalah gaya lokal yang lolos dari temamu.
Variasi 3: Tipografi Majalah — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun halaman artikel bergaya majalah dengan hierarki tipografi yang jelas: judul besar, kutipan menonjol, teks isi yang nyaman dibaca, dan keterangan gambar. Pakai dua keluarga font — satu untuk judul, satu untuk isi — dan daftarkan keduanya sebagai aset, bukan diunduh saat runtime.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ JUDUL BESAR ARTIKEL │
│ │
│ "Kutipan menonjol di sini" │
│ │
│ Teks isi artikel yang nyaman │
│ dibaca dengan tinggi baris yang │
│ cukup lega... │
│ │
│ Gambar oleh: Fotografer X │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Lima tingkat di atas (judul, kutipan, isi, keterangan, + satu lagi pilihanmu) harus tetap tampil normal dalam mode pesawat — bukti kedua font sudah jadi aset lokal, bukan diunduh saat runtime.
Kriteria selesai:
- Kedua font terdaftar di
pubspec.yamldengan bobot yang benar, dan aplikasi tetap tampil benar dalam mode pesawat. - Seluruh gaya teks berasal dari
TextThemeyang disesuaikan lewatcopyWith, bukanTextStyleyang ditulis dari nol. - Ada minimal lima tingkat hierarki yang bisa dibedakan sekilas.
- Tinggi baris teks isi disetel sadar untuk kenyamanan membaca, tidak dibiarkan bawaan.
Petunjuk: google_fonts sangat nyaman untuk prototipe tetapi mengunduh font saat aplikasi berjalan — pengguna pertama di jaringan buruk akan melihat font sistem sesaat, dan pengguna tanpa koneksi tidak pernah melihat fontmu sama sekali. Untuk produksi, unduh berkasnya dan daftarkan sebagai aset. Ingat juga bahwa aset butuh hot restart, bukan hot reload — kalau fontmu tidak muncul, itu tersangka pertama sebelum kamu mencurigai pubspec.yaml.
Variasi 4: Tema Komponen Menyeluruh — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun satu berkas tema yang mengatur setiap komponen yang dipakai aplikasi banking sederhana: app bar, kartu, tombol terisi dan bergaris, kolom input, dialog, snackbar, bottom sheet, chip, dan switch. Buat layar demo yang menampilkan semuanya sekaligus.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ ═══ Bank Demo ═══ │
│ ┌─────────────────────────────┐ │
│ │ Saldo: Rp 12.500.000 │ │
│ └─────────────────────────────┘ │
│ [ Kirim ] ( Tarik ) │
│ Nama [___________] │
│ ⚠ Nomor rekening tidak valid │
│ ▬▬▬▬ │
╰───────────────────────────────────╯...tap "Kirim" → dialog dan snackbar ikut bentuk & warna kartu di atas, tidak tampil beda gaya:
text
┌─────────────────────────────┐
│ Konfirmasi Transaksi? │
│ [Batal] [Konfirmasi] │
└─────────────────────────────┘
┌───────────────────────┐
│ ✓ Transaksi berhasil │
└───────────────────────┘Kriteria selesai:
- Tidak ada satu pun properti
style:ataudecoration:di layar demo. - Kolom input punya batas, sudut, dan warna fokus yang konsisten di seluruh aplikasi tanpa diulang di tiap kolom.
- Dialog dan snackbar mengikuti bentuk dan warna yang sama dengan kartu.
- Berkas tema terpisah dari
main.dartdan tidak lebih dari satu berkas.
Petunjuk: Kolom input adalah yang paling sering luput: InputDecorationTheme mengatur seluruh TextField dan TextFormField sekaligus, termasuk warna saat fokus dan saat error. Begitu kamu menyetelnya, seluruh formulir di aplikasi langsung seragam — dan lebih penting lagi, formulir yang kamu tulis enam bulan lagi akan otomatis ikut seragam tanpa kamu ingat apa pun.
Variasi 5: Pemilih Tema oleh Pengguna — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Beri pengguna pilihan lima tema warna dan tiga ukuran teks, tersimpan dan dipulihkan saat aplikasi dibuka kembali. Perubahan harus terlihat seketika di seluruh aplikasi, termasuk layar yang sedang terbuka di belakang layar pengaturan.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ Pilih Tema: │
│ (Biru)(Hijau)(Ungu)(Oranye)(Merah)│
│ Ukuran Teks: (Kecil)(Sedang)(Besar)│
│ │
│ ┌─ Pratinjau ─────────────────┐ │
│ │ Contoh tampilan tema ini │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Pilih "Hijau" + "Besar" → layar Beranda yang tadi terbuka di belakang layar pengaturan ini ikut berubah seketika, dan pilihan ini tetap tersimpan setelah aplikasi ditutup paksa lalu dibuka lagi.
Kriteria selesai:
- Kelima tema diturunkan dari lima warna benih, bukan lima objek tema yang ditulis manual.
- Ukuran teks menskalakan seluruh
TextThemesecara proporsional, bukan hanya satu gaya. - Pilihan bertahan setelah aplikasi ditutup paksa lalu dibuka lagi.
- Layar pratinjau menunjukkan tampilan tema sebelum dipilih.
Petunjuk: Menskalakan seluruh TextTheme sekaligus bisa dilakukan dengan memetakan setiap gayanya lewat copyWith pada ukuran font — atau, lebih rapi lagi, dengan MediaQuery yang menimpa faktor skala teks untuk seluruh subpohon. Yang kedua juga menghormati pengaturan aksesibilitas perangkat, yang membuatnya pilihan yang lebih baik meski terlihat kurang langsung.
Ikhtisar
ColorScheme.fromSeedmenurunkan seluruh palet dari satu warna benih. PasanganXdanonXmenjamin kontras yang benar.- Baca tema lewat
Theme.of(context), dan simpan hasilnya di variabel lokal kalau dipakai berkali-kali dalam satubuild(). TextThememenyediakan lima belas gaya bawaan. Sesuaikan dengancopyWith, jangan menulisTextStyledari nol.- Tema komponen (
appBarTheme,cardTheme,elevatedButtonTheme, dan seterusnya) menghilangkan kebutuhan menulisstyle:berulang-ulang. - Beberapa kelas tema kini berakhiran
ThemeData(CardThemeData,DialogThemeData) — periksa ini kalau menyalin kode lama. - Mode gelap cukup butuh
darkThemedanthemeMode.ThemeMode.systemmengikuti pengaturan perangkat. google_fontscepat tetapi mengunduh saat runtime; untuk produksi, daftarkan berkas font sebagai aset.- Aset butuh hot restart, bukan hot reload. Folder
2.0x/dan3.0x/memberikan gambar tajam di layar padat secara otomatis. cached_network_imagemencegah pengunduhan ulang di daftar yang digulir.FittedBoxmengecilkan teks agar muat, berbeda dariellipsisyang memotongnya.flutter_launcher_iconsmenghasilkan seluruh ukuran ikon peluncur dari satu PNG 1024×1024.
Berikutnya: Bab 10 — Responsive & Adaptive UI, agar aplikasimu tidak hanya bagus di satu ukuran layar.
Transkrip asli
Disintesis dari 1_flutter_core/4_expense-tracker_interactive-widget-theming.md (video 27–32), 1_flutter_zero-to-hero/9_package-management-and-modifications.md (Lottie, FittedBox, Launcher Icon, CircleAvatar), dan 1_flutter_core/2_basics.md (video 60, Google Fonts). Lihat PDF Flutter Core dan PDF Zero to Hero.