Формы являются фундаментальным элементом любого современного приложения. Независимо от того, создаете ли вы корпоративный портал, социальную сеть или электронную коммерцию — работа с пользовательскими данными через формы неизбежна.
В современном Flutter-приложении формы встречаются повсеместно:
-
Аутентификация — логин и регистрация
-
Профили пользователей — настройка личных данных
-
Формы обратной связи — сбор отзывов и предложений
-
Фильтры и поиск — сложные системы фильтрации
-
Анкеты и опросы — сбор структурированной информации
-
Настройки приложения — персонализация интерфейса
Базовые инструменты Flutter
Фреймворк Flutter предоставляет встроенные компоненты для работы с формами:
-
Form— основной контейнер для группы полей -
TextFormField— базовое текстовое поле с валидацией -
TextEditingController— управление состоянием ввода -
GlobalKey<FormState>— доступ к состоянию формы
Эти инструменты отлично справляются с простыми задачами, но по мере роста сложности приложения возникают новые требования:
-
Масштабируемость — поддержка большого количества полей
-
Повторное использование — создание переиспользуемых компонентов
-
Асинхронная валидация — проверка данных на сервере
-
Управление состоянием — интеграция с глобальным состоянием приложения
-
Тестирование — обеспечение качества кода
Цель материала
В этой серии статей мы подробно рассмотрим различные подходы к работе с формами во Flutter, начиная с базовых инструментов и заканчивая современными решениями. Вы узнаете:
-
Как эффективно использовать встроенные возможности Flutter
-
Какие существуют альтернативные библиотеки и подходы
-
Как выбрать оптимальное решение для вашего проекта
-
Как избежать распространенных ошибок при работе с формами
В первой части мы сосредоточимся на базовых принципах работы с формами, используя стандартные инструменты Flutter. В последующих материалах рассмотрим более продвинутые решения, включая go_form, reactive_forms и другие современные библиотеки, которые помогут вам создавать масштабируемые и поддерживаемые решения для работы с формами.
Погрузимся в изучение основ работы с формами во Flutter, чтобы заложить прочный фундамент для создания качественных пользовательских интерфейсов.
Стандартные формы Flutter (Form и FormField)
Flutter предоставляет встроенный механизм для создания форм. Основные элементы — это Form, FormState, TextFormField и 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_form, reactive_forms и другие популярные решения, которые:
-
Автоматизируют рутинные задачи
-
Упростят работу с асинхронной валидацией
-
Предоставят готовые решения для сложных форм
-
Повысят производительность разработки
Помните, что выбор правильного инструмента для работы с формами критически важен для успеха вашего проекта. Нативные решения Flutter — это хороший старт, но для серьёзных приложений потребуются более мощные инструменты.
Тг автора: kotelnikoff_dev
ссылка на оригинал статьи https://habr.com/ru/articles/922846/
Добавить комментарий