- Sử dụng
TextFieldvới controller, focus node, decoration. Form+TextFormField+GlobalKey<FormState>cho validation.- Validation realtime vs on submit.
- Custom FormField.
- Focus management: TabIndex, FocusScope, dismiss keyboard.
Autocomplete<T>widget.DropdownButtonFormField,Checkbox,Switch,Radio,Slider.- Image picker, file picker, date/time picker.
1. TextField cơ bản
TextField(
onChanged: (value) => print(value),
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'you@example.com',
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
)
2. TextEditingController
class _LoginState extends State<Login> {
final _emailCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
@override
void dispose() {
_emailCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
void _submit() {
print('${_emailCtrl.text} / ${_passwordCtrl.text}');
}
@override
Widget build(BuildContext context) => Column(
children: [
TextField(controller: _emailCtrl, decoration: const InputDecoration(labelText: 'Email')),
TextField(controller: _passwordCtrl, obscureText: true, decoration: const InputDecoration(labelText: 'Password')),
ElevatedButton(onPressed: _submit, child: const Text('Login')),
],
);
}
Không dispose = memory leak. Mọi Controller (TextEditingController, FocusNode, AnimationController, ScrollController) đều phải dispose.
3. InputDecoration
InputDecoration(
labelText: 'Email', // label float khi focus
hintText: 'you@example.com', // placeholder
helperText: 'Đăng nhập với email', // hint dưới input
errorText: error, // dòng đỏ khi có lỗi
prefixIcon: const Icon(Icons.email),
suffixIcon: IconButton(icon: const Icon(Icons.clear), onPressed: () => ctrl.clear()),
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey[100],
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
)
4. FocusNode — focus management
class _FormState extends State<MyForm> {
final _emailFocus = FocusNode();
final _passwordFocus = FocusNode();
@override
void dispose() {
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => Column(
children: [
TextField(
focusNode: _emailFocus,
textInputAction: TextInputAction.next,
onSubmitted: (_) => _passwordFocus.requestFocus(),
decoration: const InputDecoration(labelText: 'Email'),
),
TextField(
focusNode: _passwordFocus,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
_passwordFocus.unfocus(); // ẩn keyboard
_submit();
},
decoration: const InputDecoration(labelText: 'Password'),
),
],
);
}
Dismiss keyboard khi tap outside
GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(/* form */),
)
5. Keyboard types & actions
TextInputType.emailAddress // @ và .
TextInputType.phone // numeric pad
TextInputType.number // digit only
TextInputType.url // / và .
TextInputType.multiline // có Enter key
TextInputType.datetime
TextInputType.numberWithOptions(decimal: true, signed: true)
TextInputAction.next // → field tiếp
TextInputAction.done // hide keyboard
TextInputAction.search // search icon
TextInputAction.send // send icon
6. Form + GlobalKey<FormState>
class _LoginState extends State<Login> {
final _formKey = GlobalKey<FormState>();
String? email, password;
void _submit() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save(); // gọi onSaved của mọi field
print('$email / $password');
}
}
@override
Widget build(BuildContext context) => Form(
key: _formKey,
child: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Email'),
validator: (v) => v == null || v.isEmpty
? 'Email bắt buộc'
: !v.contains('@') ? 'Email không hợp lệ' : null,
onSaved: (v) => email = v,
),
TextFormField(
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
validator: (v) => v == null || v.length < 8 ? 'Tối thiểu 8 ký tự' : null,
onSaved: (v) => password = v,
),
ElevatedButton(onPressed: _submit, child: const Text('Login')),
],
),
);
}
Validator return rule
- Trả
null→ field hợp lệ. - Trả
String→ error message hiển thị dưới field.
FormState methods
validate()— chạy validator của mọi field, returntruenếu pass hết.save()— gọionSavedcủa mọi field.reset()— reset value + validation state về initial.
7. autovalidateMode
| Mode | Behavior |
|---|---|
disabled (default) | Chỉ validate khi gọi validate() thủ công. |
always | Validate liên tục — kể cả lần đầu render. Warning ngay từ đầu, UX kém. |
onUserInteraction | Validate sau khi user gõ — recommended. Không warning ngay. |
onUnfocus | Validate khi rời field. UX tốt. |
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
validator: (v) => v == null || v.isEmpty ? 'Bắt buộc' : null,
)
8. Custom FormField
class DatePickerFormField extends FormField<DateTime> {
DatePickerFormField({
super.key,
super.initialValue,
super.validator,
super.onSaved,
String? labelText,
}) : super(
builder: (state) => InputDecorator(
decoration: InputDecoration(
labelText: labelText,
errorText: state.errorText,
border: const OutlineInputBorder(),
),
child: InkWell(
onTap: () async {
final picked = await showDatePicker(
context: state.context,
initialDate: state.value ?? DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
);
if (picked != null) state.didChange(picked);
},
child: Text(state.value?.toIso8601String().split('T').first ?? 'Chọn ngày'),
),
),
);
}
9. DropdownButtonFormField
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: 'City'),
value: null,
items: ['HN', 'HCM', 'ĐN'].map((c) => DropdownMenuItem(value: c, child: Text(c))).toList(),
onChanged: (v) => print(v),
validator: (v) => v == null ? 'Chọn city' : null,
)
10. Checkbox / Switch / Radio / Slider
// Checkbox — multi-select
CheckboxListTile(
title: const Text('Đồng ý điều khoản'),
value: agreed,
onChanged: (v) => setState(() => agreed = v ?? false),
)
// Switch — toggle
SwitchListTile(
title: const Text('Nhận email marketing'),
value: marketing,
onChanged: (v) => setState(() => marketing = v),
)
// Radio — mutually exclusive
enum Plan { free, pro }
RadioListTile<Plan>(
title: const Text('Free'),
value: Plan.free,
groupValue: selectedPlan,
onChanged: (v) => setState(() => selectedPlan = v),
)
RadioListTile<Plan>(
title: const Text('Pro'),
value: Plan.pro,
groupValue: selectedPlan,
onChanged: (v) => setState(() => selectedPlan = v),
)
// Slider — continuous range
Slider(
value: volume,
min: 0,
max: 100,
divisions: 10,
label: '${volume.round()}',
onChanged: (v) => setState(() => volume = v),
)
11. Autocomplete<T>
const _cities = ['Hà Nội', 'TP.HCM', 'Đà Nẵng', 'Hải Phòng', 'Cần Thơ'];
Autocomplete<String>(
optionsBuilder: (TextEditingValue input) {
if (input.text.isEmpty) return const <String>[];
return _cities.where((c) => c.toLowerCase().contains(input.text.toLowerCase()));
},
onSelected: (selection) => print('Selected: $selection'),
)
12. Image / File / Date picker
image_picker
flutter pub add image_picker
import 'package:image_picker/image_picker.dart';
final picker = ImagePicker();
final image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
print('Path: ${image.path}');
}
// Hoặc từ camera
final photo = await picker.pickImage(source: ImageSource.camera);
iOS cần permission trong ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Cần camera để chụp avatar</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Cần thư viện ảnh để chọn avatar</string>
file_picker
import 'package:file_picker/file_picker.dart';
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'doc'],
);
if (result != null) {
print(result.files.first.path);
}
Date / Time picker built-in
final date = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime(2100),
locale: const Locale('vi'),
);
final time = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
13. Pitfall: TextField rebuild khi parent rebuild
Controller state preserved (vì controller external), text input không reset.
Nhưng initialValue của TextFormField chỉ apply lần đầu.
// ❌ Reset value bằng cách rebuild → không work
TextFormField(initialValue: someState.value)
// Khi someState đổi, field KHÔNG update — vì initialValue apply 1 lần.
// ✅ Update qua controller
_ctrl.text = someState.value;
14. Bài tập
Login form đầy đủ
Login form: email validate regex ^[\w-]+@[\w-]+\.[\w-]{2,}$, password ≥ 8 ký tự.
Submit gọi mock API (delay 1s) → snackbar success/fail.
💡 Gợi ý đáp án
Form + 2 TextFormField + validator regex + submit handler async.
Multi-step signup form 3 page
Build signup 3 step: account info → personal → preferences. Giữ state qua các page.
💡 Gợi ý đáp án
Pattern: model class SignupData lift state lên parent. Mỗi page là Stateless + callback. Hoặc dùng Stepper widget của Flutter.
Custom phone formatter
Custom FormField cho phone VN format (auto-format 0901-234-567). Validate đầu số.
💡 Gợi ý đáp án
Dùng TextInputFormatter custom hoặc package mask_text_input_formatter.
TextFormField(
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (v) {
if (v == null || v.length != 10) return 'Phải 10 chữ số';
if (!v.startsWith('0')) return 'Đầu 0';
return null;
},
)
Autocomplete với debounce
Search bar suggestion: gõ city → Autocomplete dropdown. Debounce 300ms để tránh fetch quá nhiều.
💡 Gợi ý đáp án
Dùng Timer trong onChanged callback. Cancel timer cũ trước khi tạo timer mới.
Profile edit với avatar + date
Form edit profile: avatar (image_picker), 4 text field, date picker birthday. Pre-fill data hiện tại. Submit update.
💡 Gợi ý đáp án
Combine: image_picker (section 12), TextFormField pre-fill via controller, showDatePicker for birthday.
15. Quiz
TextField không dispose controller — chuyện gì?
Xem đáp án
Đáp án: Memory leak. Mọi controller (TextEditingController, FocusNode, AnimationController) đều phải dispose.
Validator trả null — nghĩa?
Xem đáp án
Đáp án: Field hợp lệ. Trả String = error message hiển thị dưới field.
formKey.currentState!.validate() chạy validator của field nào?
Xem đáp án
Đáp án: Tất cả TextFormField / FormField descendant của Form. Return true nếu pass hết.
AutovalidateMode default?
Xem đáp án
Đáp án: disabled — chỉ validate khi gọi .validate() thủ công. Recommend onUserInteraction cho UX tốt.
TextField obscureText: true — dạng gì?
Xem đáp án
Đáp án: Hiển thị •. Dùng cho password.
Tab giữa field — config gì?
Xem đáp án
Đáp án: textInputAction: TextInputAction.next + onSubmitted: (_) => nextFocus.requestFocus().
image_picker iOS permission ở đâu?
Xem đáp án
Đáp án: ios/Runner/Info.plist — keys NSCameraUsageDescription, NSPhotoLibraryUsageDescription.
Form reset — method nào?
Xem đáp án
Đáp án: formKey.currentState!.reset(). Reset value + validation state về initial.
16. Tổng kết
- ✅ TextField + TextEditingController + FocusNode (nhớ dispose).
- ✅ InputDecoration full — labelText, hintText, errorText, prefix/suffixIcon.
- ✅ Form + GlobalKey<FormState> + TextFormField + validator.
- ✅ AutovalidateMode 4 levels.
- ✅ Custom FormField cho domain-specific input.
- ✅ Dropdown, Checkbox, Switch, Radio, Slider.
- ✅ Autocomplete với optionsBuilder.
- ✅ image_picker + file_picker + showDatePicker.
- ✅ Dismiss keyboard pattern.
- ✅ Pitfall: initialValue chỉ apply 1 lần.
17. Kết nối
- Chương 10 — Network: form submit → HTTP.
- Chương 11 — Storage: form data persistent.
- Chương 14: test form interaction.