Мы уже познакомились с основными виджетами и знаем, как их позиционировать. Поэтому самое время разобраться, как устроены формы и кнопки — основные элементы интерфейса, с помощью которых пользователь может взаимодействовать с приложением: вводить данные или совершать действия. Поэтому важно создавать эти компоненты удобными и красивыми.
В этом параграфе мы рассмотрим:
- Текстовые поля и форматтеры.
- Формы.
- Кнопки — в том числе радио и чекбоксы
- А также обработку жестов и другие полезные механизмы.
И разумеется, в каждой из секций будет много нюансов и деталей. Давайте приступать!
Текстовые поля
У Flutter есть два виджета для текстовых полей: TextField и TextFormField. Чаще всего используют TextField.
У виджета TextField широкое API для кастомизации. Вы можете добавить label, icon, hint и текст при ошибке, использовав класс InputDecoration для свойства decoration у TextField.
Пример (здесь и далее мы скрыли под катом часть кода для удобства чтения)
1TextField(
2 decoration: InputDecoration(
3 border: OutlineInputBorder(),
4 hintText: 'Enter a search term',
5 ),
6),
Второй виджет для работы с текстом — TextFormField. Он отличается тем, что его нужно использовать вместе с виджетом Form. Это даёт возможность валидировать текстовые поля: проверять длину текста, вводимое содержимое и так далее.
Пример
1TextFormField(
2 decoration: const InputDecoration(
3 border: UnderlineInputBorder(),
4 labelText: 'Enter your username',
5 ),
6),
Настраиваем отслеживание введённого текста
Как только вы создали свой TextField, вам может пригодиться отслеживание введенного текста. Это нужно, к примеру, для валидации. Для таких целей нужно сделать три шага:
- Создать
TextEditingController. - Передать контроллер внутрь
TextField. - Достать данные из
TextEditingController.
Разберём каждый пункт подробнее.
Шаг 1: создаём контроллер
Первым делом давайте создадим TextEditingController. Это класс, который позволяет взаимодействовать с виджетом TextField. С его помощью можно:
- Установить первоначальный текст для
TextField. - Получить текущее значения текстового поля.
- И много чего ещё.
Давайте создадим контроллер внутри StatefullWidget. Это позволит при вызове dispose() метода у State очистить выделенные ресурсы. Это важный момент, о котором стоит всегда помнить.
Пример
1class _MyCustomFormState extends State<MyCustomForm> {
2 final myController = TextEditingController();
3
4 @override
5 void dispose() {
6 myController.dispose();
7 super.dispose();
8 }
9
10 @override
11 Widget build(BuildContext context) {
12 /// создание TextField
13 }
14}
Шаг 2: передаём контроллер
Вторым действием мы должны передать наш TextEditingController внутрь TextField.
Пример
1return TextField(
2 controller: myController,
3);
Как только мы передали myController, у нас появилась возможность для отслеживания актуального состояния ввода TextField.
Шаг 3: достаём введённый текст
Для того чтобы получить текущее введённое значение в текстовом поле, можно использовать геттер text.
Полный пример использования TextField может быть следующим:
В некоторых случаях бывает полезно не просто получать актуальное состояние в момент, когда это потребуется, но и отслеживать в режиме реального времени всё, что происходит с TextField.
Например, вы создаёте приложение для поиска товаров и хотите при вводе очередного символа показывать пользователю подсказки для ввода.
У нас есть два способа добиться этого:
- Использовать параметр
onChanged()уTextFieldилиTextFormField. - Использовать уже знакомый нам
TextEditingController.
Давайте начнём разбор с первого пункта — это самый простой вариант.
У TextField или TextFormField есть параметр onChanged, который принимает анонимную функцию. Она вызывается каждый раз после изменения текста.
Пример
1TextField(
2 onChanged: (text) {
3 print('Введенный текст: $text');
4 },
5),
Более продвинутый, но более сложный подход — использовать TextEditingController. Для этого мы должны сделать следующее:
- Создать
TextEditingController. - Передать его в
TextField. - Создать функцию, которая будет вызываться при каждом изменении введённых данных.
- Передать её в метод
addListener().
С шагами 1 и 2 вы познакомились ранее. Разберём остальные.
Метод addListener() принимает функцию типа VoidCallback, поэтому для отслеживания ввода можно использовать такой вариант:
1void _printLatestValue() {
2 print('Последний введенный текст: ${myController.text}');
3}
Эта функция не имеет параметров с актуальным значением текстового поля. Получить текст возможно, используя свойство text.
Чтобы связать нашу функцию и TextEditingController, нужно передать её в метод addListener().
Сделать это можно, например, в initState(). Важно: необходимо иметь ссылку на созданные TextEditingController для того чтобы в будущем вызвать метод dispose() и высвободить неиспользуемые ресурсы.
Итого, полный пример:
Форматтеры
Введённый текст можно форматировать. Представим, что у нас есть поле для CVV-кода банковской карты. Мы хотим, чтобы пользователь мог ввести только три символа и только цифры: буквы и знаки препинания нам не подходят.
Для таких целей можно использовать параметр inputFormatters у виджета TextField. Мы передаём в него список из TextInputFormatter, которые запускаются по порядку и форматируют текст. TextInputFormatter — класс, который позволяет провалидировать и отформатировать введенный текст.
Вот что нужно сделать, чтобы добиться желаемого:
- Создать форматтер
FilteringTextInputFormatter, который бы валидировал, что на ввод подаются только цифры. - Создать форметтер
LengthLimitingTextInputFormatter, который бы валидировал длину введенной строки. - Использовать подходящий тип клавиатуры TextInputType чтобы пользователю комфортнее вводить CVV-код.
Рассмотрим эти шаги подробнее.
Шаг 1: создаём форматтер FilteringTextInputFormatter
FilteringTextInputFormatter — это подкласс TextInputFormatter, он обладает удобным интерфейсом для наших целей. Используем именованный конструктор .allow(), в него мы передаём паттерн, который будет валидировать строку.
Пример
1FilteringTextInputFormatter.allow(
2 RegExp(r'[0-9]'),
3);
В качестве паттерна мы будем использовать класс RegExp для работы с регулярными выражениями. В итоге мы получим форматер, который позволяет вводить только цифры.
LengthLimitingTextInputFormatter — это подкласс TextInputFormatter. Он принимает параметр maxLength, который ограничивает введённый текст:
1LengthLimitingTextInputFormatter(3)
В качестве паттерна мы будем использовать класс RegExp для работы с регулярными выражениями. В итоге мы получим форматер, который позволяет вводить только цифры.
Шаг 2: создаём форматтер LengthLimitingTextInputFormatter
LengthLimitingTextInputFormatter — это подкласс TextInputFormatter. Он принимает параметр maxLength, который ограничивает введённый текст.
Пример
1class _MyCustomFormState extends State<MyCustomForm> {
2 @override
3 Widget build(BuildContext context) {
4 final numberFormatter = FilteringTextInputFormatter.allow(
5 RegExp(r'[0-9]'),
6 );
7 final lengthFormatter = LengthLimitingTextInputFormatter(3);
8
9 return Scaffold(
10 body: Center(
11 child: Padding(
12 padding: const EdgeInsets.all(8.0),
13 child: TextField(
14 keyboardType: TextInputType.number,
15 inputFormatters: [numberFormatter, lengthFormatter],
16 decoration: const InputDecoration(
17 border: OutlineInputBorder(),
18 hintText: 'cvv',
19 ),
20 ),
21 ),
22 ),
23 );
24 }
25}
Фокус
Важная часть при работе с текстовыми полями — фокус.
Будем считать, что, когда текстовое поле выбрано и пользователь что-то набирает в нём, данное поле находится «в фокусе». Мы можем управлять тем, какое поле сейчас находится в фокусе и по каким правилам фокус переходит на следующее поле.
Это важный момент при создании интуитивно понятных форм. Например, у вас есть экран с поисковой строкой. Было бы здорово, чтобы при открытии фокус уже был на текстовом поле — и пользователь сразу начал бы набирать свой запрос.
Это не только может повысить метрики для вашего приложения по количеству поисковых запросов, но и сделает опыт пользования приложением (UX) более приятным.
Чтобы добиться такого поведения, необходимо использовать свойство autofocus.
1TextField(
2 autofocus: true,
3);
Но бывают ситуации, когда нам нужно управлять фокусом и назначать его на TextField при определённых условиях или перемещать его от одного TextField к другому. В этом случае нам поможет FocusNode. FocusNode — класс, который позволяет управлять состоянием фокуса текстовых полей.
Нужно сделать три шага:
- Создать класс
FocusNode. - Передать класс
FocusNodeв виджетTextField. - Использовать класс
FocusNodeдля назначения фокусаTextField.
Создадим класс FocusNode. Подробнее про механизм работы Flutter с фокусами можно почитать в официальной документации.
Также как и TextEditingController, при работе с FocusNode мы должны не забывать, что это долгоживущая сущность и необходимо очищать связанные с ней данные — например, в методе dispose(), как мы делали это для контроллера TextEditingController.
Пример
1class _MyCustomFormState extends State<MyCustomForm> {
2 late FocusNode myFocusNode;
3
4 @override
5 void initState() {
6 super.initState();
7
8 myFocusNode = FocusNode();
9 }
10
11 @override
12 void dispose() {
13 myFocusNode.dispose();
14 super.dispose();
15 }
16
17 @override
18 Widget build(BuildContext context) {
19 }
20}
После этого нам необходимо передать его в наш TextField:
1@override
2Widget build(BuildContext context) {
3 return TextField(
4 focusNode: myFocusNode,
5 );
6}
Ну и в самом конце мы можем вызвать фокус для данного TextField, используя метод requestFocus() у нашей myFocusNode:
1FloatingActionButton(
2 onPressed: () => myFocusNode.requestFocus(),
3),
Полный пример использования:
Формы
Часто необходимо провалидировать корректность введённых данных: чтобы в пароле были и цифры, в номере телефона не было букв и т. д. Для это нужно сделать три простых шага:
- Создать виджет
Formc ключомGlobalKey. - Добавить
TextFormFieldc логикой для валидации. - Связать всё вместе.
Form — это контейнер, в котором будут храниться наши текстовые поля. Мы должны передать GlobalKey, чтобы к нему было удобно обращаться.
Пример
1class CustomForm extends StatefulWidget {
2 const CustomForm({super.key});
3
4 @override
5 CustomFormState createState() {
6 return CustomFormState();
7 }
8}
9
10class CustomFormState extends State<CustomForm> {
11 final _formKey = GlobalKey<FormState>();
12
13 @override
14 Widget build(BuildContext context) {
15 return Form(
16 key: _formKey,
17 child: Container(),
18 );
19 }
20}
Затем следует создать наше текстовое поле при помощи виджета TextFormField. Данный виджет внутри использует TextField, но имеет дополнительные возможности. В отличие от TextField, у него есть возможность валидировать данные — благодаря параметру validator. Данный параметр принимает на вход метод с одним параметром, который обозначает текущий введённый текст.
1TextFormField(
2 validator: (value) {
3 if (value == null || value.length < 10) {
4 return 'Ненадежный пароль';
5 }
6 return null;
7 },
8);
В данном примере мы проверяем пароль на надёжность. Если validator вернёт что угодно, кроме null, то поле не прошло валидацию. Если возвращается строка, то TextFormField сам сообщит пользователю о проблеме.

Чтобы провалидировать данные, мы должны получить состояние виджета Form и вызвать у него метод validate().
1TextButton(
2 onPressed: () {
3 if (_formKey.currentState?.validate() ?? false) {
4 // прошли валидацию
5 }
6 // не прошли валидацию
7 },
8 child: Text('Валидировать'),
9);
Если теперь соединить всё вместе, то получим форму, в которую можно ввести данные и провалидировать их. А в зависимости от результата валидации либо сообщить пользователю, где он ошибся, либо отправить данные на сервер.
Пример
1class CustomForm extends StatefulWidget {
2 const CustomForm({super.key});
3
4 @override
5 CustomFormState createState() {
6 return CustomFormState();
7 }
8}
9
10class CustomFormState extends State<CustomForm> {
11 final _formKey = GlobalKey<FormState>();
12
13 @override
14 Widget build(BuildContext context) {
15 return Scaffold(
16 body: Form(
17 key: _formKey,
18 child: Column(
19 mainAxisAlignment: MainAxisAlignment.center,
20 children: [
21 TextFormField(
22 validator: (value) {
23 if (value == null || value.length < 10) {
24 return 'Ненадежный пароль';
25 }
26 return null;
27 },
28 ),
29 TextButton(
30 onPressed: () {
31 if (_formKey.currentState?.validate() ?? false) {
32 // Прошли валидацию
33 }
34 // Не прошли валидацию
35 },
36 child: Text('Валидировать'),
37 ),
38 ],
39 ),
40 ),
41 );
42 }
43}
Кнопки
Во Flutter уже есть много готовых кнопок, вот некоторые из них:
ElevatedButton— стилизована так, будто бы парит в воздухе.IconButton— отлично подходит, когда нужно, чтобы по нажатию на иконку происходило какое-то действие.OutlineButton— кнопка с дополнительной обводкой вокруг.TextButton— привычная нам кнопка-текст.
Многие из них используют под капотом виджет RawMaterialButton как основу и дополняют его своими свойствами. RawMaterialButton представляет собой основу для кнопок в стиле Material. Подробнее про стиль можно почитать на официальном сайте.
Кроме обычных кнопок в интерфейсе стоит отметить ещё FloatingActionButton. Она обычно используется как главное действие, которое пользователь должен совершить на экране. Зачастую её кладут в Scaffold.
Пример
1Scaffold(
2 floatingActionButton: FloatingActionButton(
3 onPressed: () {},
4 child: Icon(Icons.send),
5 ),
6)
Также существует несколько разновидностей кнопок:
- радио (Radio button);
- чекбоксы.
Рассмотрим их подробнее.
Радио
Этот тип кнопок отлично подходит, когда нужно предоставить пользователю выбор между взаимоисключающими вариантами. Во Flutter для этого используется виджет Radio.
Чтобы создать его, необходимо передать следующие параметры:
value— значение, с которым будет сравниватьсяgroupValue, и если сравнение пройдёт успешно, то данная кнопка будет выбрана.groupValue— общее значение для несколькихRadio Button. Мы как раз управляем состоянием нескольких кнопок, меняя его.onChanged— в этом колбэке мы получаем актуальноеvalueдля кнопки. Его стоит использовать для обновленияgroupValueи перерисовки компонента.
Пример с одной кнопкой
1Radio<String>(
2 value: 'Каша',
3 groupValue: breakfast,
4 onChanged: (String? value) {
5 setState(() {
6 breakfast = value;
7 });
8 },
9),
Пример с двумя кнопками
1class _RadioButtonExampleState extends State<RadioButtonExample> {
2 String? breakfast = 'Каша';
3
4 @override
5 Widget build(BuildContext context) {
6 return Column(
7 children: <Widget>[
8 ListTile(
9 title: const Text('Каша'),
10 leading: Radio<String>(
11 value: 'Каша',
12 groupValue: breakfast,
13 onChanged: (String? value) {
14 setState(() {
15 breakfast = value;
16 });
17 },
18 ),
19 ),
20 ListTile(
21 title: const Text('Творог'),
22 leading: Radio<String>(
23 value: 'Творог',
24 groupValue: breakfast,
25 onChanged: (String? value) {
26 setState(() {
27 breakfast = value;
28 });
29 },
30 ),
31 ),
32 ],
33 );
34 }
35}
Чекбокс
Этот элемент бывает очень полезным, когда мы хотим дать пользователю выбрать несколько вещей из какого-то списка или когда нам нужен выключатель для управления какой-то настройкой.
Пример
1Checkbox(
2 value: isChecked,
3 onChanged: (bool? value) {
4 setState(() {
5 isChecked = value;
6 });
7 },
8);
По дефолту данный переключатель может находиться в двух состояниях: включен или выключен. Но можно задать свойство tristate: true, и тогда мы получим промежуточное состояние.

Жесты
Иногда бывают случаи, когда мы хотим, чтобы действие происходило не по нажатии на кнопку, а на какой-то иной объект или произвольную область на экране. Для этих целей обычно используют виджеты GestureDetector или InkWell.
Сначала рассмотрим InkWell. Он служит для обработки «простых» жестов, таких как тапы, двойные тапы и некоторые другие.
Пример
1InkWell(
2 onTap: () {
3 print("onTap");
4 },
5 child: FlutterLogo(
6 size: 200,
7 ),
8);
При нажатии он добавляет поверхности эффект, будто бы это кнопка. Если такой визуальный эффект не нужен или нужно обрабатывать более сложные жесты: слайды в разные стороны, сильные/слабые нажатия и другое, то стоит взглянуть в сторону виджета GestureDetector.
Чтобы им воспользоваться, необходимо обернуть им искомый виджет.
Пример
1GestureDetector(
2 onTap: () {
3 print("onTap");
4 },
5 onHorizontalDragStart: ((details) {
6 print("onHorizontalDragStart");
7 }),
8 child: FlutterLogo(
9 size: 200,
10 ),
11);
В этом примере кроме обычных нажатий мы можем отслеживать, в какой момент пользователь начал использовать горизонтальный слайд на нашем виджете.
В этом примере кроме обычных нажатий мы можем отслеживать, в какой момент пользователь начал использовать горизонтальный слайд на нашем виджете.
Как избежать перекрытия контента клавиатурой
Страницы с текстовыми полями зачастую устроены стандартным образом: какой-то заголовок, по центру текстовые поля, а в самом низу располагается кнопка подтверждения ввода.
При такой конфигурации элементов часто бывают проблемы с тем, что клавиатура перекрывает кнопку подтверждения, и пользователю приходится её принудительно закрывать, чтобы подтвердить заполнение формы.
Чтобы решить эту проблему, можно поместить контент в какую-то скроллющуюся обёртку. Это могут быть виджеты ListView или SingleChildScrollView.
Ниже — результат применения виджета ListView. Теперь у пользователя есть возможность подскролливать контент на экране.
Пример
1ListView(
2 children: [
3 Placeholder(fallbackHeight: 400),
4 TextField(),
5 Placeholder(fallbackHeight: 400),
6 ],
7),
Теперь вы знаете, как создавать формы, состоящие из текстовых инпутов и кнопок. А также умеете форматировать и валидировать переданные данные.
Это мощный шаг вперёд, который позволит вам не просто показывать пользователям интерфейс, но и получать от него обратную связь.
На этом мы заканчиваем наше погружение в мир виджетов Flutter. Напоследок — небольшой совет. Каждый раз, когда сталкиваетесь с новым виджетом, рекомендуем начинать с документации — команда Flutter проводит отличную работу по документированию, а также снимает короткие видеоролики в которых наглядно демонстрируется поведение и примеры использования различных компонентов Flutter.
А в следующем параграфе как раз поговорим о том, как отправить данные с форм на сервер.
