В этом параграфе мы познакомимся с основным инструментом для построения 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, но и на эмуляторе или мобильном устройстве.
Обратите внимание на объекты Column
, Icon
, Text
. Каждый из них — наследник класса Widget
.
Widget
во Flutter — иммутабельное (неизменяемое) описание части пользовательского интерфейса. Они — ключевая концепция для построения UI.
Каждый виджет имеет свою ответственность:
Column
располагает дочерние виджеты один за другим в вертикальном направлении;Icon
отображает иконку, которая передаётся в качестве параметра (Icons.bolt
);Text
отображает строковое значение.
Виджетам Text
и Icon
необходимо передавать параметр textDirection
— иначе получите ошибку. Позже вы познакомитесь с виджетом MaterialApp
, который возьмёт эту работу на себя.
При работе с виджетами используется композиция (один виджет вкладывается в другой), а значит, мы можем представить их в виде дерева. В нашем случае оно будет совсем простым, но в реальном приложении дерево может быть огромным.
Дерево виджетов из Hello, World
примера
Если просто создать виджеты, они не будут отрисованы на экране. Чтобы это произошло, необходимо вызвать встроенную функцию runApp(Widget app)
, которая присоединяет переданный ей виджет, а точнее всё дерево виджетов, к экрану. Переданный виджет, в нашем случае Column
, становится корневым.
В большинстве приложений есть необходимость вызывать runApp
единожды. Если вы повторно вызовете эту функцию, то предыдущее дерево виджетов будет отсоединено от экрана, а новое присоединено. Однако не стоит так делать, если у вас нет чёткого понимания, зачем прибегать к такому подходу.
Что происходит до вызова runApp
Функция main
не вызывается моментально после запуска приложения. Требуется некоторое время для инициализации всех составляющих движка Flutter. Чтобы пользователь не смотрел в пустой экран, в это время отображается нативный сплеш-скрин (англ. Splash Screen). Если запустить пример из предыдущего пункта на телефоне, то при запуске показывается логотип Flutter. Это и есть сплеш-скрин по умолчанию.
Сплеш-скрин можно кастомизировать — например, заменить на логотип вашего приложения. Подробнее об этом можно прочесть в документации Android и iOS. Благодаря нативному сплеш-скрину также необязательно вызывать runApp
первой же операцией в функции main
. Если необходимо, можно дождаться выполнения какой-либо асинхронной операции (например, инициализация библиотеки аналитики, получение данных из кэша или БД).
👉 Запомните:
Widget
— это иммутабельное описание части пользовательского интерфейса.runApp(Widget app)
прикрепляет дерево виджетов к экрану.- До вызова
runApp
на экране будет отображаться нативный сплеш-скрин, который можно кастомизировать.
Всего существует три основных типа виджетов:
StatelessWidget
— виджет, который не изменяется со временем, статический.InheritedWidget
— виджет для передачи данных по дереву.StatefulWidget
— виджет, который может изменяться, динамический.
Прежде чем двинуться дальше, проговорим два важных нюанса.
- Во-первых, виджетов гораздо больше, чем мы перечислили, но основные — именно эти три. Важно изучить в первую очередь их.
- В этом параграфе мы рассмотрим только
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 context
. BuildContext
— это интерфейс, который предоставляет виджету методы, чтобы взаимодействовать с деревом элементов. К контексту и элементам мы скоро вернёмся.
Теперь давайте переделаем наш 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
. К реализации есть два требования:
- Конструктор должен содержать параметр
child
, чтобы было возможно передать дочерний виджет. - Обязательна реализация метода
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
.