Формы являются фундаментальным элементом любого современного приложения. Независимо от того, создаете ли вы корпоративный портал, социальную сеть или электронную коммерцию — работа с пользовательскими данными через формы неизбежна.
В современном 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