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