Input & Forms #

Input dan forms adalah jantung dari hampir setiap aplikasi yang mengumpulkan data pengguna. Flutter menyediakan dua widget utama: TextField untuk input teks dasar, dan TextFormField yang terintegrasi dengan sistem Form untuk validasi terkoordinasi. Memahami keduanya — beserta ekosistem TextEditingController, FocusNode, dan InputDecoration — memungkinkan kita membangun form yang fungsional, elegan, dan mudah dipelihara.

TextField — Input Teks Dasar #

TextField adalah widget fundamental untuk menerima input teks dari pengguna. Ia sangat dapat dikustomisasi dan mendukung berbagai jenis keyboard, formatter, dan dekorasi.

TextField(
  // Controller untuk membaca/mengubah nilai programatik
  controller: _controller,

  // Tipe keyboard yang muncul
  keyboardType: TextInputType.emailAddress,

  // Aksi tombol "done" di keyboard
  textInputAction: TextInputAction.next,

  // Sembunyikan teks (untuk password)
  obscureText: true,
  obscuringCharacter: '•',

  // Maksimum karakter
  maxLength: 100,

  // Ekspansi otomatis (untuk textarea)
  minLines: 1,
  maxLines: 5,

  // Dekorasi visual
  decoration: const InputDecoration(
    labelText: 'Email',
    hintText: '[email protected]',
    prefixIcon: Icon(Icons.email),
    border: OutlineInputBorder(),
  ),

  // Callback
  onChanged: (nilai) => print('Berubah: $nilai'),
  onSubmitted: (nilai) => _handleSubmit(nilai),
)

TextEditingController — Kontrol Programatik #

TextEditingController memungkinkan kita membaca nilai teks, mengubah isi field secara programatik, dan mendengarkan perubahan nilai:

class _LoginState extends State<LoginScreen> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    // Isi awal (pre-fill)
    _emailController.text = '[email protected]';

    // Dengarkan perubahan
    _emailController.addListener(() {
      // Dipanggil setiap kali teks berubah
      final isValid = _emailController.text.contains('@');
      setState(() => _emailValid = isValid);
    });
  }

  void _clearAll() {
    _emailController.clear();
    _passwordController.clear();
  }

  void _prefillAdmin() {
    // Set teks secara programatik
    _emailController.text = '[email protected]';
    // Pindahkan cursor ke akhir
    _emailController.selection = TextSelection.fromPosition(
      TextPosition(offset: _emailController.text.length),
    );
  }

  String get _email => _emailController.text.trim();
  String get _password => _passwordController.text;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(controller: _emailController, ...),
        TextField(controller: _passwordController, ...),
        ElevatedButton(onPressed: _submit, child: const Text('Login')),
      ],
    );
  }

  @override
  void dispose() {
    // WAJIB dispose untuk mencegah memory leak
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

TextField vs TextEditingController — Kapan Pakai Mana? #

Gunakan TextEditingController jika:
  ✓ Perlu membaca nilai teks saat tombol submit ditekan
  ✓ Perlu mengubah isi field secara programatik (pre-fill, clear)
  ✓ Perlu mendengarkan perubahan nilai untuk logika custom
  ✓ Perlu mengontrol posisi cursor atau selection

Gunakan onChanged callback saja jika:
  ✓ Hanya perlu update state saat nilai berubah
  ✓ Tidak perlu mengubah teks secara programatik
  ✓ Kode lebih sederhana tanpa controller

FocusNode — Manajemen Fokus #

FocusNode memungkinkan kontrol programatik terhadap fokus keyboard antar TextField:

class _FormState extends State<FormScreen> {
  final _namaFocus    = FocusNode();
  final _emailFocus   = FocusNode();
  final _passwordFocus = FocusNode();

  @override
  void initState() {
    super.initState();
    // Dengarkan perubahan fokus
    _emailFocus.addListener(() {
      if (!_emailFocus.hasFocus) {
        // Field kehilangan fokus -- validasi
        _validateEmail();
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          focusNode: _namaFocus,
          textInputAction: TextInputAction.next,
          onSubmitted: (_) {
            // Pindah fokus ke field berikutnya saat Enter/Next ditekan
            FocusScope.of(context).requestFocus(_emailFocus);
          },
          decoration: const InputDecoration(labelText: 'Nama'),
        ),
        TextField(
          focusNode: _emailFocus,
          textInputAction: TextInputAction.next,
          onSubmitted: (_) {
            FocusScope.of(context).requestFocus(_passwordFocus);
          },
          decoration: const InputDecoration(labelText: 'Email'),
        ),
        TextField(
          focusNode: _passwordFocus,
          textInputAction: TextInputAction.done,
          onSubmitted: (_) {
            // Sembunyikan keyboard dan submit form
            _passwordFocus.unfocus();
            _submit();
          },
          decoration: const InputDecoration(labelText: 'Password'),
        ),
      ],
    );
  }

  @override
  void dispose() {
    _namaFocus.dispose();
    _emailFocus.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }
}

Form dan TextFormField — Validasi Terkoordinasi #

Untuk form dengan beberapa field dan validasi terkoordinasi, gunakan Form + TextFormField. Gunakan GlobalKey saat membuat form. Ini memberikan identitas unik dan memungkinkan validasi form nanti.

Sistem validasi ini bekerja dengan mengalirkan instruksi validasi dari widget Form ke seluruh widget anak FormField secara terkoordinasi. Alur validasi ini dapat digambarkan melalui diagram berikut:

flowchart TD
    Submit["Pemicu: FormState.validate()"] --> ValidateAll["Iterasi Semua FormField Anak"]
    ValidateAll --> CheckEach{"Panggil validator(nilai)"}
    CheckEach -->|Mengembalikan String| ShowError["Tampilkan Pesan Error di Layar"]
    CheckEach -->|Mengembalikan null| ValidState["Tandai Input Valid"]
    ShowError --> ReturnFalse["Mengembalikan false (Validasi Gagal)"]
    ValidState --> ReturnTrue["Mengembalikan true (Validasi Berhasil)"]
class RegistrasiForm extends StatefulWidget {
  const RegistrasiForm({super.key});

  @override
  State<RegistrasiForm> createState() => _RegistrasiFormState();
}

class _RegistrasiFormState extends State<RegistrasiForm> {
  // GlobalKey untuk mengakses FormState
  final _formKey = GlobalKey<FormState>();

  // State form
  String _nama = '';
  String _email = '';
  String _password = '';
  bool _setuju = false;

  void _submit() {
    // Validasi semua field sekaligus
    if (_formKey.currentState!.validate()) {
      // Simpan nilai dari semua field
      _formKey.currentState!.save();

      // Proses data
      _daftarkan(_nama, _email, _password);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          TextFormField(
            decoration: const InputDecoration(
              labelText: 'Nama Lengkap',
              prefixIcon: Icon(Icons.person),
            ),
            validator: (nilai) {
              if (nilai == null || nilai.trim().isEmpty) {
                return 'Nama tidak boleh kosong';
              }
              if (nilai.trim().length < 3) {
                return 'Nama minimal 3 karakter';
              }
              return null; // valid
            },
            onSaved: (nilai) => _nama = nilai!.trim(),
          ),
          const SizedBox(height: 16),
          TextFormField(
            keyboardType: TextInputType.emailAddress,
            decoration: const InputDecoration(
              labelText: 'Email',
              prefixIcon: Icon(Icons.email),
            ),
            validator: (nilai) {
              if (nilai == null || nilai.isEmpty) {
                return 'Email tidak boleh kosong';
              }
              final emailRegex = RegExp(r'^[^@]+@[^@]+\.[^@]+');
              if (!emailRegex.hasMatch(nilai)) {
                return 'Format email tidak valid';
              }
              return null;
            },
            onSaved: (nilai) => _email = nilai!,
          ),
          const SizedBox(height: 16),
          TextFormField(
            obscureText: true,
            decoration: const InputDecoration(
              labelText: 'Password',
              prefixIcon: Icon(Icons.lock),
            ),
            validator: (nilai) {
              if (nilai == null || nilai.isEmpty) {
                return 'Password tidak boleh kosong';
              }
              if (nilai.length < 8) {
                return 'Password minimal 8 karakter';
              }
              if (!nilai.contains(RegExp(r'[A-Z]'))) {
                return 'Password harus mengandung huruf kapital';
              }
              return null;
            },
            onSaved: (nilai) => _password = nilai!,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Daftar'),
          ),
        ],
      ),
    );
  }
}

AutovalidateMode — Kapan Validasi Terjadi #

AutovalidateMode mengontrol kapan validasi TextFormField berlangsung.

// 1. disabled (default): validasi HANYA saat _formKey.currentState!.validate() dipanggil
TextFormField(
  autovalidateMode: AutovalidateMode.disabled,
  validator: _validateEmail,
)

// 2. onUserInteraction: validasi setelah pengguna mulai berinteraksi
// Error muncul saat pengguna mengetik dan meninggalkan field
TextFormField(
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: _validateEmail,
)

// 3. always: validasi terus-menerus, bahkan sebelum pengguna menyentuh field
// Gunakan dengan hati-hati -- bisa menampilkan error terlalu awal
TextFormField(
  autovalidateMode: AutovalidateMode.always,
  validator: _validateEmail,
)

// Pola yang direkomendasikan:
// Mulai dengan disabled, switch ke onUserInteraction setelah submit pertama
class _FormState extends State<MyForm> {
  bool _submitted = false;

  AutovalidateMode get _autovalidateMode => _submitted
      ? AutovalidateMode.onUserInteraction
      : AutovalidateMode.disabled;
}

InputDecoration — Kustomisasi Visual #

InputDecoration mengontrol semua aspek visual dari TextField dan TextFormField:

TextFormField(
  decoration: InputDecoration(
    // Label yang melayang saat fokus
    labelText: 'Email',
    labelStyle: const TextStyle(color: Colors.blue),

    // Placeholder saat kosong
    hintText: '[email protected]',
    hintStyle: TextStyle(color: Colors.grey.shade400),

    // Teks bantu di bawah field
    helperText: 'Gunakan email aktif',

    // Ikon
    prefixIcon: const Icon(Icons.email),
    suffixIcon: IconButton(
      icon: const Icon(Icons.clear),
      onPressed: () => _controller.clear(),
    ),

    // Teks prefix/suffix di dalam field
    prefixText: '+62 ',
    suffixText: '.com',

    // Border style
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
    ),
    enabledBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: BorderSide(color: Colors.grey.shade300),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.blue, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.red),
    ),

    // Background
    filled: true,
    fillColor: Colors.grey.shade50,

    // Padding konten
    contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  ),
)

Keyboard Type dan TextInputAction #

// keyboardType -- jenis keyboard yang muncul
TextField(keyboardType: TextInputType.text)         // default: keyboard biasa
TextField(keyboardType: TextInputType.emailAddress)  // @ dan .com
TextField(keyboardType: TextInputType.number)        // hanya angka
TextField(keyboardType: TextInputType.phone)         // keypad telepon
TextField(keyboardType: TextInputType.datetime)      // tanggal/waktu
TextField(keyboardType: TextInputType.url)           // keyboard URL
TextField(keyboardType: TextInputType.multiline)     // Enter = baris baru

// textInputAction -- tombol aksi di sudut kanan bawah keyboard
TextField(textInputAction: TextInputAction.next)     // "Next" → pindah fokus
TextField(textInputAction: TextInputAction.done)     // "Done" → tutup keyboard
TextField(textInputAction: TextInputAction.search)   // "Search" → ikon kaca pembesar
TextField(textInputAction: TextInputAction.send)     // "Send"
TextField(textInputAction: TextInputAction.go)       // "Go"

InputFormatter — Batasi dan Format Input #

InputFormatter memungkinkan kita memfilter atau memformat teks secara real-time saat pengguna mengetik:

import 'package:flutter/services.dart';

// Formatter bawaan Flutter
TextField(
  inputFormatters: [
    // Hanya izinkan angka
    FilteringTextInputFormatter.digitsOnly,

    // Hanya huruf (tanpa spasi, angka, simbol)
    FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),

    // Tolak karakter tertentu
    FilteringTextInputFormatter.deny(RegExp(r'[<>]')),

    // Batas panjang
    LengthLimitingTextInputFormatter(10),
  ],
)

// Formatter kustom -- format nomor telepon
class PhoneInputFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final text = newValue.text.replaceAll(RegExp(r'[^0-9]'), '');

    // Format: 0812-3456-7890
    final buffer = StringBuffer();
    for (int i = 0; i < text.length; i++) {
      if (i == 4 || i == 8) buffer.write('-');
      buffer.write(text[i]);
    }

    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

// Penggunaan formatter kustom
TextField(
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    LengthLimitingTextInputFormatter(12),
    PhoneInputFormatter(),
  ],
)

Widget Input Lainnya #

// Checkbox
CheckboxListTile(
  title: const Text('Saya setuju dengan syarat dan ketentuan'),
  value: _setuju,
  onChanged: (nilai) => setState(() => _setuju = nilai!),
)

// Switch
SwitchListTile(
  title: const Text('Notifikasi push'),
  subtitle: const Text('Terima notifikasi dari kami'),
  value: _notifikasiAktif,
  onChanged: (nilai) => setState(() => _notifikasiAktif = nilai),
)

// Radio Button
Column(
  children: [
    RadioListTile<String>(
      title: const Text('Pria'),
      value: 'pria',
      groupValue: _gender,
      onChanged: (nilai) => setState(() => _gender = nilai!),
    ),
    RadioListTile<String>(
      title: const Text('Wanita'),
      value: 'wanita',
      groupValue: _gender,
      onChanged: (nilai) => setState(() => _gender = nilai!),
    ),
  ],
)

// Slider
Slider(
  value: _nilai,
  min: 0,
  max: 100,
  divisions: 10,
  label: '${_nilai.round()}',
  onChanged: (v) => setState(() => _nilai = v),
)

// DropdownButton
DropdownButtonFormField<String>(
  decoration: const InputDecoration(labelText: 'Kota'),
  value: _kotaTerpilih,
  items: ['Jakarta', 'Bandung', 'Surabaya']
      .map((kota) => DropdownMenuItem(value: kota, child: Text(kota)))
      .toList(),
  onChanged: (nilai) => setState(() => _kotaTerpilih = nilai),
  validator: (nilai) => nilai == null ? 'Pilih kota' : null,
)

Pola Form yang Reusable #

Buat widget form field kustom yang bisa digunakan ulang di seluruh aplikasi:

class AppTextFormField extends StatelessWidget {
  final String label;
  final String? hint;
  final IconData? prefixIcon;
  final bool obscureText;
  final TextInputType? keyboardType;
  final TextInputAction textInputAction;
  final String? Function(String?)? validator;
  final void Function(String?)? onSaved;
  final void Function(String)? onChanged;
  final TextEditingController? controller;
  final FocusNode? focusNode;
  final VoidCallback? onEditingComplete;

  const AppTextFormField({
    super.key,
    required this.label,
    this.hint,
    this.prefixIcon,
    this.obscureText = false,
    this.keyboardType,
    this.textInputAction = TextInputAction.next,
    this.validator,
    this.onSaved,
    this.onChanged,
    this.controller,
    this.focusNode,
    this.onEditingComplete,
  });

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      focusNode: focusNode,
      obscureText: obscureText,
      keyboardType: keyboardType,
      textInputAction: textInputAction,
      onEditingComplete: onEditingComplete,
      validator: validator,
      onSaved: onSaved,
      onChanged: onChanged,
      decoration: InputDecoration(
        labelText: label,
        hintText: hint,
        prefixIcon: prefixIcon != null ? Icon(prefixIcon) : null,
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
        filled: true,
        fillColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.3),
      ),
    );
  }
}

// Penggunaan -- bersih dan konsisten di seluruh app
AppTextFormField(
  label: 'Email',
  hint: '[email protected]',
  prefixIcon: Icons.email,
  keyboardType: TextInputType.emailAddress,
  validator: Validators.email,
  onSaved: (v) => _email = v!,
)

Ringkasan #

  • TextField adalah widget input dasar yang sangat dapat dikustomisasi. Gunakan bersama TextEditingController untuk kontrol programatik dan FocusNode untuk manajemen fokus antar field.
  • TextFormField terintegrasi dengan Form dan GlobalKey<FormState> untuk validasi terkoordinasi — ideal untuk form dengan beberapa field.
  • Validator mengembalikan String berisi pesan error jika input tidak valid, atau null jika valid.
  • AutovalidateMode mengontrol kapan validasi terjadi: disabled (hanya saat validate() dipanggil), onUserInteraction (setelah pengguna berinteraksi), atau always.
  • InputDecoration mengontrol semua aspek visual — label, hint, ikon, border, background, dan style error.
  • InputFormatter memfilter atau memformat input secara real-time. Gunakan FilteringTextInputFormatter untuk kasus umum dan buat kustom untuk format seperti nomor telepon.
  • Gunakan textInputAction untuk mengontrol tombol aksi keyboard (next, done, search) dan keyboardType untuk jenis keyboard yang sesuai.
  • Buat widget form field kustom yang reusable untuk konsistensi visual dan mengurangi duplikasi kode di seluruh aplikasi.
  • Selalu dispose TextEditingController dan FocusNode di dispose() untuk mencegah memory leak.

← Sebelumnya: Scrolling   Berikutnya: Animation →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact