2.9. Виджеты: основы

В этом параграфе мы познакомимся с основным инструментом для построения UI во Flutter — виджет (англ. Widget).

Начнём с небольшой теории — узнаем, как работают с UI другие кроссплатформенные фреймворки. Затем поговорим о том, что такое виджеты и познакомимся с двумя разновидностями виджетов — StatelessWidget и InheritedWidget.

Виды кроссплатформенных фреймворков

Существует множество кроссплатформенных фреймворков помимо Flutter. Давайте рассмотрим, какие подходы они используют для отображения UI:

  • Cordova, Ionic, PhoneGap и другие — фреймворки, основанные на Web View. В них весь пользовательский интерфейс приложения отрисовывается при помощи браузерного движка.
  • React Native, Xamarin — фреймворки, основанные на нативных виджетах. Они используют платформенные реализации для отображения пользовательского интерфейса. Например, если приложение выводит текст на экран, при запуске на Android будет использован нативный виджет TextView из Android Framework, а на iOS — UITextView из UIKit.
  • Flutter — фреймворк с собственным движком отрисовки. Он не использует нативные виджеты или Web View, а рисует всё напрямую при помощи движка Skia. Способ отображения UI во Flutter похож на то, как работают игровые движки.

Подход Flutter даёт несколько преимуществ:

  • уверенность, что UI будет одинаково выглядеть на всех платформах;
  • гибкость при разработке сложных интерфейсов;
  • возможности для оптимизации внутри фреймворка;
  • возможности по адаптации Flutter под любую платформу и ОС.

Теперь давайте посмотрим, как создаётся UI во Flutter.

Знакомство с виджетами

Начнём с классического примера и выведем Hello, World на экране устройства.

Попробуйте запустить пример не только в DartPad, но и на эмуляторе или мобильном устройстве.

Обратите внимание на объекты ColumnIconText. Каждый из них — наследник класса Widget.

Widget во Flutter — иммутабельное (неизменяемое) описание части пользовательского интерфейса. Они — ключевая концепция для построения UI.

Каждый виджет имеет свою ответственность:

  • Column располагает дочерние виджеты один за другим в вертикальном направлении;
  • Icon отображает иконку, которая передаётся в качестве параметра (Icons.bolt);
  • Text отображает строковое значение.

Виджетам Text и Icon необходимо передавать параметр textDirection — иначе получите ошибку. Позже вы познакомитесь с виджетом MaterialApp, который возьмёт эту работу на себя.

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

fluttern

Дерево виджетов из Hello, World примера

Если просто создать виджеты, они не будут отрисованы на экране. Чтобы это произошло, необходимо вызвать встроенную функцию runApp(Widget app), которая присоединяет переданный ей виджет, а точнее всё дерево виджетов, к экрану. Переданный виджет, в нашем случае Column, становится корневым.

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

Что происходит до вызова runApp

Функция main не вызывается моментально после запуска приложения. Требуется некоторое время для инициализации всех составляющих движка Flutter. Чтобы пользователь не смотрел в пустой экран, в это время отображается нативный сплеш-скрин (англ. Splash Screen). Если запустить пример из предыдущего пункта на телефоне, то при запуске показывается логотип Flutter. Это и есть сплеш-скрин по умолчанию.

fluttern

Сплеш-скрин можно кастомизировать — например, заменить на логотип вашего приложения. Подробнее об этом можно прочесть в документации Android и iOS. Благодаря нативному сплеш-скрину также необязательно вызывать runApp первой же операцией в функции main. Если необходимо, можно дождаться выполнения какой-либо асинхронной операции (например, инициализация библиотеки аналитики, получение данных из кэша или БД).

👉 Запомните:

  • Widget — это иммутабельное описание части пользовательского интерфейса.
  • runApp(Widget app) прикрепляет дерево виджетов к экрану.
  • До вызова runApp на экране будет отображаться нативный сплеш-скрин, который можно кастомизировать.

Всего существует три основных типа виджетов:

  • StatelessWidget — виджет, который не изменяется со временем, статический.
  • InheritedWidget— виджет для передачи данных по дереву.
  • StatefulWidget — виджет, который может изменяться, динамический.

Прежде чем двинуться дальше, проговорим два важных нюанса.

  1. Во-первых, виджетов гораздо больше, чем мы перечислили, но основные — именно эти три. Важно изучить в первую очередь их.
  2. В этом параграфе мы рассмотрим только StatelessWidget и InheritedWidget, а StatefulWidget посвятим отдельный параграф.

StatelessWidget

В первом примере мы создали виджеты прямо в функции main. Как только вы попытаетесь построить чуть более сложный UI, станет понятно, что писать код таким образом крайне неудобно. Эту проблему решает StatelessWidget.

StatelessWidget — виджет, не имеющий состояния. Он позволяет декомпозировать UI.

Чтобы создать собственный StatelessWidget, можно использовать сниппет stless (работает в IDE с установленным плагином Flutter), который создаст следующий класс:

1class SomeWidget extends StatelessWidget {
2  const SomeWidget({Key? key}) : super(key: key);
3
4  @override
5  Widget build(BuildContext context) {
6    return const Placeholder();
7  }
8}

Как видите, это просто класс, который наследуется от абстрактного класса StatelessWidget и реализует метод build. Из build-метода мы должны вернуть объект типа Widget.

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

У build-метода есть аргумент BuildContext contextBuildContext — это интерфейс, который предоставляет виджету методы, чтобы взаимодействовать с деревом элементов. К контексту и элементам мы скоро вернёмся.

Теперь давайте переделаем наш Hello, World с использованием StatelessWidget. Для этого необходимо создать наследник класса StatelessWidget и перенести виджет Column из функции main в метод build.

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

👉 StatelessWidget — виджет без состояния, позволяющий выделить часть UI в отдельный класс.

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

  • выделение структурных частей, например Page, Header и Content;
  • выделение UI-компонентов (как правило, их собирают дизайнеры в UI-kit), например AppBar, Button и TextField;
  • выделение виджетов, используемых в разных частях экрана/приложения, — к примеру, виджет для отображения пользовательского комментария под постом;
  • выделение декорирующих виджетов, чтобы упростить понимание вёрстки.

Посмотрите на код ниже. Мы добавили обёртку для иконки с градиентом и скруглением, но это сильно затруднило чтение build-метода.

Мы можем вынести часть кода, отвечающего за декорирование иконки, в отдельный виджет и тем самым упростить понимание кода вёрстки:

Мы сделали виджет _GradientBackground приватным. Так делают, если уверены, что виджет будет использоваться только внутри этого файла. Если виджет пригодится в других частях приложения, то его следует сделать публичным и вынести в отдельную папку — например, lib/widgets. Желательно заранее продумать точки расширения вашего виджета и сделать его максимально гибким.

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

InheritedWidget

InheritedWidget — виджет, позволяющий эффективно передавать данные вниз по дереву.

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

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

Посмотрим на пример реализации InheritedWidget.

Виджет InheritedNumber унаследован от InheritedWidget. К реализации есть два требования:

  1. Конструктор должен содержать параметр child, чтобы было возможно передать дочерний виджет.
  2. Обязательна реализация метода updateShouldNotify. Этот метод позволяет определить, когда дочерние виджеты будут уведомлены при изменении InheritedWidget.

Когда какой-то InheritedWidget встроен в дерево, дочерние виджеты могут подписаться на него при помощи метода dependOnInheritedWidgetOfExactType у BuildContext, который приходит в качестве параметра build-метода. Он возвращает родительский InheritedWidget нужного типа. Чтобы упростить работу с InheritedWidget, принято добавлять статичные методы of и maybeOf.

InheritedWidget могут использоваться довольно часто, например для доступа к теме, локализации. При каждом обращении к InheritedWidget будет вызываться context.dependOnInheritedWidgetOfExactType.

Так как деревья элементов в реальных приложениях могут быть огромными, разработчики Flutter позаботились об оптимизации. Доступ к InheritedWidget осуществляется за O(1), то есть сложность доступа не зависит от размера дерева элементов. Это достигается благодаря тому, что в каждом Element хранится Map со ссылками на InheritedWidget, где в качестве ключа используется тип InheritedWidget.

При использовании InheritedWidget важно понимать, с каким именно BuildContext вы работаете. Посмотрите на следующий код:

1class SomeWidget extends StatelessWidget {
2  @override
3 Widget build(BuildContext context) {
4   return InheritedNumber(
5     number: 1,
6     child: Text(InheritedNumber.of(context).number.toString()),
7   )
8 }
9}

В этом примере мы не сможем получить InheritedNumber , потому что dependOnInheritedWidgetOfExactType возвращает InheritedWidget, находящийся выше по дереву, чем context. Мы использовали context от SomeWidget, а InheritedNumber определён по дереву ниже.

Исправить приведённый код можно двумя способами. Первый вариант — вынести Text в отдельный виджет, как было в примере из начала данного пункта. Второй вариант — использовать виджет Builder:

1class SomeWidget extends StatelessWidget {
2  @override
3 Widget build(BuildContext context) {
4   return InheritedNumber(
5     number: 1,
6     child: Builder(
7        builder: (context) => Text(
8          InheritedNumber.of(context).number.toString(),
9        ),
10      ),
11   )
12 }
13}

Builder позволяет получить BuildContext именно из того места дерева, в которое он встроен.

При этом из of и maybeOf необязательно возвращать сам InheritedWidget. Вы можете захотеть абстрагировать данные от самого виджета или выполнить какую-то дополнительную логику. Такое часто встречается в самом фреймворке. Посмотрим на реализацию Theme.of:

1static ThemeData of(BuildContext context) {
2  final _InheritedTheme? inheritedTheme = context.dependOnInheritedWidgetOfExactType<_InheritedTheme>();
3  final MaterialLocalizations? localizations = Localizations.of<MaterialLocalizations>(context, MaterialLocalizations);
4  final ScriptCategory category = localizations?.scriptCategory ?? ScriptCategory.englishLike;
5  final ThemeData theme = inheritedTheme?.theme.data ?? _kFallbackTheme;
6  return ThemeData.localize(theme, theme.typography.geometryThemeFor(category));
7}

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

  • InheritedWidget — виджет, позволяющий передать данные вниз по дереву виджетов.
  • context.dependOnInheritedWidgetOfExactType возвращает InheritedWidget определённого типа, если тот существует выше по дереву виджетов, и делает это за константное время.

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

Мы рассмотрели два основных класса виджетов — StatelessWIdget и InheritedWidget. Советуем пройти квиз, заерепить знания — и переходить к следующему параграфу, в котором мы сфокусируемся на третьем классе виджетов — StatefulWidget.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

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

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.8. Dart: Stream API и реактивное программирование
Следующий параграф2.10. Виджеты: StatefulWidget