Skip to content

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:

PropertiFungsi
labelTextLabel yang mengambang ke atas saat difokuskan
hintTextTeks abu-abu yang hilang saat mengetik
helperTextPetunjuk kecil di bawah kolom
errorTextPesan kesalahan berwarna merah
prefixIcon / suffixIconIkon di kiri atau kanan
prefixText / suffixTextTeks tetap, misalnya Rp atau kg
borderGaya garis tepi
filled / fillColorLatar 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.

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:

  1. value harus salah satu dari nilai items. Kalau tidak cocok, Flutter melempar error.
  2. Setiap value di items harus unik. Nilai duplikat menyebabkan error.
  3. onChanged harus memanggil setState. 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.0
dart
import 'package:intl/intl.dart';

final formatter = DateFormat('d MMMM y', 'id_ID');
print(formatter.format(DateTime.now()));   // 28 Agustus 2026

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:

  1. Form — pembungkus yang mengoordinasi seluruh kolom.
  2. GlobalKey<FormState> — pegangan untuk memvalidasi dan menyimpan.
  3. TextFormField — versi TextField yang sadar akan Form.
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 gagal

Validator 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: /* ... */,
)
ModePerilaku
disabledHanya saat validate() dipanggil — bawaan
onUserInteractionSetelah pengguna menyentuh kolom itu — pilihan terbaik
alwaysTerus-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 Form dengan GlobalKey<FormState> yang dibuat sekali sebagai properti final, bukan di dalam build().
  • 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 TextEditingController di-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 value dari state dan onChanged yang memanggil setState.
  • 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 ListTile yang sesuai, bukan Row yang 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 .adaptive yang 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 onChanged untuk 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

  • TextField dengan TextEditingController lebih baik daripada onChanged untuk formulir sungguhan — dan setiap controller wajib di-dispose().
  • Sesuaikan keyboardType dan pakai inputFormatters untuk membatasi input di sumbernya.
  • Checkbox, Switch, dan Slider mengikuti pola sama: value dari state, onChanged memanggil setState.
  • Varian .adaptive memberi gaya iOS di iOS dan Material di Android secara otomatis.
  • DropdownButton butuh value yang cocok dengan salah satu items, dan nilai items yang unik.
  • onPressed: null menonaktifkan tombol dengan benar; fungsi kosong hanya membuatnya tampak aktif padahal tidak.
  • InkWell untuk umpan balik riak, GestureDetector untuk gestur selain ketukan. Kalau riak tidak muncul, bungkus dengan Material.
  • showDatePicker mengembalikan null saat dibatalkan — selalu periksa.
  • isScrollControlled: true pada bottom sheet, dan tambahkan MediaQuery.viewInsets.bottom ke padding agar papan ketik tidak menutupi kolom.
  • Form + GlobalKey<FormState> + TextFormField mengelola banyak kolom sekaligus lewat validate() dan save().
  • GlobalKey harus dibuat sekali sebagai properti final, tidak pernah di dalam build().
  • validator mengembalikan null untuk valid, String untuk pesan kesalahan.
  • AutovalidateMode.onUserInteraction biasanya memberi pengalaman terbaik.
  • Selalu tryParse, tidak pernah parse, 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.

Rangkuman pembelajaran pribadi, disusun ulang dari beberapa kursus Flutter.