Form đang gõ bỗng mất chữ khi một widget cha rebuild. Hoặc nút điền dữ liệu đưa con trỏ về vị trí khó đoán. Với TextEditingController, vấn đề không chỉ là lấy được chuỗi text; bạn còn cần xác định ai sở hữu controller và khi nào được phép thay nội dung người dùng đang sửa.
Bài này xây một màn hình nhỏ có nội dung ban đầu và nút khôi phục mẫu. Tài liệu Flutter được đối chiếu ngày 24/10/2026. SDK lưu tại máy biên tập ghi Flutter 3.47.7, Dart 3.13.5 trong metadata cache; ví dụ chưa được build hoặc chạy widget test trên SDK đó. Hãy xác nhận phiên bản dự án bằng flutter --version trước khi áp dụng.
1. Tách lúc tạo controller khỏi lúc build
TextEditingController giữ cả text, selection và vùng composing của trình nhập. Controller thông báo cho listener khi giá trị thay đổi. Vì vậy, thay text bằng code cũng có thể kích hoạt những listener mà bạn tự đăng ký.
Trong ví dụ, State tạo controller một lần và giải phóng nó khi State bị dispose. build chỉ sử dụng controller đã có. Nếu tạo controller mới trong build, bạn biến một lần dựng lại giao diện thành một lần thay đối tượng giữ trạng thái nhập liệu.
2. Ví dụ một màn hình tự đủ
Đặt mã vào lib/main.dart của một dự án Flutter thử nghiệm. MaterialApp và Scaffold cung cấp cấu trúc Material cho TextField. Đây là form minh họa, chưa có lưu dữ liệu hoặc request mạng.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: NotePage()));
class NotePage extends StatefulWidget {
const NotePage({super.key});
@override
State<NotePage> createState() => _NotePageState();
}
class _NotePageState extends State<NotePage> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: 'Ghi chú ban đầu');
}
void _restoreSample() {
const sample = 'Ghi chú mới';
_controller.value = TextEditingValue(
text: sample,
selection: TextSelection.collapsed(offset: sample.length),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sửa ghi chú')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Nội dung'),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _restoreSample,
child: const Text('Khôi phục mẫu'),
),
],
),
),
);
}
}
Nút thay cả giá trị và selection trong một lần gán. Offset bằng sample.length trỏ tới cuối chuỗi theo cách đánh chỉ số mà API selection sử dụng; đây không phải cách đếm số ký tự người dùng nhìn thấy. Khi cần đếm emoji hay giới hạn số ký tự hiển thị, hãy xử lý grapheme cluster theo hướng dẫn của formatter, không suy ra từ ví dụ đặt con trỏ này.
3. Xác định quyền sở hữu và chính sách cập nhật
State tự tạo controller thì State tự dispose. Nếu một widget nhận controller từ cha, cần thống nhất bên tạo sẽ giải phóng; đừng dispose một controller vẫn còn được cha sử dụng. Trong danh sách dựng lười, controller có thể cần được quản lý ở ancestor có vòng đời phù hợp thay vì tạo mới cho mỗi lần cell xuất hiện.
Nội dung mẫu được thay khi người dùng bấm nút. Với dữ liệu tải từ server, hãy quyết định rõ: có ghi đè nếu người dùng đã gõ không, có cần nút xác nhận không, và record đổi ID thì tạo phiên chỉnh sửa mới hay cập nhật phiên cũ? Không gán _controller.text trong mỗi lần build để “đồng bộ”, vì cách đó dễ xóa phần người dùng vừa sửa.
Gán thuộc tính text sẽ đặt lại selection và composing. Khi thực sự cần thay cả nội dung lẫn con trỏ, cập nhật value giúp bạn mô tả trạng thái mong muốn cùng lúc. Tuy nhiên, thay composing giữa lúc IME đang ghép chữ vẫn cần thận trọng.
4. Đừng sửa text vòng quanh listener
Một listener đọc text, chuyển chữ thường rồi gán lại controller có thể tự gọi lại chính nó. Với bàn phím đang quản lý vùng composing, vòng phản hồi giữa framework và IME còn khó xử lý hơn. Tài liệu controller nêu cụ thể rủi ro với Gboard.
Nếu cần định dạng khi gõ, xem TextInputFormatter. Tài liệu yêu cầu việc sửa text chỉ diễn ra khi IME đã commit, không can thiệp text còn composing. Ví dụ này cố ý không tự chuẩn hóa chữ khi người dùng nhập; nút khôi phục là một hành động riêng có ý nghĩa rõ ràng.
Kiểm tra kết quả
Trên emulator và thiết bị, nhập một ghi chú, làm widget cha rebuild rồi xác nhận nội dung không đổi. Bấm khôi phục, kiểm tra chuỗi mới và vị trí con trỏ. Tiếp tục thử gõ tiếng Việt, chọn một đoạn, dán emoji, mở rồi đóng màn hình nhiều lần.
Nếu cursor nhảy khi gõ, tìm mọi chỗ ghi vào text hoặc value, kể cả listener và callback nhận dữ liệu. Nếu form giữ nội dung của record trước, rà chính sách khi record ID thay đổi thay vì chỉ thêm setState. Nếu báo dùng sau dispose, kiểm tra controller có bị chia sẻ sang widget sống lâu hơn chủ sở hữu không.
Kết luận
Controller nên có chủ sở hữu rõ ràng và chỉ bị cập nhật vào những thời điểm được thiết kế. Giữ nó ngoài build, thay text cùng selection khi cần và kiểm tra IME thực tế sẽ giúp form ổn định hơn các bản vá chỉ xử lý chuỗi text.

