Рад представить вам свою библиотеку GoForm — решение, которое выросло из боли и страданий при работе с нативными формами Flutter.

В предыдущей статье мы подробно разобрали, почему стандартные инструменты Flutter для работы с формами (Form, TextFormField, GlobalKey) начинают трещать по швам при масштабировании проекта. Помните эти бесконечные TextEditingController, проблемы с асинхронной валидацией и танцы с бубном вокруг состояния?

Я столкнулся с этими проблемами не раз и не два, и вместо того чтобы каждый раз изобретать велосипед, решил создать инструмент, который избавит вас от этих страданий. Так появился GoForm

В этой статье мы подробно разберем, как GoForm решает все те проблемы, с которыми мы столкнулись при работе с нативными формами, и даже больше.

Готовы узнать, как сделать работу с формами во Flutter приятной и продуктивной? Тогда давайте начнем!

И да, это моя библиотека — я знаю о её подводных камнях не понаслышке и готов поделиться всеми тонкостями использования. И костылями.

Почему GoForm?

GoForm — это современное решение для работы с формами, которое:

  • Упрощает создание и управление формами

  • Автоматизирует рутинные задачи (валидация, фокус, состояние)

  • Предоставляет удобный API для валидации (в том числе асинхронной)

  • Легко интегрируется с Riverpod, Bloc, Provider и другими системами

  • Работает на основе одного FormController

  • Поддерживает такие фичи как debounce, скролл к ошибкам, установка ошибок с сервера и т.д.

Основные преимущества по сравнению с нативными формами Flutter:

  • Единое состояние для всех полей формы.

  • Автоматическая валидация без необходимости писать TextEditingController на каждое поле.

  • Простая интеграция с архитектурными решениями.

  • Гибкая настройка валидаторов и преобразователей значений.

  • Минималистичный и читаемый синтаксис при создании формы.

⚠️ Важное предупреждение: в статье будет много кода, и это круто! Но чтобы не превратить чтение в марафон по пустыне, я спрятал большую часть примеров под спойлеры.

Оглавление:

Создание кастомных полей

Создание кастомных полей — одна из сильных сторон GoForm. Вы можете строить любые виджеты, сохраняющие реактивную связь с формой. Ниже — пример нескольких различных кастомных импутов.


Кроме того, если вы используете FormFieldModelBase<String>, то FieldController автоматически содержит поле textController, которое удобно использовать для TextField или TextFormField. Это избавляет от необходимости вручную создавать и синхронизировать TextEditingController. Однако тип T может быть любым — не обязательно String. Это может быть, например, bool, int, List, DateTime, или даже ваша собственная модель. Это делает FormFieldModelBase универсальной основой для создания любых контролируемых полей.

Пример 1: Текстовое поле

Код для текстового поля
class GoTextInput extends FormFieldModelBase<String> {
  final String label;
  final Widget? prefix;
  final List<TextInputFormatter>? inputFormatters;
  final TextInputType? keyboardType;

  GoTextInput({
    required super.name,
    super.validator,
    required this.label,
    this.prefix,
    this.inputFormatters,
    this.keyboardType,
    super.key,
    super.initialValue,
    super.debounceDuration,
    super.asyncValidator,
  });

  @override
  Widget build(BuildContext context, FieldController<String> controller) {
    return RootInput(
      onChanged: (newValue) => controller.onChange(newValue),
      initialValue: controller.value,
      validator: validator,
      errorText: controller.error,
      labelText: label,
      prefix: prefix,
      inputFormatters: inputFormatters,
      focusNode: controller.focusNode,
    );
  }
}

Пример 2: Поле ввода пароля

Код примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:go_form_example/inputs/root_input.dart';

class GoPasswordInput extends FormFieldModelBase<String>{
  final String label;
  GoPasswordInput( {required super.name, super.validator,required this.label,});

  @override
  Widget build(BuildContext context, FieldController controller) {
    return _PasswordField(
      controller: controller,
      label: label,
      validator: validator,
    );
  }
}

class _PasswordField extends StatefulWidget {
  final FieldController controller;
  final String label;
  final String? Function(String?)? validator;

  const _PasswordField({
    required this.controller,
    required this.label,
    this.validator,
  });

  @override
  State<_PasswordField> createState() => _PasswordFieldState();
}

class _PasswordFieldState extends State<_PasswordField> {
  bool showPassword = false;

  @override
  Widget build(BuildContext context) {
    return RootInput(
      onChanged: (newValue) => widget.controller.onChange(newValue),
      initialValue: widget.controller.value,
      validator: widget.validator,
      errorText: widget.controller.error,
      labelText: widget.label,
      suffixIcon: IconButton(
        icon: Icon(showPassword ? Icons.visibility : Icons.visibility_off),
        onPressed: () {
          setState(() {
            showPassword = !showPassword;
          });
        },
      ),
      obscureText: !showPassword,
    );
  }
}

Пример 3: CheckBox

Чек-бокс из примера.
class GoCheckBox extends FormFieldModelBase<bool> {
  final String label;

  GoCheckBox({
    required super.name,
    super.initialValue = false,
    super.validator,
    required this.label,
  });

  @override
  Widget build(BuildContext context, FieldController controller) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            Checkbox(
              value: controller.value,
              onChanged: (newValue) {
                controller.onChange(newValue);
              },
            ),
            Text(label),
          ],
        ),
        if (controller.error != null)
          Padding(
            padding: const EdgeInsets.only(top: 4.0),
            child: Text(
              controller.error!,
              style: TextStyle(color: Colors.red),
            ),
          ),
      ],
    );  }

}

Пример 4: селектор фото

Код примера
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:image_picker/image_picker.dart';

String formatFileSize(int bytes) {
  const suffixes = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'];
  double size = bytes.toDouble();
  int i = 0;

  while (size >= 1024 && i < suffixes.length - 1) {
    size /= 1024;
    i++;
  }

  return '${size.toStringAsFixed(1)} ${suffixes[i]}';
}

class GoFormFiles extends FormFieldModelBase<List<File>> {
  GoFormFiles({
    required super.name,
    super.initialValue = const [],
    super.validator,
  });

  @override
  Widget build(BuildContext context, FieldController<List<File>> controller) {
    return Column(
      children: [
        SizedBox(
          width: MediaQuery.of(context).size.width,
          child: ElevatedButton(
            onPressed: () async {
              showModalBottomSheet(
                context: context,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
                ),
                builder: (context) {
                  return SafeArea(
                    child: Wrap(
                      children: [
                        ListTile(
                          leading: Icon(Icons.photo_library),
                          title: Text('Выбрать фото'),
                          onTap: () async {
                            Navigator.of(context).pop();
                            final picker = ImagePicker();
                            final pickedFile = await picker.pickImage(source: ImageSource.gallery);
                            print(pickedFile!=null);
                            if (pickedFile != null) {
                              final image = File(pickedFile.path);
                              controller.onChange([...controller.value ?? [], image]);
                            }
                          },
                        ),
                        ListTile(
                          leading: Icon(Icons.cancel),
                          title: Text('Отмена'),
                          onTap: () => Navigator.of(context).pop(),
                        ),
                      ],
                    ),
                  );
                },
              );
            },
            child: Text('Выбрать фото'),
          ),
        ),
        ListView.builder(
          itemBuilder: (context, index) {
            final item = controller.value?[index];
            final file = item!;
            return ListTile(
              contentPadding: EdgeInsets.zero,
              leading: Image.file(file),
              title: Text(file.path.split('/').last, maxLines: 2, overflow: TextOverflow.ellipsis),
              subtitle: Text(formatFileSize(file.lengthSync())),
              trailing: IconButton(
                onPressed: () {
                  controller.onChange(List.from(controller.value ?? [])..remove(item));
                },
                icon: const Icon(Icons.delete, color: Colors.red),
              ),
            );
          },
          shrinkWrap: true,
          itemCount: controller.value?.length ?? 0,
        ),
        if (controller.error != null)
          Text(
            controller.error!,
            style: const TextStyle(color: Colors.red),
          )
      ],
    );
  }
}

Эти примеры можно легко включать в DynamicForm, комбинируя по необходимости в разных сценариях UI.

Пример 5: Выпадающий список (Dropdown)

Вы также можете использовать сторонние виджеты, такие как dropdown_button2, flutter_datetime_picker, intl_phone_field и другие — просто обернув их в FormFieldModelBase и синхронизируя с FieldController.

Код примера
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';

class GoDropdownButton<T> extends FormFieldModelBase<T> {
  final List<T> items;

  const GoDropdownButton({
    required super.name,
    required this.items,
    super.asyncValidator,
    super.initialValue,
    super.validator,
    super.key,
  });

  @override
  Widget build(BuildContext context, FieldController<T> controller) {
    return DropdownButtonHideUnderline(
      child: DropdownButton2<T>(
        onChanged: (T? value) {
          controller.onChange(value);
        },
        value: controller.value,
        items: items
            .map(
              (item) => DropdownMenuItem<T>(
                value: item,
                child: Text(item.toString()),
              ),
            )
            .toList(),
      ),
    );
  }
}

Такой дропдаун можно использовать внутри DynamicForm, задав список значений и, при необходимости, валидацию.

Пример использования:

GoDropdownButton<String>(
  name: 'gender',
  items: ['Мужской', 'Женский', 'Другое'],
  validator: (val) {
    if (val == null || val.isEmpty) {
      return 'Пожалуйста, выберите значение';
    }
    return null;
  },
  initialValue: 'Мужской',
),

Пример базовой формы

Один из самых распространённых сценариев — форма логина или ввода контактных данных. Ниже — пример базовой формы, включающей в себя email, телефон, пароль и чекбокс согласия:

Код из примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:go_form_example/inputs/go_password_input.dart';

import '../inputs/inputs.dart';

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _formController = FormController(debug: true);
  String result='';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        DynamicForm(
          fields: [
            GoTextInput(
              name: 'email',
              label: 'Email',
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Согласись';
                }
                return null;
              },
            ),
            GoPasswordInput(
              name: 'password',
              label: 'Password',
              validator: (val) {
                if (val == null || val.isEmpty) {
                  return 'Согласись';
                }
                return null;
              },
            ),
            GoCheckBox(
              name: 'checkbox',
              label: 'checkbox',
              validator: (val) {
                if (val == null || val == false) {
                  return 'Согласись';
                }
                return null;
              },
            ),
          ],
          controller: _formController,
        ),
        ElevatedButton(
          onPressed: () {
            _formController.resetAllErrors();
            if (!_formController.validate()) {
              return;
            }
            print('${_formController.getValues()}');
            setState(() {
              result='${_formController.getValues()}';
            });
          },
          child: const Text('Результат'),
        ),
        const SizedBox(
          height: 30,
        ),
        Text(result)
      ],
    );
  }
}

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

Работа с уже заполненными формами (initialValue)

Если вы работаете с редактированием существующих данных — например, профиля пользователя или сохранённой анкеты — то поля формы должны быть изначально заполнены. GoForm позволяет передать initialValue каждому полю, и оно корректно отобразит начальное значение и синхронизирует его с FormController.

Код примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';

import '../inputs/go_dropdown_button.dart';
import '../inputs/go_dynamic_input.dart';

class InitValuesPage extends StatefulWidget {
  const InitValuesPage({super.key});

  @override
  State<InitValuesPage> createState() => _InitValuesPageState();
}

class _InitValuesPageState extends State<InitValuesPage> {
  final _formController = FormController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Редактирование профиля'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            DynamicForm(
              fields: [
                GoDynamicInput(
                  name: 'name',
                  label: 'Имя',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Пожалуйста, введите имя';
                    }
                    return null;
                  },
                  initialValue: 'Алексей',
                ),
                GoDynamicInput(
                  name: 'email',
                  label: 'Email',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Введите email';
                    }
                    final emailRegex = RegExp(r'^[\w\.-]+@[\w\.-]+\.\w+$');
                    if (!emailRegex.hasMatch(val)) {
                      return 'Некорректный email';
                    }
                    return null;
                  },
                  initialValue: 'alexey@example.com',
                ),
                GoDropdownButton(
                  name: 'gender',
                  items: ['Мужской', 'Женский', 'Другое'],
                  initialValue: 'Мужской',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Выберите пол';
                    }
                    return null;
                  },
                ),
              ],
              controller: _formController,
            ),
            ElevatedButton(
              onPressed: () {
                if (!_formController.validate()) {
                  return;
                }
                print('${_formController.getValues()}');
              },
              child: Text('Результат'),
            ),

          ],
        ),
      ),
    );
  }
}

Каждое поле может принимать initialValue. Эти значения попадут в FormController и будут доступны через getValues() или getFieldValue(...). Это удобно и для отображения, и для отправки данных на сервер.

Асинхронная валидация

Асинхронная валидация особенно полезна, когда необходимо проверить значение поля через внешний API — например, доступность имени пользователя или правильность кода подтверждения. GoForm позволяет использовать асинхронную валидацию через параметр asyncValidator. При этом можно задать debounceDuration, чтобы избежать лишних сетевых запросов при частом вводе данных. Ниже приведён пример формы с асинхронной валидацией:

Код из примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';

import '../inputs/go_text_input.dart';

/// Пример использования асинхронного валидатора с GoForm.
/// После ввода в поле "Search" и нажатия кнопки происходит проверка значения.
/// Если поле пустое — возвращается ошибка.
class AsyncValidatorPage extends StatefulWidget {
  const AsyncValidatorPage({super.key});

  @override
  State<AsyncValidatorPage> createState() => _AsyncValidatorPageState();
}

class _AsyncValidatorPageState extends State<AsyncValidatorPage> {
  final formController = FormController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Async Validator'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: Column(
          children: [
            DynamicForm(
              fields: [
                GoTextInput(
                  name: 'search',
                  label: 'Search',
                  asyncValidator: (value) async {
                    await Future.delayed(const Duration(seconds: 2));
                    if (value == null || value.isEmpty) {
                      return 'Поле обязательно';
                    }
                    // Можно добавить проверку на уникальность через API
                    if (value == 'admin') {
                      return 'Это имя уже занято';
                    }
                    return null;
                  },
                ),
              ],
              controller: formController,
            ),
            const SizedBox(height: 16),
            ElevatedButton(
              onPressed: () async {
                final result = await formController.validateAsync();
                if (result) {
                  final value = formController.getFieldValue<String>('search');
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('Введено: $value')),
                  );
                }
              },
              child: const Text('Проверить'),
            ),
          ],
        ),
      ),
    );
  }
}

Ключевые особенности:

  • asyncValidator может быть задан на любом поле.

  • Можно отображать прелоадер рядом с полем при выполнении запроса (через FieldController.status).

Асинхронная валидация и debounce (отложенные изменения)

Иногда важно не реагировать мгновенно на каждое изменение в поле ввода, а дождаться небольшой паузы — это называется debounce. Такой подход часто используется в поиске, автокомплитах или фильтрации.GoForm позволяет задать задержку обработки изменений через debounceDuration и легко подключить асинхронную валидацию. Ниже — актуальный пример поля поиска с debounce и асинхронной валидацией, а также визуальной индикацией статуса поля:

Код из примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:go_form_example/inputs/go_text_input.dart';

import '../inputs/search_input.dart';

/// Example: Debounced Input Field
///
/// This example demonstrates how to use a debounce delay on a text field
/// using `GoTextInput` from the `go_form` package. The value change is debounced
/// by 2 seconds and the result is displayed on the screen.
///
/// This is useful for cases like search inputs, where you want to limit
/// how often the form reacts to user typing.
class DebounceExamplePage extends StatefulWidget {
  const DebounceExamplePage({super.key});

  @override
  State<DebounceExamplePage> createState() => _DebounceExamplePageState();
}

class _DebounceExamplePageState extends State<DebounceExamplePage> {
  final formController = FormController();
  String _output = '';

  @override
  void initState() {
    super.initState();
    formController.addFieldValueListener((f, v) {
      setState(() {
        _output = 'Debounced value: $v';
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Debounce'),
      ),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        child: Column(
          children: [
            DynamicForm(
              fields: [
                GoSearchInput(
                  name: 'search',
                  label: 'Search',
                  debounceDuration: const Duration(seconds: 2),
                  asyncValidator: (v) async {
                    if (v == null || v.isEmpty) {
                      return 'Input text';
                    }
                    await Future.delayed(const Duration(seconds: 2));
                    if (v == 'admin') {
                      return 'Name exist';
                    }
                    return null;
                  },
                  onDebounceComplete: () async {
                    print('start onDebounceComplete');
                    await formController.validateAsync();
                  },
                ),
              ],
              controller: formController,
            ),
            const SizedBox(height: 16),
            Text(_output),
            ElevatedButton(
              onPressed: () {
                formController.setError('search', 'error');
              },
              child: Text('Add error'),
            )
          ],
        ),
      ),
    );
  }
}

Динамические действия с формой (ошибки, значения, сброс)

После отправки формы на сервер бывает нужно отобразить ошибки, полученные от API (например, "email уже зарегистрирован"). GoForm предоставляет удобные методы для управления ошибками и значениями полей программно:

Пример использования:

// Установить ошибку на конкретное поле:
formController.setError('email', 'Такой email уже зарегистрирован');

//Установить значение поля вручную
//(например, предзаполнить email из данных пользователя):
formController.setValue('email', 'custom@email.example');

// Сбросить все значения полей:
formController.resetAllFields();

// Сбросить все ошибки:
formController.resetAllErrors();

Эти методы позволяют гибко управлять формой в ответ на действия пользователя или ответы от сервера, не нарушая структуру и реактивность.

Код из примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';

import '../inputs/go_dynamic_input.dart';
import '../inputs/go_text_input.dart';

class DynamicActionsPage extends StatefulWidget {
  const DynamicActionsPage({super.key});

  @override
  State<DynamicActionsPage> createState() => _DynamicActionsPageState();
}

class _DynamicActionsPageState extends State<DynamicActionsPage> {
  final _formController = FormController();
  String _output = '';

  @override
  void initState() {
    super.initState();
    _formController.addListener(() {
      setState(() {
        _output = 'All Values: ${_formController.getValues()}';
      });
    });
    _formController.addFieldValueListener((name, v) {
      setState(() {
        _output = 'Field "$name" changed: $v';
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Dynamic Actions'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            DynamicForm(
              fields: [
                GoDynamicInput(
                  name: 'text',
                  label: 'Email Address',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Please provide an email';
                    }
                    return null;
                  },
                ),
              ],
              controller: _formController,
            ),
            TextButton(
              onPressed: () {
                _formController.setValue('text', 'custom@email.example');
              },
              child: const Text('Set Email'),
            ),
            ElevatedButton(
              onPressed: () {
                if (!_formController.validate()) {
                  return;
                }
                setState(() {
                  _output = 'Validated values: ${_formController.getValues()}';
                });
              },
              child: const Text('Submit'),
            ),
            const SizedBox(
              height: 30,
            ),
            ElevatedButton(
              onPressed: () {
                _formController.resetAllFields();
              },
              child: const Text('Reset Fields'),
            ),
            ElevatedButton(
              onPressed: () {
                _formController.setError('text', 'User exist');
              },
              child: const Text('Set error'),
            ),
            ElevatedButton(
              onPressed: () {
                setState(() {
                  _output =
                      'Single value: ${_formController.getFieldValue<String>('text')}';
                });
              },
              child: const Text('Get Field Value'),
            ),
            const SizedBox(height: 20),
            Text(_output),
          ],
        ),
      ),
    );
  }
}

Управление фокусом

Каждое поле в GoForm управляется своим FieldController, который содержит FocusNode. Это позволяет гибко управлять фокусом как программно (через formController.focus(...), formController.unfocus(...) и focusNode.requestFocus()), так и вручную. Вы можете использовать focusNode в своих кастомных виджетах — например, для управления переходом между полями, анимацией, визуальной подсветкой и другими взаимодействиями.

Пример: Управление фокусом между полями

Ниже пример, который демонстрирует, как можно управлять фокусом полей формы программно: сфокусироваться на определённом поле, снять фокус, перейти к следующему полю и т.д.

Скрытый текст
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';

import '../inputs/go_password_input.dart';
import '../inputs/go_text_input.dart';

class FocusExamplePage extends StatefulWidget {
  const FocusExamplePage({super.key});

  @override
  State<FocusExamplePage> createState() => _FocusExamplePageState();
}

class _FocusExamplePageState extends State<FocusExamplePage> {
  final form = FormController(debug: true);

  @override
  void dispose() {
    form.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Пример фокуса')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            DynamicForm(
              fields: [
                GoTextInput(
                  label: 'Имя',
                  name: 'name',
                ),
                GoTextInput(
                  label: 'Email',
                  name: 'email',
                ),
                GoPasswordInput(
                  name: 'password',
                  label: 'Пароль',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Согласитесь';
                    }
                    return null;
                  },
                ),
              ],
              controller: form,
            ),
            const SizedBox(height: 32),
            Wrap(
              spacing: 10,
              runSpacing: 10,
              children: [
                ElevatedButton(
                  onPressed: () => form.focus('name'),
                  child: const Text('Фокус на имя'),
                ),
                ElevatedButton(
                  onPressed: () => form.focus('email'),
                  child: const Text('Фокус на email'),
                ),
                ElevatedButton(
                  onPressed: () => form.focus('password'),
                  child: const Text('Фокус на пароль'),
                ),
                ElevatedButton(
                  onPressed: () => form.unfocus('email'),
                  child: const Text('Снять фокус с email'),
                ),
                ElevatedButton(
                  onPressed: () => form.focusNext('name'),
                  child: const Text('Фокус на следующее после "name"'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Большие формы и прокрутка к ошибке

Этот пример демонстрирует, как построить форму с большим количеством полей, в которой при наличии ошибки фокус автоматически прокручивается к первому ошибочному полю. Полезно при тестировании производительности ввода и UX при ошибках.

Код из примера

import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import '../inputs/go_text_input.dart';

class BigListPage extends StatefulWidget {
  const BigListPage({super.key});

  @override
  State<BigListPage> createState() => _BigListPageState();
}

class _BigListPageState extends State<BigListPage> {
  final _formController = FormController();
  final ScrollController _scrollController = ScrollController();


  void _validateForm() {
    if(!_formController.validate()){
      _formController.scrollToFirstErrorField();
      return;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Container(
        padding: const EdgeInsets.all(24),
        child: SingleChildScrollView(
          controller: _scrollController,
          child: DynamicForm(
            fields: [
              ...List.generate(50, (index){
                return GoTextInput(
                  name: 'text$index',
                  label: 'Email',
                  validator: (val) {
                    if (val == null || val.isEmpty) {
                      return 'Fill in the field';
                    }
                    return null;
                  },
                  initialValue: 'text ${index}',
                );
              }),
              GoTextInput(
                name: 'email_error',
                label: 'Email',
                validator: (val) {
                  if (val == null || val.isEmpty) {
                    return 'Fill in the field';
                  }
                  return null;
                },
              )
            ],
            controller: _formController,
          ),
        ),
      ),
      bottomNavigationBar: SafeArea(
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10),
          child: ElevatedButton(
            onPressed: _validateForm,
            child: const Text('Validate'),
          ),
        ),
      ),
    );
  }
}

Реакция на изменения

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

  • addFieldValueListener — позволяет отслеживать любые изменения значений в полях.

  • addValidationListener — уведомляет, когда форма становится валидной или невалидной.

  • addFocusListener — позволяет отследить, когда конкретное поле получило или потеряло фокус.

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

Пример: Слушатель изменений значений

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

_formController.addFieldValueListener((name, value) {
      print('Поле "$name" изменено: $value');
    });

Пример: Слушатель фокуса

Метод addFocusListener позволяет реагировать на получение или потерю фокуса конкретными полями. Это может быть полезно для отображения подсказок, запуска валидации или аналитики поведения пользователя.

_formController.addFocusListener((name, focus) {
    print('Поле $name ${focus.hasFocus ? "получило" : "потеряло"} фокус');
  });

Пример: Слушатель валидации

_formController.addValidationListener((v){
          print('Форма ${v ? "прошла" : "не прошла"} валидацию');

    });

Работа с динамической маской номера телефона

Пример с выбором страны и применением маски mask_text_input_formatter.

Следующий пример демонстрирует, как создать поле ввода номера телефона с возможностью выбора кода страны и динамической маской. Мы используем mask_text_input_formatter для установки нужной маски, зависящей от выбранной страны.

Код из примера
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:mask_text_input_formatter/mask_text_input_formatter.dart';
import 'package:flutter_libphonenumber/flutter_libphonenumber.dart';

import '../domain/country_phone_dto.dart';
import '../inputs/go_text_input.dart';
import '../inputs/root_input.dart';

class PhoneAndCountry extends StatefulWidget {
  const PhoneAndCountry({super.key});

  @override
  State<PhoneAndCountry> createState() => _PhoneAndCountryState();
}

class _PhoneAndCountryState extends State<PhoneAndCountry> {
  final formController = FormController(debug: true);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            DynamicForm(fields: [
              GoPhoneAndCountryInput(name: 'phoneAndCountrty'),
            ], controller: formController),
            const SizedBox(height: 10),
            ElevatedButton(
              onPressed: () {
                debugPrint(formController.getFieldValue<String>('phone'));
              },
              child: const Text('Значение поля'),
            ),
          ],
        ),
      ),
    );
  }
}

class PhoneState {
  final String? value;
  final MaskTextInputFormatter maskFormatter;
  final CountryPhoneDto country;

  const PhoneState({
    this.value,
    required this.maskFormatter,
    required this.country,
  });

  PhoneState copyWith({
    String? value,
    MaskTextInputFormatter? maskFormatter,
    CountryPhoneDto? country,
    bool? clearPhone,
  }) =>
      PhoneState(
        value: (clearPhone == true) ? null : value ?? this.value,
        maskFormatter: maskFormatter ?? this.maskFormatter,
        country: country ?? this.country,
      );

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is PhoneState &&
          runtimeType == other.runtimeType &&
          value == other.value &&
          maskFormatter == other.maskFormatter &&
          country == other.country;

  @override
  int get hashCode => Object.hash(value, maskFormatter, country);
}

List<CountryPhoneDto> countries = [
  CountryPhoneDto(
    name: 'Россия',
    nativeName: 'Россия',
    countryCode: 'RU',
    dialCode: '+7',
    flagEmoji: '??',
    phoneMask: '(###) ###-##-##',
    priority: 1,
  ),
  CountryPhoneDto(
    name: 'США',
    nativeName: 'United States',
    countryCode: 'US',
    dialCode: '+1',
    flagEmoji: '??',
    phoneMask: '(###) ###-####',
    priority: 2,
  ),
  CountryPhoneDto(
    name: 'Германия',
    nativeName: 'Deutschland',
    countryCode: 'DE',
    dialCode: '+49',
    flagEmoji: '??',
    phoneMask: '#### ########',
    priority: 4,
  ),
  CountryPhoneDto(
    name: 'Франция',
    nativeName: 'France',
    countryCode: 'FR',
    dialCode: '+33',
    flagEmoji: '??',
    phoneMask: '# ## ## ## ##',
    priority: 5,
  ),
  CountryPhoneDto(
    name: 'Бразилия',
    nativeName: 'Brasil',
    countryCode: 'BR',
    dialCode: '+55',
    flagEmoji: '??',
    phoneMask: '(##) #####-####',
    priority: 6,
  ),
];

class GoPhoneAndCountryInput extends FormFieldModelBase<PhoneState> {
  final String? label;

  const GoPhoneAndCountryInput({
    required super.name,
    this.label,
  });

  @override
  void onInit(FieldController<PhoneState> controller) {
    final country = countries.firstWhere(
      (c) => c.countryCode == 'RU',
      orElse: () => countries.first,
    );
    final maskFormatter = MaskTextInputFormatter(
      mask: country.phoneMask,
      filter: {"#": RegExp(r'[0-9]')},
    );
    controller.setValue(
      PhoneState(value: '', maskFormatter: maskFormatter, country: country),
    );
    
    super.onInit(controller);
  }

  @override
  Widget build(BuildContext context, FieldController<PhoneState> controller) {
    final value = controller.value;
    if (value == null) {
      return Container();
    }

    return RootInput(
      initialValue: controller.value?.value,
      key: ValueKey(controller.value!.maskFormatter.getMask()),
      onChanged: (newValue) =>
          controller.onChange(controller.value?.copyWith(value: newValue)),
      errorText: controller.error,
      labelText: label,
      prefix: InkWell(
        onTap: () => _showCountrySelectionSheet(context, controller),
        child: Text(
          '${controller.value?.country.flagEmoji} ${controller.value?.country.dialCode} ',
        ),
      ),
      inputFormatters: [controller.value!.maskFormatter],
      focusNode: controller.focusNode,
    );
  }

  void _showCountrySelectionSheet(
      BuildContext context, FieldController<PhoneState> controller) {
    showModalBottomSheet(
      context: context,
      shape: const RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
      ),
      builder: (context) {
        return Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text(
                'Выберите страну',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 16),
              Expanded(
                child: ListView.builder(
                  itemCount: countries.length,
                  itemBuilder: (context, index) {
                    final country = countries[index];
                    return Padding(
                      padding: const EdgeInsets.symmetric(vertical: 4),
                      child: InkWell(
                        onTap: () {
                          controller.setValue(
                            controller.value?.copyWith(
                              country: country,
                              maskFormatter: MaskTextInputFormatter(
                                mask: country.phoneMask,
                                filter: {"#": RegExp(r'[0-9]')},
                              ),
                              clearPhone: true,
                            ),
                          );
                          controller.value?.maskFormatter.clear();
                          Navigator.of(context).pop();
                        },
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
                          children: [
                            Text('${country.flagEmoji} ${country.name}'),
                            Text(country.dialCode),
                          ],
                        ),
                      ),
                    );
                  },
                ),
              ),
            ],
          ),
        );
      },
      isScrollControlled: true,
    );
  }
}

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

import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:go_form/go_form.dart';
import 'package:go_form_example/pages/test_form_page.dart';

void main() {
  group('TestFormPage Tests', () {
    testWidgets('TestFormPage renders correctly', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(home: TestFormPage()));

      expect(find.text('Test form'), findsOneWidget);
      expect(find.byType(DynamicForm), findsOneWidget);
      expect(find.byType(TextFormField), findsOneWidget);
    });

    testWidgets('Entering text updates the field value', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(home: TestFormPage()));
      final textField = find.byType(TextFormField);
      await tester.enterText(textField, 'new@example.com');
      await tester.pump();
      expect(find.text('new@example.com'), findsOneWidget);
    });

    testWidgets('Validation error appears and disappears correctly', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(home: TestFormPage()));
      final textField = find.byType(TextFormField);
      await tester.enterText(textField, '');
      final validateButton = find.byKey(const Key('validate_button'));
      await tester.tap(validateButton);
      await tester.pump();
      expect(find.text('Согласись'), findsOneWidget);

      await tester.enterText(textField, 'valid@example.com');
      await tester.pump();
      expect(find.text('Согласись'), findsNothing);
    });

    testWidgets('Reset button clears validation errors', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(home: TestFormPage()));
      final textField = find.byType(TextFormField);
      await tester.enterText(textField, '');
      await tester.pump();
      final validateButton = find.byKey(Key('validate_button'));
      await tester.tap(validateButton);
      await tester.pump();
      expect(find.text('Согласись'), findsOneWidget);

      final resetButton = find.byKey(Key('reset_button'));
      await tester.tap(resetButton);
      await tester.pump();
      expect(find.text('Согласись'), findsNothing);
    });

    testWidgets('Manual error set using setError() is displayed', (WidgetTester tester) async {
      await tester.pumpWidget(MaterialApp(home: TestFormPage()));
      final formWidget = tester.widget<DynamicForm>(find.byType(DynamicForm));
      final formController = formWidget.controller;
      formController.setError('text', 'Ошибка сервера');
      await tester.pump();
      expect(find.text('Ошибка сервера'), findsOneWidget);
    });

    testWidgets('Form does not rebuild unnecessarily', (WidgetTester tester) async {
      int buildCount = 0;
      await tester.pumpWidget(
        StatefulBuilder(
          builder: (context, setState) {
            buildCount++;
            return MaterialApp(home: TestFormPage());
          },
        ),
      );
      final textField = find.byType(TextFormField);
      await tester.enterText(textField, 'test@example.com');
      await tester.pump();
      expect(buildCount, lessThan(3));
    });
  });
}

Этот пример показывает, как использовать flutter_test для проверки логики форм, таких как валидация, обновление значений, сброс и ручная установка ошибок. Используйте ключи (Key) для кнопок, чтобы проще взаимодействовать с ними в тестах.

Что мы узнали о GoForm:

  • Забили гвоздь в крышку гроба Form

  • Научились создавать кастомные поля без головной боли

  • Познакомились с асинхронной валидацией, которая больше не вызывает кошмаров

  • Узнали, как подружить формы с любым state management

Главные плюсы GoForm, которые вы уже оценили:

  • Единый контроллер для всей формы — больше никаких конфликтов

  • Автоматическая валидация — забудьте про рутину

  • Интеграция с популярными решениями — всё работает как часы

  • Минималистичный синтаксис — код читается на одном дыхании

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

Что дальше? В следующих статьях разберём что за reactive_forms и зачем он нужен.

P.S. Если вы дочитали до конца — вы настоящий герой!

Нашли баг, есть идеи или хочется просто сказать «спасибо» — пишите, не стесняйтесь.

А ещё больше контента по Flutter и разработке — в моём Telegram-канале — подписывайтесь, будет интересно!

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