Pull to refresh

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

Level of difficultyEasy
Reading time9 min
Views1.2K

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

В современном 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

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
0
Comments1

Articles