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

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

class MyCustomFormState extends State <MyCustomForm> {

  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: const Column(
        children: <Widget>[
          // Add TextFormFields and ElevatedButton here.
        ],
      ),
    );
  }
}

Вот пример обычной формы. Мы создаем форму, прописываем formKey и дальше перечисляем виджеты. Вот, например, виджет TextFormField:

TextFormField(
  validator: (value) {
    if (value == null || value.isEmpty) {
      return 'Please enter some text';
    }
    return null;
  },
),

Здесь у нас появляется inline-валидация — и это проблема, хотя валидацию и можно спрятать отдельно в файл или метод.

ElevatedButton(
  onPressed: () {
    if (_formKey.currentState!.validate()) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Processing Data')),
      );
    }
  },
  child: const Text('Submit'),
),

В этом примере мы проверяем, валидны ли формы (например, по клику на кнопку), и потом выполняем какие-то действия. Всё будто бы нормально, но чтобы просто отправить два поля на бэкенд, мы должны прописать два контроллера, инициализировать их и не забыть dispose. Получается немало кода:

class _HomePageState extends State<HomePage> {
  final _formKey = GlobalKey<FormState>();

  late final TextEditingController _emailController;
  late final TextEditingController _passwordController;

  @override
  void initState() {
    super. initState();
    _emailController = TextEditingController();
    _passwordController = TextEditingController();
  }

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

Если ваша форма содержит хотя бы 5-10 полей, результат будет выглядеть диковато. Итоговый список проблем:

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

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

  • Необходимо использовать inline-код валидации.

  • Необходимо использовать onChanged и onSave колбэки, чтобы наполнять модель.

  • Необходимо использовать dispose для контроллеров.

Реактивные формы

Альтернативное решение — это библиотека reactive_forms, во многом скопированная из Angular. Если вы будете гуглить реактивные формы, то сначала, скорее всего, попадете на реактивные формы Angular, которые хорошо зарекомендовали себя на фронтенде. 

class LoginFormState extends State<LoginForm> {

  final form = FormGroup ({
    'email': FormControl<String>(
      validators: [
        Validators.required,
        Validators.email
      ]),
    'password': FormControl<String>(
      validators: [
        Validators.required
      ]),
  });
}

Инициализация реактивных форм проста: достаточно создать группу FormGroup и в ней FormControl — это поля, которые мы можем вложить в структуру Map. Если у вас имеется какая-то сущность в виде массива, можно сделать FormArray вместо FormGroup, и там уже будут свои методы.

Установить значение формы довольно просто — через setState или другим привычным способом:

this.form.value = {
  'name': 'John',
  'email': 'john@email.com',
};

У reactive_forms есть свои обертки для существующих виджетов и сторонних библиотек. Можно обернуть и свои виджеты. Поведение и вид основных контролов не меняются, они просто расширяются дополнительными функциями, чтобы всё работало.

@override
Widget build(BuildContext context) {
  return ReactiveForm(
    formGroup: this.form,
    child: Column(
      children: <Widget> [
        ReactiveTextField(
          formControlName: 'email'
        ),
        ReactiveTextField(
          formControlName: 'password',
          obscureText: true,
        ),
      ],
    ),
  );
}

В примере мы видим, что formControlName — это обязательное поле, где мы указываем, с какими виджетами будет мапиться наша форма.

Библиотека уже «из коробки» предоставляет валидаторы: выше в коде были приведены required и email. Мы можем взять их и сразу добавлять валидационные сообщения. Не нужно писать inline-код валидации, достаточно только самих сообщений:

children: <Widget>[
  ReactiveTextField(
    formControlName: 'email',
    validationMessages: {
      'required': (error) => 'The email must not be empty',
      'email': (error) => 'The email value must be a valid email'
    },
  ),
  ReactiveTextField(
    formControlName: 'password',
    obscureText: true,
    validationMessages: {
      'required': (error) => 'The password must not be empty'
      'minLength': (error) => 'The password must have at least 8 characters'
    },
  ),
],

Также мы можем блокировать, например, кнопку отправки, если наша форма невалидна. Получить значение form.valid просто:

ElevatedButton(
  onPressed: form.valid ? _onSubmit : null,
  child: const Text ("Submit"),
)

Можем писать и свои, кастомные валидаторы:

class MustMatchValidator extends Validator<dynamic> {
  final String controlName;
  final String matchingControlName;

  MustMatchValidator ( {
    required this.controlName,
    required this.matchingControlName,
}) : super();

  @override
  Map<String, dynamic>? validate (AbstractControl<dynamic> control) {
    final form = control as FormGroup;

    final formControl = form.control(controlName);
    final matchingFormControl = form.control(matchingControlName);

    if (formControl.value != matchingFormControl.value) {
      matchingFormControl.setErrors({'mustMatch': true});
      matchingFormControl.markAsTouched();
    } else {
      matchingFormControl.removeError('mustMatch');
    }

    return null;
  }
}

В этом валидаторе мы можем и получить значение вообще всей FormGroup, и, например, сделать кросс-валидацию между двумя полями. Вообще, валидатор выше взят «из коробки»; я привел этот пример специально, чтобы показать, какая это мощная штука, насколько сложную логику здесь можно написать.

final form = FormGroup({
  'email': FormControl<String>(validators: [Validators.required,
Validators.email]),
  'password': FormControl<String>(validators: [Validators.required,
Validators.minLength(8)]),
  'passwordConfirmation': FormControl<String>(),
}, validators: [
  MustMatchValidator(controlName: 'password', matchingControlName: 'passwordConfirmation' )
]);

А вот как встраивается кастомный валидатор: мы указываем два поля, что с чем должно совпадать, и он работает. Можно добавить на passwordConfirmation сообщение, которое будет отображаться.

final form = FormGroup({
  'cardNumber': FormControl<String>(
    validators: [
      Validators.composeOR( [
        Validators.pattern(americanExpressCardPattern),
        Validators.pattern(masterCardPattern),
        Validators. pattern(visaCardPattern),
      ])
    ],
  ),
});

В реактивных формах предусмотрен и удобный композер для валидаторов, позволяющий объединить паттерны для валидации. Это может быть composeOR, как в примере — тогда будет применяться «что-то из». Или просто compose — тогда будут применяться «все». Например, если вы хотите проверить несколько вещей независимо.

final Login login = Login(
  email: form.control('email').value,
  password: form.control('password').value);

…

final Login login = Login.fromJson(form.value);

Мы можем с каждого control достать значения и сделать для них set — например, form.control и email.value в примере выше. Либо, поскольку сама форма хранится в Map<String, dynamic>, можно использовать методы fromJson модели: просто сделать set всей формы на класс модели, как во втором примере. Это удобно, потому что нам не нужно всё описывать и мапить поля.

Вот лишь часть виджетов, доступных в реактивных формах:

  • ReactiveTextField

  • ReactiveDropdownField

  • ReactiveSwitch

  • ReactiveCheckbox

  • ReactiveRadio

  • ReactiveSlider

  • ReactiveCheckboxListTile

  • ReactiveSwitchListTile

  • ReactiveRadioListTile

  • ReactiveDatePicker

  • ReactiveTimePicker

Есть еще отдельный пакет reactive_forms_widgets, в котором поддерживаются все сторонние виджеты.

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

Кое-что стоит рассказать и про тестирование. Возьмем для примера форму:

class LoginFormState extends State<LoginForm> {
  final form = FormGroup({
    'email': FormControl<String>(validators: [Validators.required, Validators.email]),
    'password': FormControl<String>(validators: [Validators.required]),
  });

  @override
  Widget build(BuildContext context) {
    return ReactiveForm
      formGroup: form,
      child: Column(
        children: <Widget> [
          ReactiveTextField(
            key: const Key('email'),
            formControlName: 'email',
          ),
          ReactiveTextField(
            key: const Key('password'),
            formControlName: 'password',
            obscureText: true,
          ),
          ElevatedButton(
            key: const Key('submit'),
            onPressed: form.valid ? _onSubmit : null,
            child: const Text('Submit'),
          ),
        ],

Сверху мы сделали set для FormGroup. Снизу у нас сборка самого виджета, build-метод. Далее включены поля ReactiveTextField и кнопка Submit.

Кстати, стоит сказать еще об одной хорошей штуке. Мы можем делать наши формы полностью «тупыми» — без какой-либо логики и отправки данных на бэкенд:

void _onSubmit() {
  widget.callback(form.value)
}

Мы просто передаем виджет формы в callback-функцию и потом вызываем ее наверх, на submit, то есть со значением функции. Таким образом, тестировать эту форму будет очень удобно и просто, без необходимости сложной подготовки. Если бы вы использовали какой-нибудь dependency injection, потребовались бы отдельные усилия, чтобы хорошо всё замокать. А здесь можно просто выводить данные наверх.

В примере ниже мы «прогреваем» наш виджет — передаем туда LoginForm:

void main| ) {
  testwidgets('LoginForm should pass with correct values', (tester) async {

    await tester.pumpWidget( const MaterialApp(
      home: Scaffold(body: LoginForm()),
    ));

    await tester.enterText(find.byKey(const Key'email')), 'etc@test.qa');
    await tester.enterText(find.byKey(const Key('password')), 'password');

    await tester.tap(find.byKey(const Key('submit')));

    await tester.pump();

    expect (find.text('etc@test.qa'), findsOneWidget);

    final LoginFormState loginFormState = tester.state(find.byType(LoginForm));

    expect(loginFormState.form.valid, true);
  }):
}

Кстати, остановлюсь еще на одном важном моменте. Зачем добавлять ключи key для каждого поля? Чтобы через widget testing можно было просто обратиться к этому полю.

Вернемся к примеру выше. В нем мы обращаемся к полям email и password, вводим какие-то данные и можем нажать кнопку Submit. Потом делаем pump, чтобы произошли нужные изменения, и дальше смотрим, что у нас есть поле с введенным почтовым адресом. Эта проверка опциональна.

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

Тестировать форму из примера можно на юнит-уровне. Мы можем просто поднять ее, и она заработает: после добавления значений можно будет сразу проверять результаты валидации, без поднятия виджета.

Итоги

Все перечисленные в статье проблемы обычных форм решены в реактивных формах. Всю форму сразу можно получать без огромного количества кода и его дублирования. Мы не используем контроллеры. Вся валидация лежит отдельно в классах. Не нужны onChanged и onSave колбэки, не надо следить за dispose контроллеров.

Помимо решения проблем, мы получаем и дополнительные преимущества. У нас теперь есть декларативное описание ошибок. Валидаторы вынесены, модель и представление разделены — это удобно и понятно с точки зрения разработки. Тестировать также становится проще. И наконец, у нас получается меньше кода — а значит, и меньше проблем.

Комментарии (3)


  1. navar
    13.10.2023 04:44

    Недостаток предложенного подхода — повсеместное использование строковых ключей.


    1. pumano Автор
      13.10.2023 04:44

      ключи используются для тестов, если ты про Key


      1. navar
        13.10.2023 04:44

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