TextEditingController хранит текущее значение поля (текст, выделение и диапазон, который ещё набирает IME) и позволяет читать, задавать и слушать его. Если создать его в build, каждый ребилд заменяет его новым пустым, и набранный текст исчезает.
Чтение и запись: controller.text читает значение; присваивание controller.text заменяет его и сбрасывает выделение; controller.value задаёт текст и каретку вместе; clear() очищает поле
Подписка: это ValueNotifier<TextEditingValue>, поэтому addListener и ValueListenableBuilder видят каждое изменение
Создавать один раз: полем State или в initState, никогда в build и никогда в StatelessWidget
Освобождать: вызывать dispose() в State.dispose; освобождать только свои контроллеры, а не тот, что передал родитель
Типичная ошибка: передать в TextFormField одновременно controller и initialValue: сработает assertion; начальный текст нужно положить в контроллер
FlutterJunior
Формы, ввод и фокус
TextField, TextEditingController, валидация Form, FocusNode, форматтеры ввода, клавиатура
Закрыто вопросов
0 / 21
- 0Повторить
- 4Часто спрашивают
Вопросы
TextFormField представляет собой TextField, обёрнутый в FormField<String>: он регистрируется в ближайшем Form и добавляет validator, onSaved, initialValue и autovalidateMode, показывая сообщение валидатора как ошибку поля.
**TextField**: обычное поле ввода; ошибку вы показываете сами через InputDecoration.errorText
**TextFormField**: рассчитан на Form: до него доходят validate(), save() и reset()
Те же параметры ввода: controller, focusNode, keyboardType, inputFormatters и decoration передаются во внутренний TextField
Правило: строка поиска или поле чата делается на TextField; поле, которое валидируется вместе с остальными, делается на TextFormField
Форма объединяет TextFormField-ы, GlobalKey<FormState> даёт доступ к её состоянию, а validator каждого поля возвращает текст ошибки или null. При отправке вы вызываете _formKey.currentState!.validate(): он запускает все валидаторы и возвращает true, только если все прошли.
Контракт валидатора: синхронная функция String? Function(String?): null значит «валидно», строка становится текстом под полем
Где живёт ключ: в State, создаётся один раз; ключ, созданный в build, даёт новый FormState на каждом ребилде
Когда появляются ошибки: по умолчанию только после validate(); это меняет autovalidateMode
После валидации: значения читают из контроллеров или вызывают save(), чтобы отработал onSaved каждого поля
Это не защита: валидация на клиенте нужна для удобства пользователя; сервер проверяет данные ещё раз
onChanged срабатывает, когда пользователь редактирует текст, onSubmitted срабатывает, когда он нажимает клавишу действия на клавиатуре, а слушатель контроллера срабатывает на любое изменение значения, включая перемещение каретки и текст, заданный из кода.
**onChanged**: вызывается при наборе, вставке и автозаполнении; не вызывается, когда вы присваиваете controller.text в коде
**onSubmitted: вызывается по Done, Next, Search или Enter в однострочном поле
Слушатель контроллера**: реагирует одинаково на text, selection и composing, поэтому даже тап, который только двигает каретку, его вызывает
Типичная ошибка: поиск на addListener шлёт запрос, когда пользователь просто переставил каретку; сначала сравните с последним текстом или используйте onChanged
Все четыре рисует InputDecorator внутри блока самого поля. Label всплывает, hint виден, пока поле пустое и в фокусе (или без label), а helper и error делят строку под полем; если строки helper не было, errorText добавляет её, поле становится выше и всё ниже сдвигается.
**labelText: стоит внутри пустого поля и всплывает к рамке, когда поле в фокусе или заполнено
hintText**: виден, пока значение пустое; если задан labelText, то только когда поле в фокусе и label уже всплыл
**helperText: постоянная строка под полем
errorText**: занимает место строки helper и красит рамку и label в цвет ошибки; TextFormField заполняет его из валидатора
Чтобы вёрстка не двигалась: зарезервируйте строку через helperText: ' ' и ограничьте длинные сообщения через errorMaxLines
Общий стиль задают в ThemeData.inputDecorationTheme; в InputDecoration конкретного поля остаются только тексты.
Они настраивают экранную клавиатуру, а не значение: keyboardType выбирает раскладку, textInputAction выбирает клавишу действия, textCapitalization задаёт состояние shift. Ни один из них не мешает ввести что-то другое.
**keyboardType**: emailAddress добавляет @, phone и number показывают цифровую клавиатуру, multiline оставляет Enter для переноса строки (вместе с maxLines: null)
**textInputAction**: подпись клавиши действия (next, done, search, send); по умолчанию next переводит фокус на следующее поле, а done закрывает клавиатуру
**textCapitalization**: words, sentences или characters; меняется только shift клавиатуры, а текст не меняется
Типичная ошибка: доверять клавиатуре: в цифровое поле всё равно вставляются буквы, а аппаратная клавиатура раскладку игнорирует, поэтому правило нужно обеспечивать через inputFormatters и валидатор
При resizeToAvoidBottomInset: true (значение по умолчанию) Scaffold уменьшает body на высоту клавиатуры. Но заставить фиксированный Column скроллиться он не может, поэтому то, что больше не помещается, переполняется или оказывается скрытым; скроллиться должен сам body.
Высота клавиатуры: MediaQuery.viewInsetsOf(context).bottom даёт её выше Scaffold и в bottomNavigationBar; внутри body Scaffold уже вычел её (там 0), пока resizeToAvoidBottomInset равно true. SafeArea читает padding, нижняя часть которого падает до нуля, пока клавиатура открыта
Сделайте body скроллируемым: SingleChildScrollView с Column или ListView; тогда поле в фокусе прокрутится в видимую область само
Панели внизу: bottomNavigationBar над клавиатурой не поднимается; добавьте ему отступ на view inset или перенесите кнопку в скроллируемый body
Типичная ошибка: resizeToAvoidBottomInset: false убирает полосы переполнения и оставляет кнопку под клавиатурой навсегда
Form значений не хранит. Каждое поле держит своё значение в своём FormFieldState, а save() лишь вызывает onSaved у каждого поля; если onSaved вы не написали, никуда ничего не записывается.
**save()**: вызывает onSaved(value) у каждого поля и ничего не возвращает; в модель пишет именно ваш колбэк
**reset()**: возвращает все поля к initialValue и очищает ошибки
Два способа прочитать значения: контроллеры (_email.text после успешного validate()) или onSaved плюс save(); в одной форме выбирайте что-то одно
**validateGranularly()**: валидирует как validate(), но возвращает набор невалидных FormFieldState, удобно прокрутить к первому из них
Типичная ошибка: _formKey.currentState равен null, пока Form ни разу не построился, поэтому вызов из initState падает
Вложенные формы: поле регистрируется в ближайшем Form, поэтому поля внутреннего Form не видны внешним validate() и save()
initialValue читается только один раз: когда создаётся FormFieldState поля. На первом билде _profile ещё null, поэтому поле стартует пустым, а ребилд после загрузки передаёт имя полю, которое его игнорирует.
Почему игнорируется: TextFormField копирует initialValue во внутренний контроллер в initState; didUpdateWidget реагирует только на новый controller, поэтому последующие значения до экрана не доходят (заново initialValue читает только reset())
Первый способ: строить поле, когда данные уже есть: пока _profile не задан, показывать лоадер, а затем один раз построить форму с настоящими начальными значениями; пользователь не успеет набрать текст, который потом затрётся
Второй способ: свой контроллер: после загрузки выполнить _name.text = p.name (и не забыть dispose()); подходит, когда форма должна быть видна во время загрузки, но нельзя затирать текст, который пользователь уже ввёл
**Смена Key остаётся крайней мерой**: ValueKey(_profile) действительно пересоздаёт поле, но каждый новый ключ выбрасывает его текст, каретку и ошибку
Ловить тестом: подставить репозиторий на Completer, отрисовать форму через pumpWidget, завершить Completer, вызвать pump() и проверить expect(find.text('Ada'), findsOneWidget)
Это AutovalidateMode.always: он запускает все валидаторы на каждом build, включая первый, поэтому нетронутая форма уже полна ошибок. Используйте onUserInteraction или onUnfocus, а при отправке явно вызывайте validate().
**disabled**: значение по умолчанию; валидаторы запускаются, только когда вы вызываете validate()
**onUserInteraction**: заданный у поля, он молчит, пока пользователь не изменил это поле, а затем проверяет его на каждое нажатие; заданный у Form, первое изменение любого поля запускает проверку всех
**onUnfocus: проверка, когда фокус уходит с поля; лучше для правил, которые нельзя выполнить посреди набора (полный email или совпадение паролей)
always: имеет смысл только для формы, которая открывается уже заполненной, например экран редактирования
Задавайте режим у полей**: режим Form добавляется к режиму каждого поля, а не заменяет его; оставьте форму disabled и задайте режим полям, если нетронутые поля должны молчать
Типичная ошибка: не вызвать validate() при отправке: в режиме onUserInteraction поле, которого никто не касался, так и не проверено
decoration.errorText хранит ваше собственное состояние, а не состояние формы: TextFormField подставляет сообщение валидатора, только когда оно есть, а _serverError при наборе никто не сбрасывает. Поэтому старое сообщение сервера висит, пока вы не очистите его сами.
Сбрасывайте при редактировании: в onChanged вызывайте setState(() => _serverError = null), когда текст изменился
**Используйте forceErrorText**: с Flutter 3.24 у TextFormField и FormField есть forceErrorText: пока он не null, поле показывает его вместо результата валидатора; задайте его из ответа сервера и очищайте при редактировании
**validate() её не видит**: ошибка, переданная через decoration.errorText, невидима для validate(), поэтому форма считает себя валидной, хотя на экране красный текст
У каждого сообщения один владелец: локальные правила формата в validator, вердикты сервера в состоянии (вердикт, который должен видеть validate(), можно вернуть и из валидатора, взяв из состояния, как в паттерне асинхронной проверки)
FocusNode задаёт место поля в дереве фокуса, которое существует рядом с деревом виджетов; ввод с клавиатуры получает узел с основным фокусом. Фокус переводят через requestFocus() или nextFocus(), а клавиатуру закрывают, снимая фокус.
Перевод фокуса: _passwordNode.requestFocus() переходит к конкретному полю; FocusScope.of(context).nextFocus() идёт по полям в порядке чтения, а TextInputAction.next вызывает его за вас
Почему Next пропускает поле: отключённые поля и узлы с canRequestFocus: false пропускаются; для своего порядка используйте FocusTraversalGroup с OrderedTraversalPolicy
Скрыть клавиатуру: FocusManager.instance.primaryFocus?.unfocus(); на Android и iOS тап мимо поля по умолчанию ничего не делает, поэтому обработайте его через onTapOutside или GestureDetector
Владение: как с контроллерами: узел создаётся в State и освобождается в dispose(), никогда в build
Типичная ошибка: FocusScope.of(context).requestFocus(FocusNode()), чтобы скрыть клавиатуру, на каждый тап создаёт узел, который никто не освобождает
Если передать onEditingComplete, он заменяет обработку клавиши действия по умолчанию, а именно она вызывает nextFocus() для TextInputAction.next. С вашим колбэком Next только сохраняет черновик, и фокус остаётся на месте.
Что вы заменили: без onEditingComplete next вызывает nextFocus(), previous вызывает previousFocus(), а done, go, search и send снимают фокус с поля
Исправление первое: в конце колбэка вызвать FocusScope.of(context).nextFocus() или _passwordNode.requestFocus()
Исправление второе: перенести _saveDraft в onSubmitted: он вызывается после стандартной обработки, а не вместо неё
Правило: onEditingComplete нужен, чтобы полностью взять клавишу действия на себя; побочные эффекты кладите в onSubmitted
formatEditUpdate(oldValue, newValue) форматтера возвращает целиком TextEditingValue, то есть решает и то, где стоит каретка. Если поставить её в text.length, она прибита к концу; вместо этого посчитайте цифры слева от каретки и поставьте её после того же числа цифр в маскированном тексте.
Когда он вызывается: на каждое изменение, до onChanged, в порядке списка inputFormatters; поставьте FilteringTextInputFormatter.digitsOnly первым, чтобы маска видела только цифры
Ограничьте offset: TextSelection.collapsed(offset: …) должен оставаться в пределах 0..text.length, иначе поле упадёт
**Сбросьте composing**: после переписывания текста возвращайте TextRange.empty, а не входящий диапазон
Правило: в модели храните «сырые» цифры, а маскируйте только для отображения; для масок телефона и карты хорошо подходит готовый mask_text_input_formatter
Сетевой вызов не должен быть в валидаторе. Сделайте debounce для onChanged, сохраните ответ сервера в State, а синхронный валидатор пусть просто возвращает уже готовое сообщение.
Как это складывается: onChanged перезапускает Timer примерно на 400 мс; когда он срабатывает, вы вызываете API и сохраняете результат через setState (предварительно проверив mounted). С AutovalidateMode.onUserInteraction поле перепроверяется на этом ребилде и показывает сообщение.
Ответы не по порядку: ответ для «ad» может прийти после ответа для «ada»; помечайте каждый запрос и отбрасывайте устаревшие ответы или отменяйте предыдущий запрос
Пока ждём: отключите кнопку отправки и покажите спиннер в suffixIcon; «ответа ещё нет» не значит «валидно»
Последнее слово за сервером: имя могут занять между вашей проверкой и отправкой, поэтому такой отказ тоже нужно обработать
Унаследуйтесь от FormField<T>. Он хранит значение и текст ошибки и (чего самописный виджет никогда не делает) регистрируется в ближайшем Form, поэтому до него доходят validate(), save() и reset().
Что вы получаете: конструктор уже принимает initialValue, validator, onSaved и autovalidateMode; вы передаёте builder, который получает FormFieldState<T>
**Сообщайте об изменениях через didChange**: когда пользователь выбрал дату, вызывайте state.didChange(picked), а не свой setState, иначе save() запишет устаревшие данные
Выглядеть как текстовое поле: оберните содержимое в InputDecorator с InputDecoration(errorText: state.errorText), и получите тот же label, рамку и стиль ошибки
Типичная ошибка: initialValue читается один раз; чтобы передать новое значение от родителя, дайте полю новый Key
Автозаполнение строится на контракте с ОС: полям нужны autofillHints, они должны лежать внутри одного AutofillGroup, а после успешного входа стоит завершить контекст автозаполнения.
Подсказки: AutofillHints.username и AutofillHints.password для входа; newPassword при регистрации, чтобы ОС предложила сгенерировать пароль
Группа: оберните поля в один AutofillGroup; так платформа понимает, что это одна учётная запись
Завершение: TextInput.finishAutofillContext() после успешного входа говорит ОС предложить сохранение; AutofillGroup тоже завершает контекст, когда его удаляют из дерева, поэтому вызов важен, если экран входа в дереве остаётся; если пользователь отменил вход, передайте shouldSave: false
Коды из SMS: AutofillHints.oneTimeCode на iOS работает из коробки; на Android это зависит от сервиса автозаполнения, поэтому используют SMS Retriever API через плагин. Шесть полей по одной цифре ломают автозаполнение; нужно одно поле, нарисованное как ячейки
Связь с доменом: чтобы ОС предлагала пароли, уже сохранённые для вашего сайта, приложение должно быть с ним связано: webcredentials: на iOS, assetlinks.json на Android
Текстом и кареткой должен владеть TextEditingController. Если присваивать controller.text из каждого нового состояния, вы сбрасываете каретку и конкурируете с клавиатурой за значение поля, поэтому символы теряются.
Путь поле → onChanged → bloc → состояние → ребилд → присваивание проходит асинхронно: нажатие, пришедшее посреди него, перезаписывается состоянием, построенным по предыдущему.
**Держите контроллер в State виджета: он источник истины для текста и каретки; в bloc кладите производное: ошибка, доступна ли кнопка, результаты
Вставляйте текст, только если он пришёл не с клавиатуры: очистка после отправки, подстановка черновика, отсканированный штрихкод
Делайте это в BlocListener**: с проверкой if (state.query != controller.text), чтобы эхо собственного ввода пользователя игнорировалось
Поворот здесь ни при чём: во Flutter это просто изменение размера, и каждый State его переживает. Если текст пропадает при повороте, значит Element поля был размонтирован и построен заново; восстановление состояния нужно только при смерти процесса.
Что уничтожает поворот
По умолчанию ничего: Flutter-активити сама обрабатывает смену ориентации, поэтому объекты State и их контроллеры остаются живы
Ваше собственное изменение дерева: изменившийся Key, другой тип виджета или ветка if (isPortrait) … else …, которая строит поле в другом месте, создают новый State с пустым контроллером; держите одно дерево и меняйте только раскладку вокруг поля
Для чего нужно восстановление
Смерть процесса: ОС выгружает приложение в фоне; из памяти Dart ничего не возвращается
Подключение: MaterialApp(restorationScopeId: 'app'), затем RestorationMixin с restorationId и registerForRestoration(_email, 'email') в restoreState, с RestorableTextEditingController
Маршруты тоже: открывайте экран через restorablePush/restorablePushNamed, иначе сама страница не будет пересоздана
Держите данные маленькими: данные восстановления представляют собой несколько КБ сериализуемого состояния, а не кэш
ListView.builder размонтирует дочерние элементы, которые ушли за пределы области кэша, и всё, чем они владели, умирает вместе с ними: TextEditingController строки и её FormFieldState. Текст не скрыт, его больше нет.
Хуже потерянного текста: FormFieldState при размонтировании отписывается от Form, поэтому невалидное поле за экраном позволяет validate() вернуть true
Что не поможет: PageStorageKey восстанавливает позицию скролла, а не значения полей; GlobalKey не удержит строку, которую builder вообще не построил
Поднимите владение выше: State страницы хранит Map<String, TextEditingController> и раздаёт каждой строке её контроллер, так что скролл уничтожает только виджет
Держите строки живыми: AutomaticKeepAliveClientMixin в строке или вместо ленивого списка SingleChildScrollView + Column: тридцать полей не лента, построить их все дёшево, и все валидаторы остаются зарегистрированы
Проверяйте данные, а не виджеты: в длинных и многошаговых формах прогоняйте правила по модели при отправке, а не только через смонтированные поля
Пока IME составляет слово, поле хранит предварительный текст, помеченный composing, то есть диапазоном, которым владеет платформа. Форматтер переписывает текст под ним, но возвращает входящие selection и composing как есть, поэтому Flutter и клавиатура расходятся во мнении о том, что на экране.
Что такое composing
Три части: TextEditingValue состоит из text, selection и composing: подчёркнутый диапазон, над которым IME ещё работает
Кто его использует: ввод на японском, китайском и корейском, а также предиктивный ввод на Android-клавиатурах, где текущее слово остаётся «составляемым», пока его не зафиксируют
Что ломается: IME продолжает править запомненный диапазон; если текст под ним изменился, следующее обновление дублирует или теряет буквы. toUpperCase может ещё и изменить длину (ß превращается в SS), и скопированный offset каретки окажется внутри слова, а не после него
Как исправить
Не форматируйте посреди составления: if (newValue.composing.isValid) return newValue;, а форматируйте, когда слово зафиксировано
Не переиспользуйте старые offset-ы после изменения текста: пересчитайте каретку для нового текста и ограничьте её диапазоном 0..length и возвращайте TextRange.empty для composing
Ограничение длины: LengthLimitingTextInputFormatter с MaxLengthEnforcement.truncateAfterCompositionEnds ждёт, пока IME зафиксирует ввод