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 #
TextFieldadalah widget input dasar yang sangat dapat dikustomisasi. Gunakan bersamaTextEditingControlleruntuk kontrol programatik danFocusNodeuntuk manajemen fokus antar field.TextFormFieldterintegrasi denganFormdanGlobalKey<FormState>untuk validasi terkoordinasi — ideal untuk form dengan beberapa field.- Validator mengembalikan
Stringberisi pesan error jika input tidak valid, ataunulljika valid.AutovalidateModemengontrol kapan validasi terjadi:disabled(hanya saatvalidate()dipanggil),onUserInteraction(setelah pengguna berinteraksi), ataualways.InputDecorationmengontrol semua aspek visual — label, hint, ikon, border, background, dan style error.InputFormattermemfilter atau memformat input secara real-time. GunakanFilteringTextInputFormatteruntuk kasus umum dan buat kustom untuk format seperti nomor telepon.- Gunakan
textInputActionuntuk mengontrol tombol aksi keyboard (next,done,search) dankeyboardTypeuntuk jenis keyboard yang sesuai.- Buat widget form field kustom yang reusable untuk konsistensi visual dan mengurangi duplikasi kode di seluruh aplikasi.
- Selalu
disposeTextEditingControllerdanFocusNodedidispose()untuk mencegah memory leak.