2.11. Widgets: формы и кнопки

Вступление

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

Текстовые поля

У Flutter есть два виджета для текстовых полей: TextField и TextFormField. Чаще всего используютTextField.

Виджет TextField имеет широкое API для кастомизации. Вы можете добавить label, icon, hint и текст при ошибке, использовав класс InputDecoration для свойства decoration у TextField.

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

TextField(
  decoration: InputDecoration(
    border: OutlineInputBorder(),
    hintText: 'Enter a search term',
  ),
),

Второй виджет для работы с текстом — TextFormField. Он отличается тем, что его нужно использовать вместе с виджетом Form. Это даёт возможность валидировать текстовые поля: проверять длину текста, вводимое содержимое и т. д.

TextFormField(
  decoration: const InputDecoration(
    border: UnderlineInputBorder(),
    labelText: 'Enter your username',
  ),
),

Как только вы создали свой TextField, вам может пригодиться отслеживание введенного текста. Это нужно, к примеру, для валидации. Для таких целей нужно сделать три шага:

  1. Создать TextEditingController.
  2. Передать контроллер внутрь TextField.
  3. Достать данные из TextEditingController.

Шаг 1: создаём контроллер

Первым делом давайте создадим TextEditingController. Это класс, который позволяет взаимодействовать с виджетом TextField. С его помощью можно:

  1. Установить первоначальный текст для TextField.
  2. Получить текущее значения текстового поля.
  3. И много чего ещё.

Давайте создадим контроллер внутри StatefullWidget. Это позволит при вызове dispose() метода у State очистить выделенные ресурсы. Это важный момент, о котором стоит всегда помнить.

class _MyCustomFormState extends State<MyCustomForm> {
  final myController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
   /// создание TextField
  }
}

Шаг 2: передать контроллер

Вторым действием мы должны передать наш TextEditingController внутрь TextField

return TextField(
  controller: myController,
);

Как только мы передали myController, у нас появилась возможность для отслеживания актуального состояния ввода TextField.

Шаг 3: достать введённый текст

Для того чтобы получить текущее введённое значение в текстовом поле, можно использовать геттер text.

Полный пример использования TextField может быть следующим:

В некоторых случаях бывает полезно не просто получать актуальное состояние в момент, когда это потребуется, но и отслеживать в режиме реального времени всё, что происходит с TextField.

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

У нас есть два способа добиться этого:

  1. Использовать параметр onChanged() у TextField или TextFormField.
  2. Использовать уже знакомый нам TextEditingController.

Давайте начнём разбор с первого пункта — это самый простой вариант.

У TextField или TextFormField есть параметр onChanged, который принимает анонимную функцию. Она вызывается каждый раз после изменения текста. Пример:

TextField(
  onChanged: (text) {
    print('Введенный текст: $text');
  },
),

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

  1. Создать TextEditingController.
  2. Передать его в TextField.
  3. Создать функцию, которая будет вызываться при каждом изменении введённых данных.
  4. Передать её в метод addListener().

С шагами 1 и 2 вы познакомились ранее. Разберём остальные.

Метод addListener() принимает функцию типа VoidCallback, поэтому для отслеживания ввода можно использовать такой вариант:

void _printLatestValue() {
  print('Последний введенный текст: ${myController.text}');
}

Эта функция не имеет параметров с актуальным значением текстового поля. Получить текст возможно, используя свойство text.

Чтобы связать нашу функцию и TextEditingController, нужно передать её в метод addListener().

Сделать это можно, например, в initState(). Важно: необходимо иметь ссылку на созданные TextEditingController для того чтобы в будущем вызвать метод dispose() и высвободить неиспользуемые ресурсы.

Итого, полный пример:

Форматтеры

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

Для таких целей можно использовать параметр inputFormatters у виджета TextField. Мы передаём в него список из TextInputFormatter, которые запускаются по порядку и форматируют текст. TextInputFormatter — класс, который позволяет провалидировать и отформатировать введенный текст.

Вот что нужно сделать, чтобы добиться желаемого:

  1. Создать FilteringTextInputFormatter, который бы валидировал, что на ввод подаются только цифры.
  2. Создать LengthLimitingTextInputFormatter, который бы валидировал длину введенной строки.
  3. Использовать подходящий тип клавиатуры TextInputType чтобы пользователю комфортнее вводить CVV-код.

Для начала создадим FilteringTextInputFormatter. FilteringTextInputFormatter — это подкласс TextInputFormatter, он обладает удобным интерфейсом для наших целей. Используем именованный конструктор .allow(), в него мы передаём паттерн, который будет валидировать строку.

FilteringTextInputFormatter.allow(
  RegExp(r'[0-9]'),
);

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

LengthLimitingTextInputFormatter — это подкласс TextInputFormatter. Он принимает параметр maxLength, который ограничивает введённый текст:

LengthLimitingTextInputFormatter(3)

Также для удобства пользователя давайте используем параметр keyboardType у TextField. В него мы передаём желаемый вид клавиатуры, который будет открываться при тапе на наше поле. В данном случае нам подойдёт TextInputType.number.

Теперь давайте объединим форматеры и keyboardType и посмотрим на итоговый результат:

class _MyCustomFormState extends State<MyCustomForm> {
  @override
  Widget build(BuildContext context) {
    final numberFormatter = FilteringTextInputFormatter.allow(
      RegExp(r'[0-9]'),
    );
    final lengthFormatter = LengthLimitingTextInputFormatter(3);

    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: TextField(
            keyboardType: TextInputType.number,
            inputFormatters: [numberFormatter, lengthFormatter],
            decoration: const InputDecoration(
              border: OutlineInputBorder(),
              hintText: 'cvv',
            ),
          ),
        ),
      ),
    );
  }
}

Фокус

Важная часть при работе с текстовыми полями — фокус.

Будем считать, что, когда текстовое поле выбрано и пользователь что-то набирает в нём, данное поле находится «в фокусе». Мы можем управлять тем, какое поле сейчас находится в фокусе и по каким правилам фокус переходит на следующее поле.

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

Это не только может повысить метрики для вашего приложения по количеству поисковых запросов, но и сделает опыт пользования приложением (UX) более приятным.

Чтобы добиться такого поведения, необходимо использовать свойство autofocus.

TextField(
  autofocus: true,
);

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

  1. Создать FocusNode.
  2. Передать FocusNode в TextField.
  3. Использовать FocusNode для назначения фокуса TextField.

Прежде всего давайте создадим FocusNode. Подробнее про механизм работы Flutter с фокусами можно почитать в официальной документации.

Также как и TextEditingController, при работе с FocusNode мы должны не забывать, что это долгоживущая сущность и необходимо очищать связанные с ней данные — например, в методе dispose(), как мы делали это для контроллера TextEditingController.

Создать FocusNode можно так:

class _MyCustomFormState extends State<MyCustomForm> {
  late FocusNode myFocusNode;

  @override
  void initState() {
    super.initState();

    myFocusNode = FocusNode();
  }

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

  @override
  Widget build(BuildContext context) {
  }
}

После этого нам необходимо передать его в наш TextField:

@override
Widget build(BuildContext context) {
  return TextField(
    focusNode: myFocusNode,
  );
}

Ну и в самом конце мы можем вызвать фокус для данного TextField, используя метод requestFocus() у нашей myFocusNode:

FloatingActionButton(
  onPressed: () => myFocusNode.requestFocus(),
),

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

Формы

Часто необходимо провалидировать корректность введённых данных: чтобы в пароле были и цифры, в номере телефона не было букв и т. д. Для это нужно сделать три простых шага:

  1. Создать виджет Form c ключом GlobalKey.
  2. Добавить TextFormField c логикой для валидации.
  3. Связать всё вместе.

Form — это контейнер, в котором будут храниться наши текстовые поля. Мы должны передать GlobalKey, чтобы к нему было удобно обращаться.

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

  @override
  CustomFormState createState() {
    return CustomFormState();
  }
}

class CustomFormState extends State<CustomForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Container(),
    );
  }
}

Затем следует создать наше текстовое поле при помощи виджета TextFormField. Данный виджет внутри использует TextField, но имеет дополнительные возможности. В отличие от TextField, у него есть возможность валидировать данные — благодаря параметру validator. Данный параметр принимает на вход метод с одним параметром, который обозначает текущий введённый текст.

TextFormField(
  validator: (value) {
    if (value == null || value.length < 10) {
      return 'Ненадежный пароль';
    }
    return null;
  },
);

В данном примере мы проверяем пароль на надёжность.

Если validator вернёт что угодно, кроме null, то поле не прошло валидацию. Если возвращается строка, то TextFormField сам сообщит пользователю о проблеме.

image

Чтобы провалидировать данные, мы должны получить состояние виджета Form и вызвать у него метод validate().

TextButton(
  onPressed: () {
    if (_formKey.currentState?.validate() ?? false) {
      // прошли валидацию
    }
    // не прошли валидацию
  },
  child: Text('Валидировать'),
);

Если теперь соединить всё вместе, то получим форму, в которую можно ввести данные и провалидировать их. А в зависимости от результата валидации либо сообщить пользователю, где он ошибся, либо отправить данные на сервер.

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

  @override
  CustomFormState createState() {
    return CustomFormState();
  }
}

class CustomFormState extends State<CustomForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Form(
        key: _formKey,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextFormField(
              validator: (value) {
                if (value == null || value.length < 10) {
                  return 'Ненадежный пароль';
                }
                return null;
              },
            ),
            TextButton(
              onPressed: () {
                if (_formKey.currentState?.validate() ?? false) {
                  // Прошли валидацию
                }
                // Не прошли валидацию
              },
              child: Text('Валидировать'),
            ),
          ],
        ),
      ),
    );
  }
}

Как избежать перекрытия контента клавиатурой

Страницы с текстовыми полями зачастую устроены стандартным образом: какой-то заголовок, по центру текстовые поля, а в самом низу располагается кнопка подтверждения ввода.

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

Чтобы решить эту проблему, можно поместить контент в какую-то скроллющуюся обёртку. Это могут быть виджеты ListView или SingleChildScrollView.

Ниже — результат применения ListView. Теперь у пользователя есть возможность подскролливать контент на экране.

ListView(
	children: [
	  Placeholder(fallbackHeight: 400),
    TextField(),
    Placeholder(fallbackHeight: 400),
  ],
),

Кнопки

Во Flutter уже есть много готовых кнопок, вот некоторые из них:

  1. ElevatedButton — стилизована так, будто бы парит в воздухе.
  2. IconButton — отлично подходит, когда нужно, чтобы по нажатию на иконку происходило какое-то действие.
  3. OutlineButton — кнопка с дополнительной обводкой вокруг.
  4. TextButton — привычная нам кнопка-текст.

21

Многие из них используют под капотом виджет RawMaterialButton как основу и дополняют его своими свойствами. RawMaterialButton представляет собой основу для кнопок в стиле Material. Подробнее про стиль можно почитать на официальном сайте — https://m3.material.io/components/all-buttons.

Кроме обычных кнопок в интерфейсе стоит отметить ещё FloatingActionButton. Она обычно используется как главное действие, которое пользователь должен совершить на экране. Зачастую её кладут в Scaffold:

Scaffold(
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: Icon(Icons.send),
  ),
)

2

Radio

Этот тип кнопок отлично подходит, когда нужно предоставить пользователю выбор между взаимоисключающими вариантами. Во Flutter для этого используется виджет Radio.

Чтобы создать данный виджет, необходимо передать следующие параметры:

  1. value — значение, с которым будет сравниваться groupValue, и если сравнение пройдёт успешно, то данная кнопка будет выбрана.
  2. groupValue — общее значение для нескольких Radio Button. Мы как раз управляем состоянием нескольких кнопок, меняя его.
  3. onChanged — в этом колбэке мы получаем актуальное value для кнопки. Его стоит использовать для обновления groupValue и перерисовки компонента.
Radio<String>(
  value: 'Каша',
  groupValue: breakfast,
  onChanged: (String? value) {
    setState(() {
      breakfast = value;
    });
  },
),

Пример, в котором у нас есть несколько таких кнопок:

class _RadioButtonExampleState extends State<RadioButtonExample> {
  String? breakfast = 'Каша';

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        ListTile(
          title: const Text('Каша'),
          leading: Radio<String>(
            value: 'Каша',
            groupValue: breakfast,
            onChanged: (String? value) {
              setState(() {
                breakfast = value;
              });
            },
          ),
        ),
        ListTile(
          title: const Text('Творог'),
          leading: Radio<String>(
            value: 'Творог',
            groupValue: breakfast,
            onChanged: (String? value) {
              setState(() {
                breakfast = value;
              });
            },
          ),
        ),
      ],
    );
  }
}

20

Checkbox

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

Checkbox(
  value: isChecked,
  onChanged: (bool? value) {
    setState(() {
      isChecked = value;
    });
  },
);

1

По дефолту данный переключатель может находиться в двух состояниях: включен или выключен. Но можно задать свойство tristate: true, и тогда мы получим промежуточное состояние.

Screen

Жесты

Но иногда бывают случаи, когда мы хотим, чтобы действие происходило не по нажатии на кнопку, а на какой-то иной объект или произвольную область на экране. Для этих целей обычно используют виджеты GestureDetector или InkWell.

Сначала рассмотрим InkWell. Он служит для обработки «простых» жестов, таких как тапы, двойные тапы и некоторые другие:

InkWell(
  onTap: () {
    print("onTap");
  },
  child: FlutterLogo(
    size: 200,
  ),
);

При нажатии он добавляет поверхности эффект, будто бы это кнопка. Если такой визуальный эффект не нужен и/или нужно обрабатывать более сложные жесты: слайды в разные стороны, сильные / слабые нажатия и другое, то стоит взглянуть в сторону виджета GestureDetector.

Чтобы им воспользоваться, необходимо обернуть им искомый виджет.

GestureDetector(
  onTap: () {
    print("onTap");
  },
  onHorizontalDragStart: ((details) {
    print("onHorizontalDragStart");
  }),
  child: FlutterLogo(
    size: 200,
  ),
);

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

Вывод

Flutter имеет богатую библиотеку уже готовых компонентов и классов. Это позволяет создавать красивые и удобные приложения. Кроме того, вы можете создать свои виджеты, которые обрабатывают ввод, жесты и много чего ещё. Важно помнить о конечных пользователях вашего приложения, об их удобстве и удовольствии.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.10. Widgets: layout
Следующий параграф2.12. Работа с сетью (http, socket), сериализация, хранение данных