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 kamu 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 kamu 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.

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 kamu 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