Вступление
Вы уже познакомились с некоторыми основными виджетами и способами их позиционировать. Сегодня вы познакомитесь с формами и кнопками.Это основные элементы интерфейса, с помощью которых пользователь может взаимодействовать с приложением: вводить данные или совершать действия. Поэтому важно создавать эти компоненты удобными и красивыми.
Текстовые поля
У 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-код.
Для начала создадим FilteringTextInputFormatter
. FilteringTextInputFormatter
— это подкласс TextInputFormatter
, он обладает удобным интерфейсом для наших целей. Используем именованный конструктор .allow()
, в него мы передаём паттерн, который будет валидировать строку.
1FilteringTextInputFormatter.allow(
2 RegExp(r'[0-9]'),
3);
В качестве паттерна мы будем использовать класс RegExp
для работы с регулярными выражениями. В итоге мы получим форматер, который позволяет вводить только цифры.
LengthLimitingTextInputFormatter
— это подкласс TextInputFormatter
. Он принимает параметр maxLength
, который ограничивает введённый текст:
1LengthLimitingTextInputFormatter(3)
Также для удобства пользователя давайте используем параметр keyboardType
у TextField
. В него мы передаём желаемый вид клавиатуры, который будет открываться при тапе на наше поле. В данном случае нам подойдёт TextInputType.number
.
Теперь давайте объединим форматеры и keyboardType
и посмотрим на итоговый результат:
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
.
Создать FocusNode
можно так:
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),
Полный пример использования:
Формы
Часто необходимо провалидировать корректность введённых данных: чтобы в пароле были и цифры, в номере телефона не было букв и т. д. Для это нужно сделать три простых шага:
- Создать виджет
Form
c ключомGlobalKey
. - Добавить
TextFormField
c логикой для валидации. - Связать всё вместе.
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}
Как избежать перекрытия контента клавиатурой
Страницы с текстовыми полями зачастую устроены стандартным образом: какой-то заголовок, по центру текстовые поля, а в самом низу располагается кнопка подтверждения ввода.
При такой конфигурации элементов часто бывают проблемы с тем, что клавиатура перекрывает кнопку подтверждения, и пользователю приходится её принудительно закрывать, чтобы подтвердить заполнение формы.
Чтобы решить эту проблему, можно поместить контент в какую-то скроллющуюся обёртку. Это могут быть виджеты ListView
или SingleChildScrollView
.
Ниже — результат применения ListView. Теперь у пользователя есть возможность подскролливать контент на экране.
1ListView(
2 children: [
3 Placeholder(fallbackHeight: 400),
4 TextField(),
5 Placeholder(fallbackHeight: 400),
6 ],
7),
Кнопки
Во Flutter уже есть много готовых кнопок, вот некоторые из них:
ElevatedButton
— стилизована так, будто бы парит в воздухе.IconButton
— отлично подходит, когда нужно, чтобы по нажатию на иконку происходило какое-то действие.OutlineButton
— кнопка с дополнительной обводкой вокруг.TextButton
— привычная нам кнопка-текст.
Многие из них используют под капотом виджет RawMaterialButton
как основу и дополняют его своими свойствами. RawMaterialButton
представляет собой основу для кнопок в стиле Material. Подробнее про стиль можно почитать на официальном сайте — https://m3.material.io/components/all-buttons.
Кроме обычных кнопок в интерфейсе стоит отметить ещё FloatingActionButton
. Она обычно используется как главное действие, которое пользователь должен совершить на экране. Зачастую её кладут в Scaffold
:
1Scaffold(
2 floatingActionButton: FloatingActionButton(
3 onPressed: () {},
4 child: Icon(Icons.send),
5 ),
6)
Radio
Этот тип кнопок отлично подходит, когда нужно предоставить пользователю выбор между взаимоисключающими вариантами. Во 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}
Checkbox
Этот элемент бывает очень полезным, когда мы хотим дать пользователю выбрать несколько вещей из какого-то списка или когда нам нужен выключатель для управления какой-то настройкой.
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);
В этом примере кроме обычных нажатий мы можем отслеживать, в какой момент пользователь начал использовать горизонтальный слайд на нашем виджете.
Вывод
Flutter имеет богатую библиотеку уже готовых компонентов и классов. Это позволяет создавать красивые и удобные приложения. Кроме того, вы можете создать свои виджеты, которые обрабатывают ввод, жесты и много чего ещё. Важно помнить о конечных пользователях вашего приложения, об их удобстве и удовольствии.