Tampilan
Bab 5 — State & StatefulWidget
Sampai bab lalu, semua yang kamu bangun bersifat statis. Ia digambar sekali dan tidak pernah berubah. Bab ini adalah titik di mana aplikasimu menjadi hidup — dan juga titik di mana banyak orang tersandung, karena mekanisme perubahan di Flutter bekerja dengan cara yang mungkin berbeda dari yang kamu duga.
Kenapa StatelessWidget tidak cukup
Mari mulai dari kegagalan. Perhatikan kode berikut:
dart
class PenghitungSalah extends StatelessWidget {
const PenghitungSalah({super.key});
@override
Widget build(BuildContext context) {
int hitungan = 0; // ← variabel lokal di dalam build
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$hitungan', style: const TextStyle(fontSize: 48)),
ElevatedButton(
onPressed: () {
hitungan++; // nilainya memang bertambah...
print('Sekarang: $hitungan'); // ...dan terbukti di konsol
},
child: const Text('Tambah'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Jalankan ini dan kamu akan melihat hal yang membingungkan: konsol mencetak 1, 2, 3, 4 — nilainya benar-benar bertambah — tetapi angka di layar tetap nol.
Ada dua masalah di sini, dan keduanya perlu kamu pahami.
Masalah pertama: Flutter tidak tahu harus menggambar ulang. Flutter tidak mengawasi variabelmu. Ia tidak punya cara mendeteksi bahwa hitungan berubah. Selama tidak ada yang memberi tahu, ia menganggap tampilan masih benar.
Masalah kedua: variabelnya hilang. hitungan dideklarasikan di dalam build(). Seandainya Flutter menggambar ulang, build() dipanggil lagi dari awal dan hitungan kembali diinisialisasi ke nol.
Jadi kita butuh dua hal: tempat menyimpan nilai yang bertahan lintas pembangunan ulang, dan cara memberi tahu Flutter bahwa ada yang berubah. StatefulWidget menyediakan keduanya.
StatefulWidget: dua kelas, satu tujuan
StatefulWidget selalu ditulis sebagai sepasang kelas. Ini terasa bertele-tele di awal, tetapi ada alasannya.
dart
class Penghitung extends StatefulWidget {
const Penghitung({super.key});
@override
State<Penghitung> createState() => _PenghitungState();
}
class _PenghitungState extends State<Penghitung> {
int _hitungan = 0; // ← hidup di luar build(), jadi bertahan
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$_hitungan', style: const TextStyle(fontSize: 48)),
ElevatedButton(
onPressed: () {
setState(() { // ← memberi tahu Flutter
_hitungan++;
});
},
child: const Text('Tambah'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Sekarang angkanya berubah di layar.
Kenapa harus dua kelas?
Ingat dari Bab 3: widget itu murah dan sering dibuang. Flutter membuang serta membuat ulang objek widget berkali-kali. Kalau state disimpan di dalam widget, ia akan ikut terbuang setiap kali.
Karena itu Flutter memisahkan keduanya:
┌───────────────────────────────────────────────────────────┐
│ Penghitung (StatefulWidget) │
│ • Objek konfigurasi, tidak berubah (immutable) │
│ • DIBUANG & DIBUAT ULANG berkali-kali │
│ • Menyimpan properti yang diberikan induk │
└──────────────────────────┬────────────────────────────────┘
│ createState() — sekali saja
▼
┌───────────────────────────────────────────────────────────┐
│ _PenghitungState (State) │
│ • BERTAHAN selama widget masih di pohon │
│ • Menyimpan nilai yang berubah-ubah │
│ • Punya build(), setState(), dan metode siklus hidup │
└───────────────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Objek State dibuat sekali dan tetap hidup, sementara objek widget di atasnya boleh diganti sesering mungkin. Itulah kenapa nilai di dalamnya bertahan.
Garis bawah pada _PenghitungState dan _hitungan adalah konvensi Dart untuk privat — hanya bisa diakses dari berkas yang sama. State seharusnya memang tidak diakses dari luar.
Mengakses properti widget dari State
Kelas State bisa membaca properti widget-nya lewat widget:
dart
class SalamPengguna extends StatefulWidget {
const SalamPengguna({super.key, required this.nama});
final String nama; // properti dari induk
@override
State<SalamPengguna> createState() => _SalamPenggunaState();
}
class _SalamPenggunaState extends State<SalamPengguna> {
int _jumlahSapaan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Akses properti widget lewat `widget.`
Text('Halo, ${widget.nama}!'),
Text('Disapa $_jumlahSapaan kali'),
ElevatedButton(
onPressed: () => setState(() => _jumlahSapaan++),
child: const Text('Sapa lagi'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Memahami setState
setState sering disalahpahami sebagai "fungsi yang mengubah nilai". Bukan. Ia adalah pemberitahuan.
dart
setState(() {
_hitungan++;
});1
2
3
2
3
Yang sebenarnya terjadi:
- Fungsi di dalamnya dijalankan — di sinilah kamu mengubah nilai.
- Flutter menandai widget ini sebagai "kotor" (perlu digambar ulang).
- Pada frame berikutnya, Flutter memanggil
build()lagi. - Flutter membandingkan hasil baru dengan yang lama dan memperbarui hanya bagian yang berbeda.
Pengguna menekan tombol
│
▼
setState(() { ... })
│
┌───────┴───────┐
▼ ▼
nilai widget ditandai
diubah "perlu dibangun ulang"
└───────┬───────┘
▼
Frame berikutnya
│
▼
build() dipanggil ulang
│
▼
Flutter membandingkan & memperbarui layar1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Karena itu, mengubah nilai di luar setState tidak berdampak pada tampilan:
dart
// ❌ Nilainya berubah, tetapi layar tidak
onPressed: () {
_hitungan++;
}
// ✅ Nilai berubah DAN layar diperbarui
onPressed: () {
setState(() {
_hitungan++;
});
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Jangan lakukan pekerjaan berat di dalam setState
Fungsi di dalam setState harus sesingkat mungkin — cukup penugasan nilai. Jangan melakukan pemanggilan jaringan atau perhitungan lama di dalamnya, karena itu menahan proses penggambaran.
dart
// ❌ Buruk — await di dalam setState
setState(() async {
_data = await ambilData();
});
// ✅ Benar — kerjakan dulu, baru umumkan
final hasil = await ambilData();
if (!mounted) return;
setState(() {
_data = hasil;
});1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Siklus hidup StatefulWidget
Objek State melewati beberapa tahap sejak lahir sampai dilepas. Empat di antaranya akan sering kamu pakai.
createState()
│
▼
initState() ← sekali, sebelum build pertama
│
▼
┌─ build() ─┐ ← berkali-kali
│ │
│ setState() ──┐
│ │ │
└───────────┘◄──┘
│
▼
didUpdateWidget() ← ketika induk memberi konfigurasi baru
│
▼
deactivate()
│
▼
dispose() ← sekali, saat dilepas dari pohon1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
initState
Dipanggil sekali saja, sebelum build() pertama. Tempat yang tepat untuk inisialisasi yang tidak boleh diulang.
dart
class _DaftarBeritaState extends State<DaftarBerita> {
late final TextEditingController _pengendali;
List<Berita> _berita = [];
bool _memuat = true;
@override
void initState() {
super.initState(); // ← selalu panggil ini duluan
_pengendali = TextEditingController();
_muatBerita();
}
Future<void> _muatBerita() async {
final hasil = await layananBerita.ambilSemua();
if (!mounted) return;
setState(() {
_berita = hasil;
_memuat = false;
});
}
// ...
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Dua aturan penting:
- Selalu panggil
super.initState()lebih dulu. - Jangan panggil
setState()di dalaminitState(). Widget belum selesai dibangun; cukup tugaskan nilainya langsung.
Kenapa late
Kata kunci late berarti "variabel ini akan diisi sebelum dipakai, percayalah". Ini memungkinkan _pengendali bertipe non-nullable meskipun belum diisi saat deklarasi. Kalau ternyata kamu memakainya sebelum diisi, Dart melempar error yang jelas — jauh lebih baik daripada menjadikannya nullable dan menaburkan tanda seru di mana-mana.
dispose
Kebalikan dari initState. Dipanggil ketika widget dilepas dari pohon selamanya. Ini tempat membersihkan apa pun yang bisa bocor.
dart
@override
void dispose() {
_pengendali.dispose(); // lepaskan controller
_timer?.cancel(); // hentikan timer
_langganan?.cancel(); // hentikan stream subscription
super.dispose(); // ← panggil ini TERAKHIR
}1
2
3
4
5
6
7
2
3
4
5
6
7
Perhatikan urutannya: super.initState() dipanggil pertama, super.dispose() dipanggil terakhir. Lupa memanggil dispose pada TextEditingController, AnimationController, atau StreamSubscription adalah penyebab kebocoran memori paling umum di aplikasi Flutter.
didUpdateWidget
Dipanggil ketika induk membangun ulang dan memberikan konfigurasi baru ke widget ini. Berguna ketika state internalmu bergantung pada properti dari luar.
dart
@override
void didUpdateWidget(covariant ProfilPengguna oldWidget) {
super.didUpdateWidget(oldWidget);
// Kalau ID penggunanya berganti, muat ulang datanya
if (oldWidget.idPengguna != widget.idPengguna) {
_muatProfil();
}
}1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
Tanpa pemeriksaan if itu, kamu akan memuat ulang data pada setiap pembangunan ulang — pemborosan yang sulit dilacak.
mounted
Bukan metode siklus hidup, tetapi properti yang sangat penting. Ia bernilai true selama objek State masih terpasang di pohon.
dart
Future<void> _kirim() async {
final hasil = await api.kirimData();
// Pengguna mungkin sudah menutup halaman selama menunggu
if (!mounted) return;
setState(() => _status = hasil);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Terkirim')),
);
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Aturannya sederhana: setiap kali kamu memakai context atau setState sesudah sebuah await, periksa mounted terlebih dahulu.
Contoh lengkap: aplikasi kuis
Mari gabungkan semuanya menjadi aplikasi kecil yang utuh — kuis dengan beberapa soal, yang melacak jawaban dan menampilkan hasil di akhir.
Struktur berkasnya:
lib/
├── main.dart
├── models/
│ └── soal.dart
├── data/
│ └── soal_kuis.dart
└── screens/
├── layar_mulai.dart
├── layar_soal.dart
└── layar_hasil.dart1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
Model data
dart
// lib/models/soal.dart
class Soal {
const Soal(this.teks, this.jawaban);
final String teks;
final List<String> jawaban;
/// Jawaban benar selalu disimpan di indeks 0,
/// tetapi diacak sebelum ditampilkan agar tidak ketahuan.
List<String> get jawabanAcak {
final salinan = List<String>.from(jawaban);
salinan.shuffle();
return salinan;
}
String get jawabanBenar => jawaban[0];
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Data soal
dart
// lib/data/soal_kuis.dart
import '../models/soal.dart';
const daftarSoal = [
Soal(
'Apa fungsi utama widget Scaffold?',
[
'Menyediakan kerangka dasar halaman Material',
'Mengatur navigasi antar halaman',
'Menyimpan state aplikasi',
'Mengelola koneksi jaringan',
],
),
Soal(
'Kapan setState() perlu dipanggil?',
[
'Saat nilai berubah dan tampilan harus diperbarui',
'Setiap kali build() dijalankan',
'Hanya di dalam initState()',
'Ketika aplikasi pertama kali dibuka',
],
),
Soal(
'Apa perbedaan Expanded dan Flexible?',
[
'Expanded wajib mengisi jatahnya, Flexible boleh lebih kecil',
'Keduanya sama persis',
'Flexible hanya untuk Row, Expanded hanya untuk Column',
'Expanded hanya bekerja pada gambar',
],
),
Soal(
'Di mana controller sebaiknya dibersihkan?',
[
'Di dalam dispose()',
'Di dalam build()',
'Di dalam initState()',
'Tidak perlu dibersihkan',
],
),
];1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
Tombol jawaban
Widget kecil ini menerima teks dan sebuah fungsi. Ia tidak tahu apa yang terjadi ketika ditekan — itu urusan induknya. Pola "anak melapor, induk memutuskan" yang dibahas di Bab 2.
dart
// lib/widgets/tombol_jawaban.dart
import 'package:flutter/material.dart';
class TombolJawaban extends StatelessWidget {
const TombolJawaban({
super.key,
required this.teks,
required this.saatDipilih,
});
final String teks;
final void Function() saatDipilih;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: saatDipilih,
style: ElevatedButton.styleFrom(
backgroundColor: const Color.fromARGB(255, 33, 1, 95),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 40),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Text(teks, textAlign: TextAlign.center),
),
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Layar soal
Di sinilah state hidup. _indeksSoal menentukan soal mana yang tampil, dan setiap kali jawaban dipilih, indeksnya bertambah lewat setState.
dart
// lib/screens/layar_soal.dart
import 'package:flutter/material.dart';
import '../data/soal_kuis.dart';
import '../widgets/tombol_jawaban.dart';
class LayarSoal extends StatefulWidget {
const LayarSoal({super.key, required this.saatJawabDipilih});
/// Dipanggil setiap kali pengguna memilih jawaban,
/// sehingga induk bisa mencatat riwayatnya.
final void Function(String jawaban) saatJawabDipilih;
@override
State<LayarSoal> createState() => _LayarSoalState();
}
class _LayarSoalState extends State<LayarSoal> {
var _indeksSoal = 0;
void _jawab(String jawaban) {
widget.saatJawabDipilih(jawaban);
setState(() {
_indeksSoal++;
});
}
@override
Widget build(BuildContext context) {
final soal = daftarSoal[_indeksSoal];
return SizedBox(
width: double.infinity,
child: Container(
margin: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Penanda progres
Text(
'Soal ${_indeksSoal + 1} dari ${daftarSoal.length}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.6),
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
soal.teks,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Spread operator membongkar list widget ke dalam children
...soal.jawabanAcak.map((jawaban) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TombolJawaban(
teks: jawaban,
saatDipilih: () => _jawab(jawaban),
),
);
}),
],
),
),
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Layar hasil
dart
// lib/screens/layar_hasil.dart
import 'package:flutter/material.dart';
import '../data/soal_kuis.dart';
class LayarHasil extends StatelessWidget {
const LayarHasil({
super.key,
required this.jawabanDipilih,
required this.saatUlangi,
});
final List<String> jawabanDipilih;
final void Function() saatUlangi;
/// Menggabungkan soal dan jawaban pengguna menjadi ringkasan.
List<Map<String, Object>> get _ringkasan {
return [
for (var i = 0; i < jawabanDipilih.length; i++)
{
'nomor': i,
'pertanyaan': daftarSoal[i].teks,
'jawabanBenar': daftarSoal[i].jawabanBenar,
'jawabanPengguna': jawabanDipilih[i],
}
];
}
@override
Widget build(BuildContext context) {
final total = daftarSoal.length;
final benar = _ringkasan
.where((r) => r['jawabanPengguna'] == r['jawabanBenar'])
.length;
return SizedBox(
width: double.infinity,
child: Container(
margin: const EdgeInsets.all(40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Kamu menjawab $benar dari $total soal dengan benar.',
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
// Daftar hasil per soal
Expanded(
child: SingleChildScrollView(
child: Column(
children: _ringkasan.map((data) {
final tepat =
data['jawabanPengguna'] == data['jawabanBenar'];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 14,
backgroundColor:
tepat ? Colors.lightGreen : Colors.redAccent,
child: Text(
'${(data['nomor'] as int) + 1}',
style: const TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
data['pertanyaan'] as String,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Jawabanmu: ${data['jawabanPengguna']}',
style: const TextStyle(
color: Color.fromARGB(255, 255, 190, 220),
),
),
Text(
'Jawaban benar: ${data['jawabanBenar']}',
style: const TextStyle(
color: Color.fromARGB(255, 180, 220, 255),
),
),
],
),
),
],
),
);
}).toList(),
),
),
),
const SizedBox(height: 20),
TextButton.icon(
onPressed: saatUlangi,
icon: const Icon(Icons.refresh),
label: const Text('Ulangi Kuis'),
style: TextButton.styleFrom(foregroundColor: Colors.white),
),
],
),
),
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
Menyatukan semuanya
Widget terluar mengatur layar mana yang tampil dan menyimpan riwayat jawaban.
dart
// lib/kuis.dart
import 'package:flutter/material.dart';
import 'screens/layar_mulai.dart';
import 'screens/layar_soal.dart';
import 'screens/layar_hasil.dart';
import 'data/soal_kuis.dart';
class Kuis extends StatefulWidget {
const Kuis({super.key});
@override
State<Kuis> createState() => _KuisState();
}
class _KuisState extends State<Kuis> {
final List<String> _jawabanDipilih = [];
var _layarAktif = 'mulai';
void _mulaiKuis() {
setState(() {
_jawabanDipilih.clear();
_layarAktif = 'soal';
});
}
void _catatJawaban(String jawaban) {
_jawabanDipilih.add(jawaban);
// Kalau semua soal sudah dijawab, pindah ke layar hasil
if (_jawabanDipilih.length == daftarSoal.length) {
setState(() {
_layarAktif = 'hasil';
});
}
}
@override
Widget build(BuildContext context) {
// Pilih layar berdasarkan state
Widget layar = LayarMulai(saatMulai: _mulaiKuis);
if (_layarAktif == 'soal') {
layar = LayarSoal(saatJawabDipilih: _catatJawaban);
} else if (_layarAktif == 'hasil') {
layar = LayarHasil(
jawabanDipilih: _jawabanDipilih,
saatUlangi: _mulaiKuis,
);
}
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromARGB(255, 78, 13, 151),
Color.fromARGB(255, 107, 15, 168),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: layar,
),
),
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
Perhatikan pola pemilihan layar di build(). Sebuah variabel Widget diisi berdasarkan state, lalu dipakai di pohon. Ini cara paling sederhana melakukan navigasi tanpa Navigator — cocok untuk alur yang sangat linier seperti kuis. Navigasi yang sesungguhnya dibahas di Bab 7.
Perhatikan juga _catatJawaban: penambahan ke list dilakukan di luarsetState, dan setState hanya dipanggil ketika layarnya benar-benar berganti. Tidak ada gunanya menggambar ulang kalau tampilan tidak berubah.
Menampilkan konten secara kondisional
Ada beberapa cara menampilkan widget hanya ketika syarat tertentu terpenuhi.
Ekspresi ternari — untuk memilih antara dua widget:
dart
Center(
child: _sudahLogin
? const Text('Selamat datang kembali')
: ElevatedButton(
onPressed: _login,
child: const Text('Masuk'),
),
)1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
if di dalam list — untuk menyisipkan atau melewatkan:
dart
Column(
children: [
const Text('Profil'),
if (_pesanError != null)
Text(_pesanError!, style: const TextStyle(color: Colors.red)),
if (_memuat)
const CircularProgressIndicator()
else
const Text('Data siap'),
],
)1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Variabel widget — ketika logikanya bercabang banyak, seperti pada _KuisState di atas. Ini paling mudah dibaca ketika ada lebih dari dua cabang.
Pola tiga-status yang muncul di hampir setiap aplikasi yang memuat data:
dart
@override
Widget build(BuildContext context) {
Widget isi = const Center(child: CircularProgressIndicator());
if (_pesanError != null) {
isi = Center(child: Text(_pesanError!));
} else if (_data.isEmpty && !_memuat) {
isi = const Center(child: Text('Belum ada data.'));
} else if (!_memuat) {
isi = ListView.builder(
itemCount: _data.length,
itemBuilder: (ctx, i) => ListTile(title: Text(_data[i].judul)),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Daftar')),
body: isi,
);
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Pola ini akan kamu temui lagi di Bab 14.
ValueNotifier: alternatif ringan
Untuk state yang sangat sederhana dan terlokalisasi, ada cara yang lebih hemat daripada setState: ValueNotifier berpasangan dengan ValueListenableBuilder.
dart
class PenghitungRingan extends StatefulWidget {
const PenghitungRingan({super.key});
@override
State<PenghitungRingan> createState() => _PenghitungRinganState();
}
class _PenghitungRinganState extends State<PenghitungRingan> {
final _hitungan = ValueNotifier<int>(0);
@override
void dispose() {
_hitungan.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Bagian ini TIDAK dibangun ulang saat nilai berubah
const Text(
'Penghitung Sederhana',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// Hanya bagian ini yang dibangun ulang
ValueListenableBuilder<int>(
valueListenable: _hitungan,
builder: (context, nilai, child) {
return Text('$nilai', style: const TextStyle(fontSize: 48));
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _hitungan.value++, // tanpa setState
child: const Text('Tambah'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Keunggulannya: hanya widget di dalam ValueListenableBuilder yang dibangun ulang, bukan seluruh build(). Untuk halaman yang berat dengan satu nilai kecil yang sering berubah, ini bisa terasa perbedaannya.
Keterbatasannya: ia hanya mengelola satu nilai dan tidak menyelesaikan masalah berbagi state antar-halaman. Untuk itu kamu butuh solusi di Bagian III.
Memecah widget untuk membatasi rebuild
Ini teknik optimasi yang layak kamu biasakan sejak awal. Ketika setState dipanggil, seluruh isi build() dijalankan ulang — termasuk bagian yang tidak ada hubungannya dengan nilai yang berubah.
dart
// ❌ Header ikut dibangun ulang setiap kali hitungan berubah
class _HalamanState extends State<Halaman> {
int _hitungan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
// Widget berat yang isinya tidak pernah berubah
Container(
height: 200,
decoration: const BoxDecoration(/* gradasi, bayangan, gambar */),
child: const Text('Header'),
),
Text('$_hitungan'),
ElevatedButton(
onPressed: () => setState(() => _hitungan++),
child: const Text('Tambah'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Dua cara memperbaikinya:
Pertama, tandai dengan const. Widget const dilewati saat pembangunan ulang karena Flutter tahu isinya mustahil berubah.
dart
const HeaderStatis(), // dilewati saat rebuild1
Kedua, pecah menjadi widget terpisah dan pindahkan state ke bagian terkecil yang membutuhkannya:
dart
class Halaman extends StatelessWidget {
const Halaman({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
HeaderStatis(), // tidak pernah dibangun ulang
BagianPenghitung(), // punya state-nya sendiri
],
);
}
}
class BagianPenghitung extends StatefulWidget {
const BagianPenghitung({super.key});
@override
State<BagianPenghitung> createState() => _BagianPenghitungState();
}
class _BagianPenghitungState extends State<BagianPenghitung> {
int _hitungan = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('$_hitungan'),
ElevatedButton(
onPressed: () => setState(() => _hitungan++),
child: const Text('Tambah'),
),
],
);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Sekarang setState hanya menyentuh BagianPenghitung. HeaderStatis tidak tersentuh sama sekali. Prinsipnya: taruh state serendah mungkin di pohon, sedekat mungkin dengan widget yang benar-benar memakainya.
Mekanisme di balik optimasi ini dijelaskan lebih dalam di Bab 12.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Pengatur Waktu Istirahat — ⭐⭐ · 40–60 menit
Tantangan: Buat pengatur waktu istirahat sederhana. Ada tombol mulai, jeda, dan reset. Angka hitung mundur diperbarui setiap detik, dan ketika mencapai nol, tampilannya berubah — warna, teks, apa pun yang jelas terlihat.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ │
│ 04:59 │
│ │
│ │
│ [Mulai] [Jeda] [Reset] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
...saat mencapai nol, hanya bagian angka yang berubah tampilan (tombol di bawahnya tidak ikut rebuild):
text
╭───────────────────────────────────╮
│ 00:00 │
│ WAKTUNYA! │
│ [Mulai] [Jeda] [Reset] │
╰───────────────────────────────────╯1
2
3
4
5
2
3
4
5
Kriteria selesai:
- Timer dibuat di dalam
State, dan dibatalkan didispose(). Menutup layar saat hitungan sedang berjalan tidak boleh meninggalkan timer yang masih hidup. - Menekan mulai dua kali berturut-turut tidak membuat hitungan berjalan dua kali lebih cepat.
- Angka hitung mundur berada di widget terpisah, sehingga
setStateper detik tidak membangun ulang tombol-tombolnya. - Tampilan "selesai" dipilih dengan salah satu dari tiga pola pada bab ini — ternari,
ifdi dalam list, atau variabel widget — bukan dengan membangun dua layar terpisah.
Petunjuk: Timer.periodic dari dart:async adalah alat yang tepat, dan ia mengembalikan objek yang harus kamu simpan supaya bisa dibatalkan nanti. Menekan mulai dua kali membuat dua timer hidup bersamaan kecuali kamu memeriksa dulu apakah sudah ada yang berjalan — atau membatalkan yang lama sebelum membuat yang baru. Untuk membatasi rebuild, ada dua jalan: memecah angka ke StatefulWidget sendiri, atau memakai ValueNotifier dengan ValueListenableBuilder yang membungkus hanya angkanya. Yang kedua lebih ringkas untuk satu nilai seperti ini.
Variasi 2: Pemesanan Tiket Konser — ⭐⭐ · 30–45 menit
Tantangan: Buat pemilih jumlah tiket dengan tombol tambah dan kurang, batas maksimal enam tiket per transaksi, dan total harga yang ikut berubah seketika. Ketika batas tercapai, tombol tambah harus benar-benar nonaktif dan muncul keterangan alasannya.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Jumlah Tiket │
│ [−] 3 [+] │
│ Total: Rp 450.000 │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
...saat mencapai batas 6 tiket, tombol + benar-benar nonaktif:
text
╭───────────────────────────────────╮
│ [−] 6 [+ pudar] │
│ Sudah mencapai batas maksimal │
╰───────────────────────────────────╯1
2
3
4
2
3
4
Kriteria selesai:
- Tombol dinonaktifkan dengan
onPressed: null, bukan dengan fungsi kosong atau sekadar mengubah warnanya. - Total harga adalah nilai turunan yang dihitung dari jumlah tiket, bukan variabel kedua yang ikut diperbarui di setiap
setState. - Keterangan batas muncul dan hilang lewat
ifdi dalam daftar anak, bukan dengan menyisipkan widget kosong.
Petunjuk: Perbedaan antara onPressed: null dan onPressed: () {} tidak terlihat di kode tetapi sangat terlihat di layar: yang pertama membuat Flutter menerapkan gaya "nonaktif" secara otomatis, yang kedua menghasilkan tombol yang tampak bisa ditekan padahal tidak melakukan apa-apa. Untuk total harga, tulis ia sebagai getter — begitu ia menjadi variabel yang disimpan, kamu punya dua sumber kebenaran yang harus selalu sinkron.
Variasi 3: Stopwatch dengan Lap — ⭐⭐ · 40–60 menit
Tantangan: Bangun stopwatch dengan ketelitian sepersepuluh detik, tombol mulai/jeda/reset, dan tombol lap yang mencatat waktu putaran ke dalam daftar di bawahnya. Waktu terus berjalan dengan benar meskipun kamu menjeda lalu melanjutkan beberapa kali.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ 00:12.4 │
│ [Mulai/Jeda] [Lap/Reset] │
│ ┌─────────────────────────────┐ │
│ │ Lap 3 00:03.1 │ │
│ │ Lap 2 00:04.0 │ │
│ │ Lap 1 00:05.3 │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Kriteria selesai:
- Waktu dihitung dari selisih waktu nyata, bukan dari menjumlahkan tick — dan kamu sudah membuktikannya tetap akurat setelah berjalan dua menit.
- Daftar lap menampilkan waktu putaran (selisih dari lap sebelumnya), bukan hanya waktu total saat tombol ditekan.
- Timer dibatalkan di
dispose(), dan menekan reset saat berjalan tidak meninggalkan timer yatim.
Petunjuk: Menjumlahkan tick timer akan melenceng, karena Timer.periodic tidak menjamin ketepatan interval — ia menjamin "paling cepat sekian". Simpan waktu mulai dan hitung selisihnya terhadap sekarang di setiap tick; timer-nya hanya bertugas memicu gambar ulang. Untuk jeda, simpan akumulasi durasi yang sudah berjalan sebelum jeda, lalu tambahkan ke selisih setelah lanjut.
Variasi 4: Formulir Bertahap — ⭐⭐⭐ · 45–60 menit
Tantangan: Buat alur pengisian data tiga langkah dalam satu layar: identitas, alamat, konfirmasi. Ada indikator progres di atas, tombol lanjut dan kembali, dan data yang sudah diisi tidak boleh hilang saat berpindah antar langkah.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ ●───────○───────○ │
│ │
│ Langkah 1: Identitas │
│ Nama : [___________] │
│ Email: [___________] │
│ │
│ [Lanjut →] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
...mundur ke langkah 1 dari langkah 2, isian "Nama" & "Email" tadi tetap terisi (bukan kosong lagi):
text
╭───────────────────────────────────╮
│ ○───────●───────○ │
│ Langkah 2: Alamat │
│ [← Kembali] [Lanjut →] │
╰───────────────────────────────────╯1
2
3
4
5
2
3
4
5
Kriteria selesai:
- Ketiga langkah adalah widget terpisah, dan layar induk memilih mana yang ditampilkan lewat variabel widget atau
switch. - Data bertahan saat mundur lalu maju lagi.
- Tombol kembali tidak muncul di langkah pertama, dan tombol lanjut berubah menjadi "Kirim" di langkah terakhir.
- Setiap
TextEditingControllerdi-dispose()tepat sekali, tidak lebih.
Petunjuk: Kriteria kedua adalah inti latihan ini. Kalau tiap langkah punya controller-nya sendiri di dalam State masing-masing, data akan hilang ketika widget langkah itu dilepas dari pohon. Jawabannya adalah menaikkan state ke induk — pola yang akan kamu lakukan berulang kali sepanjang Bagian I, dan yang akan terasa makin berat sampai Bagian III memberimu alat yang lebih baik.
Variasi 5: Papan Lampu — ⭐⭐⭐ · 45–60 menit
Tantangan: Buat kisi lima kali lima berisi lampu yang bisa dinyalakan dengan ketukan. Tampilkan jumlah lampu yang menyala di bagian atas. Yang diuji di sini adalah membatasi rebuild: mengetuk satu lampu tidak boleh membangun ulang dua puluh empat lampu lainnya.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Menyala: 4 / 25 │
│ │
│ ○ ○ ● ○ ○ │
│ ○ ● ○ ○ ○ │
│ ○ ○ ○ ● ○ │
│ ● ○ ○ ○ ○ │
│ ○ ○ ○ ○ ○ │
│ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
(ketuk satu ○ → cuma lampu itu dan angka "Menyala: x/25" di atas yang rebuild, 24 lampu lain diam)
Kriteria selesai:
- Mengetuk satu lampu hanya membangun ulang lampu itu dan penghitung di atas — kamu sudah membuktikannya dengan
debugPrintdibuild()tiap lampu. - Setiap lampu adalah widget terpisah yang mengelola keadaannya sendiri.
- Penghitung tetap akurat meskipun state lampu tidak tinggal di induknya.
Petunjuk: Kriteria pertama dan ketiga tampak bertentangan: kalau tiap lampu menyimpan keadaannya sendiri, bagaimana induk tahu berapa yang menyala? Jawaban yang paling langsung untuk materi bab ini adalah ValueNotifier di induk yang dipakai bersama, dengan ValueListenableBuilder yang membungkus hanya penghitungnya. Kalau kamu memilih menaikkan seluruh state ke induk, ukur dulu berapa banyak yang dibangun ulang — hasilnya akan menjelaskan kenapa Bagian III ada.
Variasi 6: Lelang Hitung Mundur — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Buat layar lelang dengan hitung mundur ke waktu berakhir, tombol "naikkan tawaran" yang memanggil operasi asinkron tiruan berdurasi dua detik, dan aturan bahwa tawaran yang tiba setelah waktu habis harus ditolak. Layar bisa ditutup kapan saja, termasuk di tengah operasi asinkron.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Berakhir dalam 00:45 │
│ │
│ Tawaran tertinggi: │
│ Rp 1.250.000 │
│ │
│ ┌─────────────────────────────┐ │
│ │ Menaikkan tawaran... ⏳ │ │
│ └─────────────────────────────┘ │
│ ▬▬▬▬ │
╰───────────────────────────────────╯1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
(tombol tawaran nonaktif selama ⏳ berjalan; layar boleh ditutup di tengah proses ini tanpa error)
Kriteria selesai:
- Setiap sentuhan ke
setStateataucontextsetelahawaitdidahului pemeriksaanmounted. - Menutup layar tepat saat tawaran sedang diproses tidak menghasilkan error apa pun di konsol.
- Tombol tawaran dinonaktifkan selama proses berjalan, sehingga tidak bisa ditekan dua kali.
initStatememanggilsuperpertama,disposememanggilsuperterakhir, dan seluruh sumber daya dibersihkan.
Petunjuk: Kriteria kedua adalah salah satu error paling sering muncul di aplikasi Flutter sungguhan, dan ia hanya terlihat kalau kamu mengujinya dengan sengaja: tekan tombol, lalu segera tekan tombol kembali. await menahan eksekusi, dan ketika ia selesai, widget-nya sudah tidak ada lagi di pohon — setState pada State yang sudah dilepas melempar error. mounted adalah penjaganya, dan kamu akan menemukan padanannya di Bagian III berupa isClosed pada BLoC.
Ikhtisar
StatelessWidgettidak bisa berubah karena variabel di dalambuild()hilang setiap kali dibangun ulang, dan Flutter tidak mengawasi variabelmu.StatefulWidgetditulis sebagai dua kelas: widget (konfigurasi, sering dibuang) danState(nilai berubah, bertahan hidup).- Akses properti widget dari dalam
Statelewatwidget.namaProperti. setStateadalah pemberitahuan, bukan pengubah nilai. Ia menandai widget perlu digambar ulang. Isi fungsinya harus singkat — tanpaawait.initState()untuk inisialisasi sekali (panggilsuperpertama);dispose()untuk pembersihan (panggilsuperterakhir).- Lupa
disposepada controller, timer, atau stream subscription adalah penyebab kebocoran memori paling umum. - Periksa
mountedsebelum memakaicontextatausetStatesetelahawait. - Tampilkan konten kondisional dengan ternari (dua pilihan),
ifdi dalam list (sisip/lewati), atau variabel widget (banyak cabang). ValueNotifier+ValueListenableBuildermembangun ulang hanya sebagian kecil pohon — berguna untuk satu nilai yang sering berubah.- Taruh state serendah mungkin di pohon. Pecah widget agar
setStatetidak menyentuh bagian yang tidak relevan.
Berikutnya: Bab 6 — Input Pengguna & Form, tempat state bertemu dengan masukan dari pengguna.
Transkrip asli
Disintesis dari 1_flutter_core/2_basics.md (video 34–48, 58) dan 1_flutter_zero-to-hero/5_states.md. Lihat PDF Flutter Core dan PDF Zero to Hero.