Tampilan
Bab 6 — Input Pengguna & Form
Aplikasi yang hanya menampilkan sesuatu itu setengah aplikasi. Bab ini membahas separuh lainnya: bagaimana menerima masukan dari pengguna, memvalidasinya, dan mengambil hasilnya dengan aman.
Kita mulai dari widget input satu per satu, lalu naik ke Form yang mengelola semuanya sekaligus.
TextField: menerima teks
Widget paling dasar untuk memasukkan teks:
dart
TextField(
decoration: const InputDecoration(
labelText: 'Nama lengkap',
hintText: 'Contoh: Firnanda',
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
)InputDecoration menangani hampir seluruh penampilannya:
| Properti | Fungsi |
|---|---|
labelText | Label yang mengambang ke atas saat difokuskan |
hintText | Teks abu-abu yang hilang saat mengetik |
helperText | Petunjuk kecil di bawah kolom |
errorText | Pesan kesalahan berwarna merah |
prefixIcon / suffixIcon | Ikon di kiri atau kanan |
prefixText / suffixText | Teks tetap, misalnya Rp atau kg |
border | Gaya garis tepi |
filled / fillColor | Latar belakang berwarna |
Membaca nilainya
Ada dua cara, dan pilihannya menentukan gaya kodemu.
Cara pertama: onChanged — dipanggil pada setiap ketukan tombol.
dart
class _FormulirState extends State<Formulir> {
String _nama = '';
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
onChanged: (nilai) {
_nama = nilai; // simpan setiap ketikan
},
decoration: const InputDecoration(labelText: 'Nama'),
),
ElevatedButton(
onPressed: () => print('Nama: $_nama'),
child: const Text('Kirim'),
),
],
);
}
}Ini bekerja, tetapi punya kelemahan: kamu harus menyimpan satu variabel untuk setiap kolom, dan menyetel nilai awal jadi merepotkan.
Cara kedua: TextEditingController — objek yang mengelola teks untukmu. Ini yang direkomendasikan untuk formulir sungguhan.
dart
class _FormulirState extends State<Formulir> {
final _pengendaliNama = TextEditingController();
final _pengendaliEmail = TextEditingController();
@override
void dispose() {
// ⚠️ Wajib — kalau lupa, memori bocor
_pengendaliNama.dispose();
_pengendaliEmail.dispose();
super.dispose();
}
void _kirim() {
final nama = _pengendaliNama.text.trim();
final email = _pengendaliEmail.text.trim();
print('Nama: $nama, Email: $email');
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
controller: _pengendaliNama,
decoration: const InputDecoration(labelText: 'Nama'),
),
const SizedBox(height: 12),
TextField(
controller: _pengendaliEmail,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 20),
ElevatedButton(onPressed: _kirim, child: const Text('Kirim')),
],
);
}
}Dengan controller kamu bisa membaca (.text), mengisi (.text = '...'), mengosongkan (.clear()), dan memantau perubahan (.addListener()) — semuanya lewat satu objek.
Jangan lupa dispose
Setiap TextEditingController yang kamu buat wajib di-dispose(). Ini kesalahan yang paling sering terjadi dan paling sulit terdeteksi, karena aplikasinya tetap berjalan — hanya memakan memori makin lama makin banyak.
Jenis papan ketik dan pembatasan input
Menyesuaikan papan ketik meningkatkan pengalaman pengguna secara signifikan:
dart
TextField(
controller: _pengendaliJumlah,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: const InputDecoration(
labelText: 'Jumlah',
prefixText: 'Rp ',
),
)Nilai keyboardType yang umum: text, number, emailAddress, phone, multiline, url, dan visiblePassword.
Untuk membatasi karakter yang boleh diketik, gunakan inputFormatters:
dart
import 'package:flutter/services.dart';
TextField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly, // hanya angka
LengthLimitingTextInputFormatter(12), // maksimal 12 karakter
],
)Beberapa properti berguna lainnya:
dart
TextField(
obscureText: true, // untuk kata sandi
maxLines: 4, // kolom multi-baris
minLines: 2,
maxLength: 200, // menampilkan penghitung karakter
textCapitalization: TextCapitalization.words,
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.next, // tombol "berikutnya" di papan ketik
)Checkbox, Switch, Slider
Ketiganya mengikuti pola yang sama: kamu menyimpan nilainya di state, meneruskan ke widget lewat value, dan memperbaruinya di onChanged.
dart
class _PengaturanState extends State<Pengaturan> {
bool _setuju = false;
bool _notifikasi = true;
double _volume = 50;
@override
Widget build(BuildContext context) {
return Column(
children: [
// --- Checkbox ---
Row(
children: [
Checkbox(
value: _setuju,
onChanged: (nilai) => setState(() => _setuju = nilai ?? false),
),
const Expanded(child: Text('Saya setuju dengan syarat & ketentuan')),
],
),
// Versi ListTile — lebih rapi dan seluruh baris bisa diketuk
CheckboxListTile(
value: _setuju,
onChanged: (nilai) => setState(() => _setuju = nilai ?? false),
title: const Text('Saya setuju'),
subtitle: const Text('Baca syarat & ketentuan terlebih dahulu'),
controlAffinity: ListTileControlAffinity.leading,
),
// --- Switch ---
SwitchListTile(
value: _notifikasi,
onChanged: (nilai) => setState(() => _notifikasi = nilai),
title: const Text('Notifikasi'),
secondary: const Icon(Icons.notifications),
),
// --- Slider ---
Slider(
value: _volume,
min: 0,
max: 100,
divisions: 20, // hentakan tiap 5 satuan
label: '${_volume.round()}', // gelembung nilai saat digeser
onChanged: (nilai) => setState(() => _volume = nilai),
),
Text('Volume: ${_volume.round()}%'),
],
);
}
}Perhatikan nilai ?? false pada Checkbox. Callback-nya memberi bool? karena checkbox bisa berada dalam keadaan tak-tentu (tristate). Kalau kamu tidak memakai fitur itu, ?? false adalah cara aman menanganinya.
Widget adaptif
Switch.adaptive, Slider.adaptive, dan CircularProgressIndicator.adaptive otomatis menampilkan gaya Cupertino di iOS dan Material di Android. Satu baris untuk terasa lebih asli di kedua platform:
dart
Switch.adaptive(
value: _notifikasi,
onChanged: (v) => setState(() => _notifikasi = v),
)Pola adaptif yang lebih menyeluruh dibahas di Bab 10.
DropdownButton
Untuk memilih satu dari beberapa opsi:
dart
enum Kategori { makanan, transportasi, hiburan, kerja }
class _PilihKategoriState extends State<PilihKategori> {
Kategori _terpilih = Kategori.makanan;
@override
Widget build(BuildContext context) {
return DropdownButton<Kategori>(
value: _terpilih,
items: Kategori.values.map((kategori) {
return DropdownMenuItem<Kategori>(
value: kategori,
child: Text(kategori.name.toUpperCase()),
);
}).toList(),
onChanged: (nilai) {
if (nilai == null) return;
setState(() => _terpilih = nilai);
},
);
}
}Tiga hal yang wajib benar agar dropdown berfungsi:
valueharus salah satu dari nilaiitems. Kalau tidak cocok, Flutter melempar error.- Setiap
valuediitemsharus unik. Nilai duplikat menyebabkan error. onChangedharus memanggilsetState. Kalau tidak, pilihan terlihat tidak berubah.
Untuk tampilan yang menyatu dengan kolom teks lain, ada DropdownButtonFormField yang menerima InputDecoration dan bisa divalidasi bersama Form:
dart
DropdownButtonFormField<Kategori>(
initialValue: _terpilih,
decoration: const InputDecoration(
labelText: 'Kategori',
border: OutlineInputBorder(),
),
items: Kategori.values
.map((k) => DropdownMenuItem(value: k, child: Text(k.name)))
.toList(),
onChanged: (nilai) => setState(() => _terpilih = nilai!),
validator: (nilai) => nilai == null ? 'Pilih kategori' : null,
)Tombol
Flutter menyediakan beberapa jenis tombol dengan tingkat penekanan berbeda:
dart
Column(
children: [
// Paling menonjol — aksi utama
ElevatedButton(onPressed: () {}, child: const Text('Simpan')),
// Material 3 — terisi penuh, penekanan tertinggi
FilledButton(onPressed: () {}, child: const Text('Beli Sekarang')),
// Bergaris — aksi sekunder
OutlinedButton(onPressed: () {}, child: const Text('Batal')),
// Datar — aksi tersier
TextButton(onPressed: () {}, child: const Text('Lewati')),
// Hanya ikon
IconButton(icon: const Icon(Icons.delete), onPressed: () {}),
// Mengambang di sudut layar
FloatingActionButton(onPressed: () {}, child: const Icon(Icons.add)),
],
)Setiap tombol punya varian .icon yang menggabungkan ikon dan teks:
dart
ElevatedButton.icon(
onPressed: () {},
icon: const Icon(Icons.save),
label: const Text('Simpan'),
)Tombol nonaktif
Memberi onPressed: null — bukan fungsi kosong — akan menonaktifkan tombol dan membuatnya tampak abu-abu. Ini cara yang benar:
dart
ElevatedButton(
onPressed: _formValid ? _kirim : null, // ✅ otomatis abu-abu saat null
child: const Text('Kirim'),
)
ElevatedButton(
onPressed: () {}, // ❌ terlihat aktif tetapi tak berfungsi
child: const Text('Kirim'),
)Menyesuaikan gaya tombol:
dart
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
child: const Text('Tombol Kustom'),
)GestureDetector dan InkWell
Kadang kamu ingin widget apa pun bisa diketuk — sebuah Container, Card, atau gambar. Ada dua widget untuk itu, dan perbedaannya penting.
GestureDetector mendeteksi berbagai gestur, tetapi tidak memberi umpan balik visual:
dart
GestureDetector(
onTap: () => print('diketuk'),
onDoubleTap: () => print('ketuk ganda'),
onLongPress: () => print('ditekan lama'),
onHorizontalDragUpdate: (detail) => print(detail.delta.dx),
child: Container(
padding: const EdgeInsets.all(20),
color: Colors.amber,
child: const Text('Ketuk saya'),
),
)InkWell hanya menangani ketukan, tetapi memberi efek riak Material yang membuat aplikasi terasa responsif:
dart
InkWell(
onTap: () => print('diketuk'),
borderRadius: BorderRadius.circular(12), // bentuk riaknya
splashColor: Colors.indigo.withValues(alpha: 0.2),
child: Padding(
padding: const EdgeInsets.all(20),
child: const Text('Ketuk saya — dengan riak'),
),
)Aturan praktis: gunakan InkWell kecuali kamu butuh gestur selain ketukan. Umpan balik visual bukan sekadar hiasan; tanpa itu pengguna tidak yakin ketukannya terdaftar.
Riak tidak muncul?
Efek riak InkWell digambar di atas permukaan Material, bukan di atas anaknya. Kalau InkWell membungkus Container yang punya color, warna itu menutupi riaknya. Solusinya, bungkus dengan Material:
dart
Material(
color: Colors.amber,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: () {},
borderRadius: BorderRadius.circular(12),
child: const Padding(
padding: EdgeInsets.all(20),
child: Text('Sekarang riaknya terlihat'),
),
),
)Date picker
Menampilkan pemilih tanggal adalah operasi asinkron — hasilnya baru ada setelah pengguna memilih atau membatalkan.
dart
class _PilihTanggalState extends State<PilihTanggal> {
DateTime? _tanggalTerpilih;
void _bukaPemilihTanggal() async {
final sekarang = DateTime.now();
final tanggalAwal = DateTime(sekarang.year - 1, sekarang.month, sekarang.day);
final dipilih = await showDatePicker(
context: context,
initialDate: sekarang,
firstDate: tanggalAwal,
lastDate: sekarang,
helpText: 'Pilih tanggal pengeluaran',
cancelText: 'Batal',
confirmText: 'Pilih',
);
// Bernilai null kalau pengguna membatalkan
if (dipilih == null) return;
setState(() => _tanggalTerpilih = dipilih);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
_tanggalTerpilih == null
? 'Belum ada tanggal'
: _formatTanggal(_tanggalTerpilih!),
),
IconButton(
onPressed: _bukaPemilihTanggal,
icon: const Icon(Icons.calendar_month),
),
],
);
}
String _formatTanggal(DateTime t) {
const bulan = [
'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun',
'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des',
];
return '${t.day} ${bulan[t.month - 1]} ${t.year}';
}
}Untuk pemformatan tanggal yang lebih serius — termasuk nama bulan dalam Bahasa Indonesia yang lengkap — gunakan paket intl:
yaml
# pubspec.yaml
dependencies:
intl: ^0.19.0dart
import 'package:intl/intl.dart';
final formatter = DateFormat('d MMMM y', 'id_ID');
print(formatter.format(DateTime.now())); // 28 Agustus 2026Modal bottom sheet
Cara umum menampilkan formulir tanpa berpindah halaman:
dart
void _bukaFormulirTambah() {
showModalBottomSheet(
context: context,
isScrollControlled: true, // izinkan lebih tinggi dari setengah layar
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => const FormulirPengeluaran(),
);
}isScrollControlled: true penting ketika formulirnya panjang atau ketika papan ketik muncul — tanpa itu, sheet terbatas pada setengah tinggi layar dan isinya tertutup papan ketik.
Untuk menutupnya dari dalam:
dart
Navigator.pop(context);Menangani papan ketik yang menutupi kolom input:
dart
@override
Widget build(BuildContext context) {
final ruangPapanKetik = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: ruangPapanKetik + 16, // dorong ke atas setinggi papan ketik
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ /* isi formulir */ ],
),
);
}Dialog dan SnackBar
Dialog untuk pesan yang butuh perhatian atau keputusan:
dart
void _tampilkanKesalahan(String pesan) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Input tidak valid'),
content: Text(pesan),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Mengerti'),
),
],
),
);
}Dialog konfirmasi yang mengembalikan pilihan pengguna:
dart
Future<bool> _konfirmasiHapus() async {
final hasil = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Hapus item?'),
content: const Text('Tindakan ini tidak bisa dibatalkan.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Hapus'),
),
],
),
);
// Bernilai null kalau dialog ditutup dengan mengetuk di luar
return hasil ?? false;
}SnackBar untuk pemberitahuan singkat yang tidak menghalangi:
dart
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Item dihapus'),
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
action: SnackBarAction(
label: 'Urungkan',
onPressed: () {
setState(() => _daftar.insert(indeksLama, itemTerhapus));
},
),
),
);Kalau kamu menampilkan snackbar beruntun, hapus yang sedang tampil dulu agar tidak menumpuk:
dart
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(const SnackBar(content: Text('Pesan baru')));Form: mengelola banyak kolom sekaligus
Menangani lima kolom dengan TextEditingController masing-masing menjadi melelahkan. Widget Form menyederhanakannya secara drastis.
Tiga bagiannya:
Form— pembungkus yang mengoordinasi seluruh kolom.GlobalKey<FormState>— pegangan untuk memvalidasi dan menyimpan.TextFormField— versiTextFieldyang sadar akanForm.
dart
// lib/screens/tambah_item.dart
import 'package:flutter/material.dart';
class TambahItem extends StatefulWidget {
const TambahItem({super.key});
@override
State<TambahItem> createState() => _TambahItemState();
}
class _TambahItemState extends State<TambahItem> {
// Kunci global untuk mengakses state Form dari luar widget tree
final _kunciForm = GlobalKey<FormState>();
// Tempat menampung nilai setelah divalidasi dan disimpan
var _namaMasukan = '';
var _jumlahMasukan = 1;
var _kategoriTerpilih = Kategori.sayuran;
var _sedangMengirim = false;
void _simpan() {
// validate() menjalankan SEMUA validator sekaligus.
// Ia mengembalikan false kalau ada satu saja yang gagal,
// dan otomatis menampilkan pesan kesalahannya di layar.
if (!_kunciForm.currentState!.validate()) return;
// save() memicu onSaved di setiap kolom
_kunciForm.currentState!.save();
setState(() => _sedangMengirim = true);
// Kirim hasilnya kembali ke halaman sebelumnya
Navigator.pop(
context,
Item(
nama: _namaMasukan,
jumlah: _jumlahMasukan,
kategori: _kategoriTerpilih,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tambah Item Baru')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _kunciForm,
child: Column(
children: [
// --- Nama item ---
TextFormField(
maxLength: 50,
decoration: const InputDecoration(
label: Text('Nama item'),
border: OutlineInputBorder(),
),
validator: (nilai) {
if (nilai == null ||
nilai.isEmpty ||
nilai.trim().length <= 1 ||
nilai.trim().length > 50) {
return 'Panjang nama harus antara 2 dan 50 karakter.';
}
return null; // null berarti valid
},
onSaved: (nilai) {
_namaMasukan = nilai!;
},
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- Jumlah ---
Expanded(
child: TextFormField(
keyboardType: TextInputType.number,
initialValue: _jumlahMasukan.toString(),
decoration: const InputDecoration(
label: Text('Jumlah'),
border: OutlineInputBorder(),
),
validator: (nilai) {
final angka = int.tryParse(nilai ?? '');
if (angka == null || angka <= 0) {
return 'Masukkan angka positif.';
}
return null;
},
onSaved: (nilai) {
_jumlahMasukan = int.parse(nilai!);
},
),
),
const SizedBox(width: 12),
// --- Kategori ---
Expanded(
child: DropdownButtonFormField<Kategori>(
initialValue: _kategoriTerpilih,
decoration: const InputDecoration(
label: Text('Kategori'),
border: OutlineInputBorder(),
),
items: Kategori.values.map((kategori) {
return DropdownMenuItem(
value: kategori,
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: kategori.warna,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(kategori.label),
],
),
);
}).toList(),
onChanged: (nilai) {
setState(() => _kategoriTerpilih = nilai!);
},
),
),
],
),
const SizedBox(height: 24),
// --- Tombol aksi ---
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _sedangMengirim
? null
: () => _kunciForm.currentState!.reset(),
child: const Text('Reset'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _sedangMengirim ? null : _simpan,
child: _sedangMengirim
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Simpan'),
),
],
),
],
),
),
),
);
}
}Membedah cara kerjanya
GlobalKey<FormState> memberimu pegangan ke state internal Form dari luar widget tree. Tanpa itu kamu tidak bisa memicu validasi dari tombol yang berada di luar Form.
Buat GlobalKey sekali saja
GlobalKey harus dideklarasikan sebagai properti final di kelas State, bukan di dalam build(). Membuatnya ulang setiap build akan menghapus state formulir setiap kali widget dibangun ulang — dan bugnya sangat membingungkan.
dart
// ✅ Benar
class _FormState extends State<FormSaya> {
final _kunciForm = GlobalKey<FormState>();
// ❌ Salah
Widget build(BuildContext context) {
final kunciForm = GlobalKey<FormState>(); // dibuat ulang setiap build!validator adalah fungsi bertipe String? Function(String?). Ia menerima nilai kolom dan mengembalikan pesan kesalahan — atau null kalau valid. Mengembalikan null berarti "tidak ada masalah".
onSaved dipanggil ketika _kunciForm.currentState!.save() dijalankan. Di sinilah kamu memindahkan nilai kolom ke variabel state.
Alur lengkapnya:
Pengguna menekan "Simpan"
│
▼
_kunciForm.currentState!.validate()
│
┌────┴────┐
│ │
false true
│ │
│ ▼
│ _kunciForm.currentState!.save()
│ │
│ ▼
│ onSaved dipanggil di setiap kolom
│ │
│ ▼
│ Nilai tersimpan ke variabel state
│ │
│ ▼
│ Proses data / Navigator.pop
│
▼
Pesan kesalahan tampil otomatis
di bawah kolom yang gagalValidator yang sering dipakai
dart
// Wajib diisi
validator: (nilai) =>
(nilai == null || nilai.trim().isEmpty) ? 'Wajib diisi' : null,
// Email
validator: (nilai) {
if (nilai == null || nilai.trim().isEmpty) return 'Email wajib diisi';
if (!nilai.contains('@') || !nilai.contains('.')) {
return 'Format email tidak valid';
}
return null;
},
// Kata sandi
validator: (nilai) {
if (nilai == null || nilai.length < 8) {
return 'Minimal 8 karakter';
}
return null;
},
// Konfirmasi kata sandi
validator: (nilai) {
if (nilai != _pengendaliSandi.text) return 'Kata sandi tidak cocok';
return null;
},
// Angka positif
validator: (nilai) {
final angka = double.tryParse(nilai ?? '');
if (angka == null || angka <= 0) return 'Masukkan angka lebih dari 0';
return null;
},Kapan validasi berjalan
Secara bawaan, validasi hanya berjalan ketika kamu memanggil validate(). Kamu bisa mengubahnya:
dart
Form(
key: _kunciForm,
autovalidateMode: AutovalidateMode.onUserInteraction,
child: /* ... */,
)| Mode | Perilaku |
|---|---|
disabled | Hanya saat validate() dipanggil — bawaan |
onUserInteraction | Setelah pengguna menyentuh kolom itu — pilihan terbaik |
always | Terus-menerus, termasuk sebelum disentuh — cenderung mengganggu |
onUserInteraction biasanya memberi pengalaman terbaik: pengguna tidak dihujani pesan merah sebelum sempat mengetik apa pun, tetapi mendapat umpan balik segera setelah meninggalkan kolom yang salah.
Validasi manual tanpa Form
Kadang kamu tidak memakai Form — misalnya di dalam bottom sheet sederhana. Validasi manual tetap perlu, dan polanya seperti ini:
dart
void _simpanPengeluaran() {
final jumlahMasuk = double.tryParse(_pengendaliJumlah.text);
final jumlahTidakValid = jumlahMasuk == null || jumlahMasuk <= 0;
if (_pengendaliJudul.text.trim().isEmpty ||
jumlahTidakValid ||
_tanggalTerpilih == null) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Input tidak valid'),
content: const Text(
'Pastikan judul, jumlah, tanggal, dan kategori sudah terisi benar.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Mengerti'),
),
],
),
);
return; // ← hentikan di sini, jangan lanjut menyimpan
}
widget.saatDitambahkan(
Pengeluaran(
judul: _pengendaliJudul.text.trim(),
jumlah: jumlahMasuk,
tanggal: _tanggalTerpilih!,
kategori: _kategoriTerpilih,
),
);
Navigator.pop(context);
}Perhatikan return setelah menampilkan dialog. Tanpa itu, kode akan lanjut menyimpan data yang tidak valid — bug klasik yang mudah terlewat.
Perhatikan juga double.tryParse alih-alih double.parse. Yang pertama mengembalikan null untuk input tidak valid; yang kedua melempar pengecualian yang akan membuat aplikasi crash.
Latihan Mandiri
Kerjakan salah satu, beberapa, atau semuanya secara berurutan untuk melatih pemahamanmu sampai benar-benar lekat.
Variasi 1: Pemesanan Meja Restoran — ⭐⭐ · 45–60 menit
Tantangan: Buat formulir pemesanan meja restoran: nama, jumlah orang, tanggal dan jam kedatangan, pilihan area (dalam/luar/lantai dua), dan catatan alergi yang boleh dikosongkan. Tombol kirim menampilkan ringkasan pesanan di bottom sheet.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Nama [_______________] │
│ Jml Orang [___] orang │
│ Tanggal [ 12 Des 2026 ▾] │
│ Jam [ 19:00 ▾] │
│ Area ( Dalam ▾ ) │
│ Catatan [_______________] │
│ │
│ [ Pesan Meja ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯...tombol ditekan → bottom sheet ringkasan muncul dari bawah:
text
┌─────────────────────────────┐
│ Ringkasan Pesanan │
│ Nama: Budi · 4 orang │
│ 12 Des 2026, 19:00 · Dalam │
└─────────────────────────────┘Kriteria selesai:
- Memakai
FormdenganGlobalKey<FormState>yang dibuat sekali sebagai propertifinal, bukan di dalambuild(). - Nama wajib diisi; jumlah orang wajib angka antara 1 dan 20; tanggal wajib dipilih. Ketiganya menampilkan pesan kesalahan berbahasa Indonesia yang spesifik, bukan "Tidak valid".
- Membatalkan pemilih tanggal tidak membuat aplikasi berhenti atau menyimpan nilai kosong.
- Setiap
TextEditingControllerdi-dispose(), dan bottom sheet-nya tidak tertutupi papan ketik ketika ada kolom teks di dalamnya.
Petunjuk: Untuk jumlah orang, keyboardType: TextInputType.number membatasi papan ketik tetapi tidak membatasi apa yang bisa ditempelkan pengguna — jadi validatormu tetap harus memakai tryParse, tidak pernah parse. showDatePicker mengembalikan null ketika dibatalkan, dan itu bukan kasus langka: pengguna sering membuka lalu berubah pikiran. Untuk pilihan area, DropdownButtonFormField ikut divalidasi bersama kolom lain kalau ditaruh di dalam Form. Dan AutovalidateMode.onUserInteraction biasanya memberi pengalaman terbaik — pesan kesalahan muncul setelah pengguna menyentuh kolomnya, bukan langsung saat layar terbuka.
Variasi 2: Pendaftaran Anggota Gym — ⭐⭐ · 30–45 menit
Tantangan: Buat formulir pendaftaran: nama, email, nomor telepon, tanggal lahir, dan kata sandi dengan konfirmasinya. Validasi harus menolak email tanpa tanda at, nomor telepon yang bukan angka, usia di bawah tujuh belas tahun, dan konfirmasi kata sandi yang tidak cocok.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Nama [_______________] │
│ Email [_______________] │
│ Telepon [_______________] │
│ Lahir [ 01 Jan 2000 ▾] │
│ Sandi [•••••••••] 👁 │
│ Konfirmasi[•••••••••] 👁 │
│ │
│ ⚠ Kata sandi tidak cocok │
│ [ Daftar ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Validasi konfirmasi kata sandi membandingkan dengan kolom lain, dan tetap benar ketika pengguna mengubah kolom pertama setelah mengisi konfirmasinya.
- Nomor telepon dibatasi di sumbernya dengan
inputFormatters, bukan hanya ditolak setelah dikirim. - Kata sandi bisa disembunyikan dan ditampilkan lewat ikon mata.
Petunjuk: Kriteria pertama adalah jebakan yang sering lolos: validator konfirmasi membaca nilai kolom pertama saat ia dijalankan, jadi mengubah kolom pertama setelahnya tidak otomatis memicu validasi ulang kolom konfirmasi. Panggil validasi seluruh formulir ketika kolom pertama berubah, atau terima bahwa pesannya baru muncul saat kirim ditekan — pilih sadar, jangan biarkan terjadi tanpa kamu sadari.
Variasi 3: Klaim Garansi — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun formulir klaim garansi: nomor seri produk, tanggal pembelian, jenis kerusakan (dropdown), tingkat keparahan (slider satu sampai lima), pilihan "sudah pernah diperbaiki sebelumnya" (switch), dan deskripsi kerusakan yang panjang. Tanggal pembelian tidak boleh di masa depan dan tidak boleh lebih dari dua tahun lalu.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ No. Seri [_______________] │
│ Tgl Beli [ 03 Jul 2025 ▾] │
│ Kerusakan ( Layar Retak ▾) │
│ Keparahan ●───●───○───○───○ 2/5 │
│ Pernah diperbaiki? [○──●] Ya │
│ Deskripsi │
│ [___________________________] │
│ [___________________________] │
│ 120/500 karakter │
│ [ Kirim Klaim ] │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Pemilih tanggal dibatasi rentangnya sehingga tanggal tidak sah bahkan tidak bisa dipilih, bukan hanya ditolak setelahnya.
- Slider, switch, dan dropdown semuanya mengikuti pola
valuedari state danonChangedyang memanggilsetState. - Kolom deskripsi tumbuh mengikuti isi sampai batas tertentu, lalu bergulir.
- Formulir menampilkan berapa karakter tersisa untuk deskripsi.
Petunjuk: Mencegah lebih baik daripada menolak: firstDate dan lastDate pada showDatePicker menghilangkan seluruh kelas kesalahan tanpa satu baris validasi. Untuk kolom yang tumbuh, maxLines yang disetel null bersama minLines memberi perilaku itu. Dan penghitung karakter sudah tersedia secara bawaan lewat salah satu properti dekorasi kolom teks — kamu tidak perlu menghitungnya sendiri.
Variasi 4: Pengaturan Aplikasi — ⭐⭐ · 30–45 menit
Tantangan: Bangun layar pengaturan yang seluruhnya tersusun dari varian ListTile: switch untuk notifikasi, slider untuk ukuran teks, radio untuk pilihan bahasa, checkbox untuk beberapa jenis pengingat, dan satu baris yang membuka dialog konfirmasi untuk menghapus seluruh data.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Notifikasi [●──○] │
│ Ukuran Teks ──●─────── │
│ Bahasa │
│ ● Indonesia │
│ ○ English │
│ Pengingat │
│ ☑ Latihan harian │
│ ☐ Progres mingguan │
│ Hapus Semua Data > │
│ ▬▬▬▬ │
╰───────────────────────────────────╯(tiap baris di atas: ketuk di mana pun barisnya — bukan cuma kontrolnya — sudah cukup untuk mengubah nilainya)
Kriteria selesai:
- Seluruh baris memakai varian
ListTileyang sesuai, bukanRowyang disusun manual. - Mengetuk mana pun bagian barisnya mengubah nilainya, bukan hanya kontrol di ujung kanan.
- Dialog hapus data mengembalikan hasil, dan menekan di luar dialog diperlakukan sebagai pembatalan.
- Ada varian
.adaptiveyang dipakai setidaknya sekali.
Petunjuk: Kriteria kedua adalah alasan utama varian ListTile ada: SwitchListTile membuat seluruh baris menjadi area sentuh, sementara Row berisi Text dan Switch hanya bereaksi pada switch-nya. Perbedaannya kecil di kode tetapi besar di rasa. Untuk dialog, ingat bahwa showDialog mengembalikan null ketika ditutup dengan mengetuk di luar — dan null di sini berarti "batal", bukan "tidak apa-apa lanjutkan".
Variasi 5: Pengiriman Paket — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Buat formulir pengiriman dengan dua blok alamat (pengirim dan penerima) yang strukturnya identik, plus pilihan "alamat penerima sama dengan pengirim" yang mengisi otomatis dan mengunci blok kedua. Tambahkan validasi silang: berat dan dimensi paket harus masuk akal bersamaan.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ ┌─ Alamat Pengirim ───────────┐ │
│ │ Nama [____________] │ │
│ │ Alamat [____________] │ │
│ └──────────────────────────────┘ │
│ ☐ Alamat penerima sama dengan │
│ pengirim │
│ ┌─ Alamat Penerima ───────────┐ │
│ │ Nama [____________] │ │
│ │ Alamat [____________] │ │
│ └──────────────────────────────┘ │
│ Berat [__] kg Dimensi [__] cm │
│ ▬▬▬▬ │
╰───────────────────────────────────╯(kedua blok beralamat di atas adalah satu widget yang sama, dipasang dua kali dengan controller berbeda)
Kriteria selesai:
- Blok alamat adalah satu widget yang dipakai dua kali, bukan disalin.
- Mencentang "sama dengan pengirim" mengisi dan mengunci blok kedua, dan melepasnya mengembalikan isi yang sempat diketik pengguna sebelumnya.
- Ada satu validasi yang memeriksa dua kolom sekaligus dan menampilkan pesannya di tempat yang masuk akal.
- Seluruh controller dari kedua blok di-
dispose()tanpa satu pun yang terlewat.
Petunjuk: Widget yang dipakai dua kali dengan state terpisah adalah kasus klasik untuk mengangkat controller ke induk dan mengopernya ke bawah — dengan begitu induk bisa menyalin isi blok pertama ke blok kedua, dan bertanggung jawab atas dispose seluruhnya di satu tempat. Untuk mengembalikan isi yang sempat diketik, simpan salinannya sebelum menimpanya.
Variasi 6: Kalkulator Cicilan — ⭐⭐⭐ · 45–60 menit
Tantangan: Bangun kalkulator cicilan di mana jumlah pinjaman, tenor, dan bunga bisa diatur lewat slider maupun kolom teks, dan keduanya selalu sinkron: menggeser slider memperbarui angka di kolom, mengetik di kolom menggeser slider. Hasil cicilan per bulan diperbarui seketika.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Pinjaman [Rp 50.000.000] │
│ ──────●─────── │
│ Tenor [12 bulan] │
│ ───●────────── │
│ Bunga [8.5 %] │
│ ────●───────── │
│ │
│ Cicilan/bulan: Rp 4.375.000 │
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Mengetik di kolom tidak membuat kursor melompat ke awal atau teks terhapus di tengah pengetikan.
- Nilai di luar rentang slider ditolak dengan jelas, bukan diam-diam dipotong.
- Slider memakai
onChangeduntuk pratinjau dan tidak memicu perhitungan berat di setiap pergeseran mikro.
Petunjuk: Kriteria pertama adalah jebakan paling terkenal dari TextEditingController: menugaskan controller.text di dalam build atau di setiap perubahan akan mengatur ulang posisi kursor. Perbarui teks controller hanya ketika sumber perubahannya adalah slider, dan biarkan pengetikan berjalan tanpa diganggu. Membedakan "siapa yang menyebabkan perubahan ini" adalah inti dari seluruh latihan ini.
Variasi 7: Formulir Dinamis — ⭐⭐⭐⭐ · 60–90 menit
Tantangan: Buat formulir pendaftaran acara di mana pengguna memilih jumlah peserta lebih dulu, lalu formulir menghasilkan blok isian sebanyak itu secara dinamis. Peserta bisa ditambah dan dihapus setelahnya, dan data peserta lain tidak boleh ikut bergeser atau hilang ketika satu peserta di tengah dihapus.
Bayangan tampilan:
text
╭───────────────────────────────────╮
│ 9:41 ▂▄▆ 🔋 │
│ │
│ Jumlah Peserta: [4] [Buat Form] │
│ │
│ ┌─ Peserta 1 ──────────── [🗑]┐ │
│ │ Nama [____________] │ │
│ └───────────────────────────────┘ │
│ ┌─ Peserta 2 ──────────── [🗑]┐ │
│ │ Nama [____________] │ │ ← hapus ini...
│ └───────────────────────────────┘ │
│ ┌─ Peserta 3 ──────────── [🗑]┐ │
│ │ Nama [____________] │ │ ← ...3 & 4 TIDAK bergeser
│ └───────────────────────────────┘ │ atau tertukar isinya
│ ▬▬▬▬ │
╰───────────────────────────────────╯Kriteria selesai:
- Menghapus peserta kedua dari empat tidak membuat data peserta ketiga dan keempat bergeser ke atas atau tertukar.
- Seluruh controller yang dibuat secara dinamis di-
dispose(), termasuk milik peserta yang dihapus sebelum formulir ditutup. - Validasi berjalan untuk seluruh blok sekaligus, dan menggulirkan layar ke blok pertama yang bermasalah.
Petunjuk: Data yang bergeser saat item tengah dihapus adalah gejala widget yang diidentifikasi berdasarkan posisi, bukan identitas — persoalan yang dibahas tuntas di Bab 12. Beri tiap blok peserta key yang berasal dari identitas datanya. Untuk dispose yang tidak terlewat, satu daftar controller yang kamu kelola sendiri lebih andal daripada mencoba mengingat setiap tempat yang membuatnya.
Ikhtisar
TextFielddenganTextEditingControllerlebih baik daripadaonChangeduntuk formulir sungguhan — dan setiap controller wajib di-dispose().- Sesuaikan
keyboardTypedan pakaiinputFormattersuntuk membatasi input di sumbernya. Checkbox,Switch, danSlidermengikuti pola sama:valuedari state,onChangedmemanggilsetState.- Varian
.adaptivememberi gaya iOS di iOS dan Material di Android secara otomatis. DropdownButtonbutuhvalueyang cocok dengan salah satuitems, dan nilaiitemsyang unik.onPressed: nullmenonaktifkan tombol dengan benar; fungsi kosong hanya membuatnya tampak aktif padahal tidak.InkWelluntuk umpan balik riak,GestureDetectoruntuk gestur selain ketukan. Kalau riak tidak muncul, bungkus denganMaterial.showDatePickermengembalikannullsaat dibatalkan — selalu periksa.isScrollControlled: truepada bottom sheet, dan tambahkanMediaQuery.viewInsets.bottomke padding agar papan ketik tidak menutupi kolom.Form+GlobalKey<FormState>+TextFormFieldmengelola banyak kolom sekaligus lewatvalidate()dansave().GlobalKeyharus dibuat sekali sebagai propertifinal, tidak pernah di dalambuild().validatormengembalikannulluntuk valid,Stringuntuk pesan kesalahan.AutovalidateMode.onUserInteractionbiasanya memberi pengalaman terbaik.- Selalu
tryParse, tidak pernahparse, untuk input pengguna.
Berikutnya: Bab 7 — Navigasi & Multi-Screen, tempat aplikasimu tumbuh melebihi satu layar.
Transkrip asli
Disintesis dari 1_flutter_zero-to-hero/6_user-input.md, 1_flutter_core/4_expense-tracker_interactive-widget-theming.md (video 11–26), dan 1_flutter_core/10_shopping-list_form-n-handling-user-input.md. Lihat PDF Zero to Hero dan PDF Flutter Core.