Flutter Form: TextField, Form, Validation đầy đủ

Tóm tắt nhanh
Hướng dẫn đầy đủ cách tạo form trong Flutter với TextField, Form widget và validation để đảm bảo dữ liệu hợp lệ.
Mục lục bài viết (35)
- TextField cơ bản
- 1. Simple TextField
- 2. TextField với Controller
- Form Widget
- 1. Form cơ bản
- Validation
- 1. Required Field
- 2. Email Validation
- 3. Password Validation
- Complete Form Example
- Best Practices
- 1. Dispose Controllers
- 2. Reusable Validators
- TextField và TextFormField khác nhau ở đâu
- Kiểm tra dữ liệu: validator, thời điểm và autovalidateMode
- Bộ validator dùng lại được
- Định dạng dữ liệu đầu vào bằng inputFormatters
- Bàn phím, kiểu nhập và chuyển ô bằng nút Next
- Cuộn tới ô đang lỗi
- Hiển thị lỗi từ server
- Biểu mẫu động: thêm và xoá ô nhập theo dữ liệu
- Hiệu năng: đừng dựng lại cả form mỗi ký tự
- Test form bằng widget test
- 10 lỗi thường gặp khi làm form trong Flutter
- Câu hỏi thường gặp
- validator có thể trả về lỗi bất đồng bộ không?
- Làm sao kiểm tra form khi người dùng bấm nút Back hoặc chuyển màn hình?
- Form.of(context) và GlobalKey<FormState> khác nhau thế nào?
- Vì sao validate() trả về true dù tôi thấy dữ liệu sai?
- Chọn widget cho từng loại ô nhập
- Ví dụ tổng hợp: form đặt hàng có gọi API
- Câu hỏi tự kiểm tra trước khi coi form đã xong
- Kết luận
- Tóm tắt:
- Học Flutter bài bản theo lộ trình
Form là phần quan trọng trong mọi ứng dụng. Bài viết này sẽ hướng dẫn bạn tạo form hoàn chỉnh với validation trong Flutter.
TextField cơ bản
1. Simple TextField
[object Object]2. TextField với Controller
[object Object]Form Widget
1. Form cơ bản
[object Object]Validation
1. Required Field
[object Object]2. Email Validation
[object Object]3. Password Validation
[object Object]Complete Form Example
[object Object]Best Practices
1. Dispose Controllers
[object Object]2. Reusable Validators
[object Object]TextField và TextFormField khác nhau ở đâu
Đây là câu hỏi đầu tiên cần trả lời rõ, vì chọn sai widget dẫn tới code thừa hoặc thiếu chức năng.
TextField là widget nhập liệu thuần. Nó không biết gì về Form, không có validator, không tự báo lỗi. Muốn kiểm tra dữ liệu, bạn phải tự đọc controller.text và tự hiển thị thông báo.
TextFormField là FormField bọc quanh một TextField. Nó tham gia vào cơ chế kiểm tra của Form, có validator để trả về thông báo lỗi, có onSaved để thu dữ liệu khi hợp lệ, và tự hiển thị lỗi dưới ô nhập.
Quy tắc chọn thực dụng:
- Ô nhập đơn lẻ, không cần kiểm tra — ô tìm kiếm, ô ghi chú — dùng
TextField. - Ô nhập nằm trong một biểu mẫu có nút gửi — dùng
TextFormFieldđể tận dụng cơ chế kiểm tra tập trung và hiển thị lỗi nhất quán. - Ô nhập cần tự kiểm tra riêng nhưng không nằm trong
Form— dùngTextFieldkèm biến trạng thái, hoặc bọcFormFieldđể tự viết logic.
Một khác biệt nữa ít người để ý: TextFormField có tham số initialValue, còn TextField chỉ nhận controller. Khi dựng form sửa dữ liệu có sẵn, initialValue giúp code gọn hơn — nhưng nếu bạn vừa truyền controller vừa truyền initialValue cho cùng một ô, bạn sẽ gặp lỗi khẳng định vì hai cách khởi tạo xung đột.
Kiểm tra dữ liệu: validator, thời điểm và autovalidateMode
validator là một hàm nhận giá trị hiện tại và trả về null (hợp lệ) hoặc chuỗi thông báo lỗi. Nhưng câu hỏi khó hơn là: kiểm tra vào lúc nào?
autovalidateMode quyết định điều đó, và chọn sai sẽ tạo ra trải nghiệm rất khó chịu:
[object Object]Kinh nghiệm chọn: disabled cho form đăng ký (chỉ báo lỗi khi bấm gửi), onUserInteraction cho ô dài như email/số điện thoại vì người dùng cần phản hồi sớm, và onUnfocus cho biểu mẫu nhiều bước nơi việc báo lỗi giữa lúc gõ gây phân tán.
Một chi tiết quan trọng: validate() chạy tất cả validator và trả về true/false, nhưng nó cũng khiến tất cả ô hiển thị lỗi cùng lúc. Với form dài, hãy cân nhắc chỉ kiểm tra khi cần và cuộn tới ô lỗi đầu tiên, thay vì để người dùng tự tìm ô nào đang đỏ.
Bộ validator dùng lại được
Viết lại cùng một logic kiểm tra ở nhiều màn hình là nguồn gốc của việc thông báo lỗi không nhất quán. Hãy gom thành một lớp tiện ích:
[object Object]Dùng trong form:
[object Object]Hai nguyên tắc khi viết thông báo lỗi, dựa trên hành vi người dùng thật:
- Nói rõ cần làm gì, không chỉ nói sai. "Email không hợp lệ" kém hữu ích hơn "Email không hợp lệ, ví dụ: ten@gmail.com".
- Không dùng từ kỹ thuật. "Không đúng định dạng" hoặc "dữ liệu không hợp lệ" không giúp người dùng sửa được gì.
Định dạng dữ liệu đầu vào bằng inputFormatters
inputFormatters chặn dữ liệu sai ngay khi người dùng gõ, tốt hơn nhiều so với việc hiện lỗi sau đó. Vài mẫu dùng thường xuyên:
[object Object]Ba điều cần cẩn thận với inputFormatters:
- Luôn tự đặt lại vị trí con trỏ. Nếu bạn đổi chuỗi mà không cập nhật
selection, con trỏ sẽ nhảy về đầu hoặc về cuối một cách khó chịu. - Chỉ dùng định dạng khi thật cần. Định dạng số điện thoại theo từng khu vực dễ sai với người dùng ở nơi khác; nếu không chắc, chỉ nên giới hạn độ dài.
- Đừng để formatter tự ý sửa dữ liệu người dùng theo cách họ không kiểm soát được — ví dụ tự bỏ ký tự giữa chuỗi. Hãy chỉ định dạng lại, không xoá nội dung.
Bàn phím, kiểu nhập và chuyển ô bằng nút Next
Một form tốt phải nhập được mà gần như không cần chạm vào màn hình. Ba tham số lo việc này:
[object Object]keyboardTypechọn đúng loại bàn phím: số cho số điện thoại, có ký tự@cho email, che ký tự cho mật khẩu. Chọn đúng giúp người dùng nhập nhanh hơn và giảm lỗi.textInputActionđổi nhãn nút trên bàn phím. Đặtnextcho mọi ô trừ ô cuối, vàdonecho ô cuối để người dùng bấm Enter là gửi form.autofillHintsgiúp ứng dụng quản lý mật khẩu và điền tự động hoạt động — chi tiết nhỏ nhưng ảnh hưởng lớn tới tỷ lệ hoàn thành đăng ký trên thực tế.
Nếu bạn muốn tự chuyển tiêu điểm thay vì dựa vào hành vi mặc định, dùng FocusNode:
[object Object]Nhớ giải phóng FocusNode trong dispose — đây là tài nguyên thường bị quên nhất trong form.
Cuộn tới ô đang lỗi
Với form dài hơn một màn hình, người dùng bấm gửi và chỉ thấy nút gửi, không thấy ô nào đang lỗi. Cách xử lý tốt: sau khi validate() trả về false, cuộn tới ô lỗi đầu tiên.
[object Object]Cách đơn giản và hiệu quả nhất là gán FocusNode cho các ô, giữ một biến _firstErrorFocus được cập nhật ngay trong lúc chuẩn bị gửi, rồi requestFocus() để Flutter tự cuộn tới ô đó. Cách này ổn định hơn việc tự tính toán vị trí cuộn, vốn phụ thuộc chiều cao từng ô và rất dễ sai khi có ô nhiều dòng.
Hiển thị lỗi từ server
Phần kiểm tra ở client chỉ bắt được lỗi định dạng. Nguồn "email đã tồn tại" hay "mã giảm giá hết hạn" chỉ server biết. Cách hiển thị đúng gồm hai tầng:
[object Object]Hai điều đáng lưu ý:
- Xoá lỗi khi người dùng bắt đầu sửa. Nếu lỗi cũ còn nằm đó khi người dùng đã sửa, họ sẽ tưởng vẫn sai và bỏ đi.
- Phân biệt lỗi ở cấp một ô và lỗi ở cấp form. Lỗi ở cấp form (mất kết nối, hết hạn phiên) không nên gắn vào một ô cụ thể — đặt ở trên hoặc dưới form, và dùng
MaterialBanner/SnackBarnếu người dùng có thể cần thử lại.
Khi gọi API, nhớ chặn bấm trùng: dùng trạng thái _isSubmitting và vô hiệu hoá nút gửi trong lúc chờ.
Biểu mẫu động: thêm và xoá ô nhập theo dữ liệu
Form động xuất hiện ở khảo sát, danh sách thành viên, đơn hàng nhiều dòng. Hai lỗi phổ biến là dùng chỉ số làm key và dùng chung một controller cho nhiều ô.
[object Object]Điểm quan trọng nhất là key: ObjectKey(row): khi xoá một dòng ở giữa, Flutter phải biết dòng nào là dòng nào. Nếu dùng chỉ số, giá trị các ô sẽ "nhảy" sang dòng khác — đúng kiểu bug khiến người dùng mất dữ liệu đã nhập.
Hiệu năng: đừng dựng lại cả form mỗi ký tự
Gọi setState ở cấp form trong onChanged của mỗi ô khiến toàn bộ form vẽ lại ở mọi ký tự. Với form dài và máy yếu, người dùng sẽ thấy gõ chữ bị trễ.
Ba cách xử lý, theo mức độ can thiệp:
- Không cần theo dõi từng ký tự. Chỉ đọc giá trị khi cần (lúc gửi hoặc lúc kiểm tra), không lưu vào trạng thái.
- Dùng
ValueListenableBuildercho phần phụ thuộc giá trị.TextEditingControllerlà mộtValueNotifier<TextEditingValue>, nên bạn có thể chỉ vẽ lại phần hiển thị đếm ký tự thay vì cả form:
[object Object]- Tách mỗi ô thành widget riêng để phạm vi dựng lại nhỏ nhất có thể. Cách này cũng giúp code dễ đọc hơn khi form có nhiều ô được dùng lại ở nhiều màn hình.
Test form bằng widget test
Form là nơi tập trung logic quan trọng, và cũng là nơi viết test rẻ nhất vì không cần mạng:
[object Object]Hai mẹo để test form ổn định: luôn truyền lớp service giả qua constructor (đừng để form tự tạo), và dùng find.byType(TextFormField).at(n) theo thứ tự khai báo thay vì tìm theo nhãn — nhãn có thể đổi mà không ai cập nhật test.
10 lỗi thường gặp khi làm form trong Flutter
- Quên
disposechoTextEditingControllervàFocusNode— mở/đóng form nhiều lần sẽ rò rỉ bộ nhớ. - Gọi API ngay trong
validator.validatorphải nhanh và thuần tuý; gọi mạng ở đây làm giao diện khựng và tạo ra hàng loạt yêu cầu khi mỗi ký tự thay đổi. - Bật
AutovalidateMode.always. Người dùng thấy form đỏ ngay khi vừa mở. - Không xoá lỗi sau khi người dùng sửa. Lỗi cũ khiến họ tưởng mình vẫn sai.
- Không
trim()dữ liệu trước khi gửi. Khoảng trắng đầu/cuối là nguồn lỗi đăng nhập phổ biến nhất. - Để bàn phím che nút gửi. Cần cuộn được và cân nhắc đưa nút vào
bottomNavigationBarhoặcbottomSheet. - Không vô hiệu hoá nút gửi khi đang xử lý. Người dùng bấm liên tục và tạo nhiều bản ghi.
- Dùng chỉ số làm
keytrong form động. - Hiển thị lỗi kỹ thuật thô. "Exception: 500 Internal Server Error" không giúp được người dùng; hãy chuyển thành câu họ hiểu.
- Không có trạng thái thành công. Sau khi gửi xong, cần cho người dùng biết đã xong và bước tiếp theo là gì.
Câu hỏi thường gặp
validator có thể trả về lỗi bất đồng bộ không?
Không trực tiếp. validator phải trả về String? ngay lập tức. Nếu cần kiểm tra bất đồng bộ (ví dụ mã giảm giá có tồn tại không), hãy kiểm tra trước khi gửi, lưu kết quả vào biến trạng thái, rồi hiển thị qua errorText như phần lỗi từ server ở trên.
Làm sao kiểm tra form khi người dùng bấm nút Back hoặc chuyển màn hình?
Lưu lại bản nháp khi giá trị thay đổi (có chống dội), hoặc dùng PopScope để xác nhận trước khi rời màn hình. Cách thứ hai được trình bày ở bài so sánh Navigation 1.0 và 2.0.
Form.of(context) và GlobalKey<FormState> khác nhau thế nào?
Form.of(context) lấy FormState gần nhất phía trên và phù hợp khi bạn ở sâu trong cây widget. GlobalKey<FormState> cho phép gọi validate() và save() từ bất cứ đâu trong cùng một State — thường tiện hơn khi form nằm cùng file với nút gửi.
Vì sao validate() trả về true dù tôi thấy dữ liệu sai?
Kiểm tra ba khả năng: ô đó có nằm trong Form hay không (nếu không, validate() không chạm tới nó), key của Form có trùng với key bạn gọi hay không, và widget đó có phải TextFormField chứ không phải TextField.
Chọn widget cho từng loại ô nhập
Trong thực tế, bạn sẽ gặp vài loại ô lặp đi lặp lại. Bảng chọn nhanh dưới đây giúp tránh việc dùng sai widget rồi phải sửa về sau:
- Ô tìm kiếm —
TextFieldkèmprefixIconvàtextInputAction: TextInputAction.search. Không cầnvalidator, nhưng cần chống dội trước khi gọi API. - Ô nhập mật khẩu —
TextFormFieldvớiobscureText: truevà nút hiện/ẩn. Nếu bạn tự làm nút hiện/ẩn, hãy giữ nguyên vị trí con trỏ khi đổi chế độ — nếu không con trỏ sẽ nhảy về cuối. - Ô số —
TextFormFieldvớikeyboardType: TextInputType.numbervàinputFormatters. Lưu ý người dùng có thể nhập số âm hoặc số thập phân; hãy quyết định rõ ngay từ đầu. - Ô nhiều dòng (ghi chú, mô tả) —
TextFormFieldvớimaxLinesvàtextInputAction: TextInputAction.newline. Đừng đặtmaxLines: nullcho ô có thể rất dài nếu bố cục bên dưới phụ thuộc chiều cao. - Ô chọn từ danh sách — dùng
DropdownButtonFormFieldđể vẫn tham gia vào cơ chế kiểm tra củaForm, thay vìDropdownButtonthường. - Ô ngày/giờ —
TextFormFieldchỉ đọc kèmonTapmở bộ chọn ngày. Đây là mẫu quen thuộc: ô này không nhận bàn phím, nhưng vẫn cầnvalidatorđể bảo đảm người dùng đã chọn. - Ô đồng ý điều khoản —
FormField<bool>bao quanhCheckboxListTileđể thông báo lỗi hiển thị đúng chỗ.
Với các ô có định dạng đặc biệt (số điện thoại, mã số thuế, số tài khoản), hãy viết TextInputFormatter riêng và đặt ở thư mục dùng chung. Điều này quan trọng vì cùng một loại dữ liệu sẽ xuất hiện ở nhiều form, và mỗi lần viết lại là một cơ hội để phát sinh khác biệt.
Ví dụ tổng hợp: form đặt hàng có gọi API
Ví dụ dưới đây gom các phần trên thành một luồng hoàn chỉnh: kiểm tra dữ liệu, chặn bấm trùng, hiển thị lỗi từ server và xác nhận thành công.
[object Object]Bốn điều đáng chú ý trong ví dụ này, và cũng là bốn điều hay bị bỏ sót: kiểm tra _isSubmitting trước khi xử lý, dùng finally để luôn trả nút về trạng thái bình thường, kiểm tra mounted sau mỗi await, và lỗi chung được đặt ở cấp form chứ không gắn vào một ô cụ thể.
Nếu biểu mẫu này kết hợp điều hướng nhiều bước hoặc cần giữ trạng thái khi chuyển màn hình, hãy xem thêm hướng dẫn xây dựng form nhập liệu nhiều bước và bài về kiểm thử giao diện.
Câu hỏi tự kiểm tra trước khi coi form đã xong
Sáu câu dưới đây tương ứng với sáu lỗi hay gặp nhất trong form Flutter. Nếu trả lời được hết, form của bạn đang ở mức tốt hơn phần lớn mã nguồn thực tế:
- Mọi
TextEditingControllervàFocusNodeđã được giải phóng trongdisposechưa? - Khi mạng chậm và người dùng bấm gửi hai lần, có bao nhiêu yêu cầu được gửi đi?
- Dữ liệu đã được
trim()trước khi gửi chưa, và có xử lý trường hợp người dùng dán văn bản kèm khoảng trắng đầu dòng không? - Khi API trả lỗi, dữ liệu người dùng đã nhập còn nguyên trên màn hình không?
- Nút gửi có bị bàn phím che ở kích thước màn hình nhỏ nhất không?
- Nếu người dùng bấm Back giữa lúc điền, họ có bị mất toàn bộ nội dung mà không được cảnh báo không?
Cách kiểm tra nhanh nhất cho ba câu cuối là chạy ứng dụng trên một máy có màn hình nhỏ, bật cỡ chữ lớn nhất trong cài đặt hệ thống, và thử gửi form khi tắt mạng. Ba tình huống đó phát hiện được phần lớn lỗi mà việc dùng bình thường không thấy.
Về phần kiểm thử tự động, mỗi form có ít nhất ba test là hợp lý: một test cho trường hợp thiếu dữ liệu bắt buộc, một test cho trường hợp gửi thành công, và một test cho trường hợp API trả lỗi. Những test này chạy rất nhanh vì không cần mạng, và giúp bạn tự tin refactor khi form lớn dần.
Kết luận
Tạo form với Flutter:
- ✅ TextField: Widget nhập liệu
- ✅ Form: Quản lý form state
- ✅ Validation: Đảm bảo dữ liệu hợp lệ
- ✅ Best Practices: Dispose controllers, reusable validators
Tóm tắt:
- TextField: Widget nhập liệu cơ bản
- Form: Wrap với Form widget
- Validation: Validator functions
- Controllers: Quản lý text input
Tạo form chuyên nghiệp sẽ cải thiện user experience! 🚀
Học Flutter bài bản theo lộ trình
Nếu bạn muốn học có người sửa bài thay vì tự ghép tài liệu, Flutter Hướng Nghiệp đào tạo theo 3 cấp:
- Flutter Cơ Bản — Dart, widget, giao diện, 28 buổi.
- Flutter Nâng Cao — state management, REST API, Firebase, 28 buổi.
- Flutter Chuyên Sâu — back-end, AI/ML, publish lên store, 53 buổi.
Lớp online trực tiếp qua Zoom/Google Meet, có bản ghi lại — không bắt buộc đến cơ sở.
Xem lịch khai giảng hoặc so sánh 5 cách học lập trình mobile nếu còn phân vân.
