Работа с формами во Flutter

от автора

Формы являются фундаментальным элементом любого современного приложения. Независимо от того, создаете ли вы корпоративный портал, социальную сеть или электронную коммерцию — работа с пользовательскими данными через формы неизбежна.

В современном Flutter-приложении формы встречаются повсеместно:

  • Аутентификация — логин и регистрация

  • Профили пользователей — настройка личных данных

  • Формы обратной связи — сбор отзывов и предложений

  • Фильтры и поиск — сложные системы фильтрации

  • Анкеты и опросы — сбор структурированной информации

  • Настройки приложения — персонализация интерфейса

Базовые инструменты Flutter

Фреймворк Flutter предоставляет встроенные компоненты для работы с формами:

  • Form — основной контейнер для группы полей

  • TextFormField — базовое текстовое поле с валидацией

  • TextEditingController — управление состоянием ввода

  • GlobalKey<FormState> — доступ к состоянию формы

Эти инструменты отлично справляются с простыми задачами, но по мере роста сложности приложения возникают новые требования:

  • Масштабируемость — поддержка большого количества полей

  • Повторное использование — создание переиспользуемых компонентов

  • Асинхронная валидация — проверка данных на сервере

  • Управление состоянием — интеграция с глобальным состоянием приложения

  • Тестирование — обеспечение качества кода

Цель материала

В этой серии статей мы подробно рассмотрим различные подходы к работе с формами во Flutter, начиная с базовых инструментов и заканчивая современными решениями. Вы узнаете:

  • Как эффективно использовать встроенные возможности Flutter

  • Какие существуют альтернативные библиотеки и подходы

  • Как выбрать оптимальное решение для вашего проекта

  • Как избежать распространенных ошибок при работе с формами

В первой части мы сосредоточимся на базовых принципах работы с формами, используя стандартные инструменты Flutter. В последующих материалах рассмотрим более продвинутые решения, включая go_formreactive_forms и другие современные библиотеки, которые помогут вам создавать масштабируемые и поддерживаемые решения для работы с формами.

Погрузимся в изучение основ работы с формами во Flutter, чтобы заложить прочный фундамент для создания качественных пользовательских интерфейсов.

Стандартные формы Flutter (Form и FormField)

Flutter предоставляет встроенный механизм для создания форм. Основные элементы — это FormFormStateTextFormField и GlobalKey.

final _formKey = GlobalKey<FormState>(); final _emailController = TextEditingController();  final _passwordController = TextEditingController(); final _emailFocus = FocusNode(); final _passwordFocus = FocusNode(); String? serverError;  Form(   key: _formKey,   child: Column(     children: [       TextFormField(         controller: _emailController,         focusNode: _emailFocus,         decoration: InputDecoration(           labelText: 'Email',           errorText: serverError, // ошибка с сервера         ),         validator: (value) =>             value != null && value.contains('@') ? null : 'Введите корректный email',         onFieldSubmitted: (_) {           FocusScope.of(context).requestFocus(_passwordFocus);         },       ),       TextFormField(         controller: _passwordController,         focusNode: _passwordFocus,         obscureText: true,         decoration: InputDecoration(labelText: 'Пароль'),         validator: (value) =>             value != null && value.length >= 6 ? null : 'Минимум 6 символов',       ),          onPressed: () {           if (_formKey.currentState!.validate()) {             final email = _emailController.text;             final password = _passwordController.text;             print('Email: \$email, Password: \$password');           }         },         child: Text('Войти'),       ),     ],   ), );

Как управлять фокусом между полями

final _emailFocus = FocusNode(); final _passwordFocus = FocusNode();  TextFormField(   focusNode: _emailFocus,   onFieldSubmitted: (_) {     FocusScope.of(context).requestFocus(_passwordFocus);   }, ), TextFormField(   focusNode: _passwordFocus, ),

Как показать ошибки после отправки (например, с сервера)

Чтобы отрисовать ошибку, которая пришла с бэкенда, можно использовать TextFormField.decoration.errorText:

String? serverError;  TextFormField(   controller: _emailController,   decoration: InputDecoration(     labelText: 'Email',     errorText: serverError,   ), );  // Внутри кнопки или в ответе сервера: setState(() {   serverError = 'Такой email не зарегистрирован'; });

Пример формы для редактирования существующих данных

Если вы загружаете данные пользователя из базы или API и хотите отобразить их в форме, используйте TextEditingController с начальными значениями:

final _formKey = GlobalKey<FormState>(); final _emailController = TextEditingController(text: 'user@example.com'); final _nameController = TextEditingController(text: 'Иван Иванов');  Form(   key: _formKey,   child: Column(     children: [       TextFormField(         controller: _emailController,         decoration: InputDecoration(labelText: 'Email'),       ),       TextFormField(         controller: _nameController,         decoration: InputDecoration(labelText: 'Имя'),       ),       ElevatedButton(         onPressed: () {           if (_formKey.currentState!.validate()) {             final updatedEmail = _emailController.text;             final updatedName = _nameController.text;             print('Сохранение: \$updatedEmail, \$updatedName');           }         },         child: Text('Сохранить'),       ),     ],   ), );

Кастомные поля без библиотек

Иногда нужно создавать свои собственные виджеты, которые не являются частью стандартного набора TextFormField или DropdownButtonFormField. Например, переключатели, селекторы даты, или даже составные поля вроде выбора телефона и страны.Вот пример, как создать кастомное поле, совместимое с формой:

class CustomCheckboxFormField extends FormField<bool> {   CustomCheckboxFormField({     Key? key,     required Widget title,     required FormFieldSetter<bool> onSaved,     FormFieldValidator<bool>? validator,     bool initialValue = false,     AutovalidateMode autovalidateMode = AutovalidateMode.disabled,   }) : super(           key: key,           onSaved: onSaved,           validator: validator,           initialValue: initialValue,           autovalidateMode: autovalidateMode,           builder: (FormFieldState<bool> state) {             return Column(               crossAxisAlignment: CrossAxisAlignment.start,               children: [                 Row(                   children: [                     Checkbox(                       value: state.value,                       onChanged: (value) {                         state.didChange(value);                       },                     ),                     title,                   ],                 ),                 if (state.hasError)                   Padding(                     padding: const EdgeInsets.only(top: 5),                     child: Text(                       state.errorText ?? '',                       style: TextStyle(color: Colors.red),                     ),                   ),               ],             );           },         );   }

Такой подход позволяет создать переиспользуемый UI-компонент, который работает со стандартным Form, FormState, и умеет валидироваться, сохраняться и показывать ошибки.

Проверка на изменения перед выходом

Иногда нужно предупредить пользователя, если он покидает экран с несохранёнными изменениями. Пример с использованием WillPopScope:

final _initialEmail = 'user@example.com'; final _initialName = 'Иван Иванов';  final _emailController = TextEditingController(text: _initialEmail); final _nameController = TextEditingController(text: _initialName);  WillPopScope(   onWillPop: () async {     final hasChanged = _emailController.text != _initialEmail ||                        _nameController.text != _initialName;      if (hasChanged) {       final shouldLeave = await showDialog<bool>(         context: context,         builder: (ctx) => AlertDialog(           title: Text('Вы уверены?'),           content: Text('У вас есть несохранённые изменения. Покинуть экран?'),           actions: [             TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text('Отмена')),             TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text('Покинуть')),           ],         ),       );       return shouldLeave ?? false;     }      return true;   },   child: Form(     key: _formKey,     child: Column(       children: [         TextFormField(controller: _emailController, decoration: InputDecoration(labelText: 'Email')),         TextFormField(controller: _nameController, decoration: InputDecoration(labelText: 'Имя')),         ElevatedButton(           onPressed: () {             if (_formKey.currentState!.validate()) {               print('Сохранение: \${_emailController.text}, \${_nameController.text}');             }           },           child: Text('Сохранить'),         ),       ],     ),   ), );

Чтобы отрисовать ошибку, которая пришла с бэкенда, можно использовать TextFormField.decoration.errorText:

String? serverError;  TextFormField(   controller: _emailController,   decoration: InputDecoration(     labelText: 'Email',     errorText: serverError,   ), );  // Внутри кнопки или в ответе сервера: setState(() {   serverError = 'Такой email не зарегистрирован'; });

Преимущества

  • Простая реализация и понимание

  • Поддерживается большинством базовых FormField

Недостатки:

  • Нужно вручную создавать и управлять множеством контроллеров, фокусами и состояниями ошибок

  • Неудобно обрабатывать валидационные или сетевые ошибки после отправки формы

  • Сложности при работе с состоянием через Bloc или Cubit, так как встроенные FormField не интегрируются напрямую с внешними состояниями и требуют дополнительных прослоек или обёрток

  • Неудобно расширять или переиспользовать составные поля без написания кастомных FormField

Работа с формой без TextEditingController

Вместо использования TextEditingController можно задать initialValue и собрать данные через onSaved:

String? email;  Form(   key: _formKey,   child: Column(     children: [       TextFormField(         initialValue: 'user@example.com',         decoration: InputDecoration(labelText: 'Email'),         validator: (value) =>             value != null && value.contains('@') ? null : 'Некорректный email',         onSaved: (value) => email = value,       ),       ElevatedButton(         onPressed: () {           if (_formKey.currentState!.validate()) {             _formKey.currentState!.save();             print('Email: \$email');           }         },         child: Text('Сохранить'),       ),     ],   ), );

Плюсы подхода без контроллеров

  • Меньше кода: не нужно создавать и очищать контроллеры вручную

  • Удобно, если данные нужны только в момент отправки

Минусы

  • Нельзя изменить значение поля после инициализации

  • Нет прямого доступа к текущему значению вне onSaved

  • Не получится отслеживать изменения текста в реальном времени

Жизненный цикл TextEditingController и FocusNode

Если вы всё же используете контроллеры и FocusNode, не забудьте их очищать в dispose():

@override void dispose() {   _emailController.dispose();   _passwordController.dispose();   _emailFocus.dispose();   _passwordFocus.dispose();   super.dispose(); }

Минусы:

  • Нужно вручную создавать и управлять множеством контроллеров, фокусами и состояниями ошибок

  • Неудобно обрабатывать валидационные или сетевые ошибки после отправки формы

  • Сложности при работе с состоянием через Bloc или Cubit, так как встроенные FormField не интегрируются напрямую с внешними состояниями и требуют дополнительных прослоек или обёрток

  • Неудобно расширять или переиспользовать составные поля без написания кастомных FormField

  • Сложно расширять

  • Нельзя использовать произвольные виджеты без оборачивания в FormField

  • Неудобно работать с фокусом, асинхронной валидацией и обновлением состояния

Тестирование форм

Надёжное тестирование форм помогает убедиться, что ваша логика валидации, обработки и отображения ошибок работает корректно. Во Flutter можно использовать несколько уровней тестирования:

testWidgets('Form shows error on invalid email', (tester) async {   final formKey = GlobalKey<FormState>();    await tester.pumpWidget(     MaterialApp(       home: Form(         key: formKey,         child: TextFormField(           validator: (val) => val!.contains('@') ? null : 'Invalid email',         ),       ),     ),   );    await tester.enterText(find.byType(TextFormField), 'notanemail');   formKey.currentState!.validate();   await tester.pump();    expect(find.text('Invalid email'), findsOneWidget); });

Тестирование бизнес-логики отдельно от UI

Если вы используете Bloc, ChangeNotifier, ValueNotifier, то можно покрыть тестами логику формы отдельно от интерфейса:

blocTest<LoginBloc, LoginState>(   'emits new state with updated email',   build: () => LoginBloc(),   act: (bloc) => bloc.add(EmailChanged('user@example.com')),   expect: () => [LoginState(email: 'user@example.com')], );

Интеграционные тесты (end-to-end)

Проверяют весь поток: заполнение формы, нажатие на кнопку, переход на другой экран и т.д.

testWidgets('Login flow', (tester) async {   await tester.pumpWidget(MyApp());    await tester.enterText(find.byKey(Key('emailField')), 'user@example.com');   await tester.enterText(find.byKey(Key('passwordField')), 'secret');   await tester.tap(find.byKey(Key('loginButton')));   await tester.pumpAndSettle();    expect(find.text('Добро пожаловать'), findsOneWidget); });

Юнит-тесты для валидации

Если валидаторы вынесены в отдельные функции, их можно протестировать напрямую:

String? emailValidator(String? val) {   if (val == null || !val.contains('@')) return 'Invalid email';   return null; }  test('email validator returns error for invalid email', () {   expect(emailValidator('nope'), 'Invalid email');   expect(emailValidator('test@mail.com'), null); });

В ходе анализа базовых инструментов Flutter для работы с формами мы выявили ключевые проблемы, с которыми сталкиваются разработчики:

  • Сложность масштабирования — необходимость создания множества контроллеров и узлов фокуса

  • Неудобство асинхронной валидации — отсутствие встроенной поддержки асинхронных проверок

  • Проблемы интеграции с системами управления состоянием

  • Избыточность кода при работе со сложными формами

Что ждёт впереди

В последующих частях мы подробно разберём такие инструменты, как go_formreactive_forms и другие популярные решения, которые:

  • Автоматизируют рутинные задачи

  • Упростят работу с асинхронной валидацией

  • Предоставят готовые решения для сложных форм

  • Повысят производительность разработки

Помните, что выбор правильного инструмента для работы с формами критически важен для успеха вашего проекта. Нативные решения Flutter — это хороший старт, но для серьёзных приложений потребуются более мощные инструменты.

Тг автора: kotelnikoff_dev


ссылка на оригинал статьи https://habr.com/ru/articles/922846/