Chương 09 · Forms & Input

Forms & Input

Form là use case hằng ngày (login, signup, profile edit, search). Flutter có 2 cách: TextField raw (control manual) hoặc Form + TextFormField (validation helper). Hiểu cả 2 — Form không phải lúc nào cũng phù hợp (vd realtime search bar không cần Form).

Độ dài: ~1080 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 2-4 + state mgmt cơ bản
🎯 Mục tiêu chương
  • Sử dụng TextField vớ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')),
    ],
  );
}
⚠️ Dispose controller — bắt buộc

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, return true nếu pass hết.
  • save() — gọi onSaved của mọi field.
  • reset() — reset value + validation state về initial.

7. autovalidateMode

ModeBehavior
disabled (default)Chỉ validate khi gọi validate() thủ công.
alwaysValidate liên tục — kể cả lần đầu render. Warning ngay từ đầu, UX kém.
onUserInteractionValidate sau khi user gõ — recommended. Không warning ngay.
onUnfocusValidate 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'),
      ),
    ),
  );
}
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

Q1

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.

Q2

Validator trả null — nghĩa?

Xem đáp án

Đáp án: Field hợp lệ. Trả String = error message hiển thị dưới field.

Q3

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.

Q4

AutovalidateMode default?

Xem đáp án

Đáp án: disabled — chỉ validate khi gọi .validate() thủ công. Recommend onUserInteraction cho UX tốt.

Q5

TextField obscureText: true — dạng gì?

Xem đáp án

Đáp án: Hiển thị . Dùng cho password.

Q6

Tab giữa field — config gì?

Xem đáp án

Đáp án: textInputAction: TextInputAction.next + onSubmitted: (_) => nextFocus.requestFocus().

Q7

image_picker iOS permission ở đâu?

Xem đáp án

Đáp án: ios/Runner/Info.plist — keys NSCameraUsageDescription, NSPhotoLibraryUsageDescription.

Q8

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