Рад представить вам свою библиотеку 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-канале — подписывайтесь, будет интересно!