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 на экране устройства.

import 'package:flutter/material.dart';

void main() {
  runApp(
    Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: const [
        Icon(
          Icons.bolt,
          size: 32,
          textDirection: TextDirection.ltr,
        ),
        Text(
          'Hello, Widget World!',
          textDirection: TextDirection.ltr,
        ),
      ],
    ),
  );
}

Результат выполнения

Результат выполнения примера 1

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

Обратите внимание на объекты Column, Icon, Text. Каждый из них — наследник класса 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), который создаст следующий класс:

class SomeWidget extends StatelessWidget {
  const SomeWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return const Placeholder();
  }
}

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

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

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

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

import 'package:flutter/material.dart';

void main() {
  runApp(
    const HelloApp(
      text: 'Hello, I am StatelessWidget!',
    ),
  );
}

class HelloApp extends StatelessWidget {
  final IconData icon;
  final String text;

  const HelloApp({
    required this.text,
    this.icon = Icons.bolt,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(
          icon,
          size: 32,
          textDirection: TextDirection.ltr,
        ),
        Text(
          text,
          textDirection: TextDirection.ltr,
        ),
      ],
    );
  }
}

Результат выполнения

Результат выполнения примера 2

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

Примечание

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

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

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

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

import 'package:flutter/material.dart';

void main() {
  runApp(
    const HelloApp(
      text: 'Hello, I am StatelessWidget!',
    ),
  );
}

class HelloApp extends StatelessWidget {
  final IconData icon;
  final String text;

  const HelloApp({
    required this.text,
    this.icon = Icons.bolt,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        DecoratedBox(
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(8),
            gradient: const LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment(0.8, 1),
              colors: <Color>[
                Color(0xff1f005c),
                Color(0xff5b0060),
                Color(0xff870160),
                Color(0xffac255e),
                Color(0xffca485c),
                Color(0xffe16b5c),
                Color(0xfff39060),
                Color(0xffffb56b),
              ],
            ),
          ),
          child: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Icon(
              icon,
              size: 32,
              color: Colors.white,
              textDirection: TextDirection.ltr,
            ),
          ),
        ),
        const SizedBox(height: 16),
        Text(
          text,
          textDirection: TextDirection.ltr,
        ),
      ],
    );
  }
}

Результат выполнения

Результат выполнения примера 3

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

import 'package:flutter/material.dart';

void main() {
  runApp(
    const HelloApp(
      text: 'Hello, I am StatelessWidget!',
    ),
  );
}

class HelloApp extends StatelessWidget {
  final IconData icon;
  final String text;

  const HelloApp({
    required this.text,
    this.icon = Icons.bolt,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        _GradientBackground(
          padding: const EdgeInsets.all(8.0),
          child: Icon(
            icon,
            size: 32,
            color: Colors.white,
            textDirection: TextDirection.ltr,
          ),
        ),
        const SizedBox(height: 16),
        Text(
          text,
          textDirection: TextDirection.ltr,
        ),
      ],
    );
  }
}

class _GradientBackground extends StatelessWidget {
  final Widget child;
  final EdgeInsets padding;

  const _GradientBackground({
    required this.child,
    this.padding = EdgeInsets.zero,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(8),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment(0.8, 1),
          colors: <Color>[
            Color(0xff1f005c),
            Color(0xff5b0060),
            Color(0xff870160),
            Color(0xffac255e),
            Color(0xffca485c),
            Color(0xffe16b5c),
            Color(0xfff39060),
            Color(0xffffb56b),
          ],
        ),
      ),
      child: Padding(
        padding: padding,
        child: child,
      ),
    );
  }
}

Результат выполнения

Результат выполнения примера 4

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

Примечание

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

InheritedWidget

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

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

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

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

import 'package:flutter/material.dart';
import 'dart:math';

void main() {
  runApp(MaterialApp(home: RandomizerPage()));
}

class InheritedNumber extends InheritedWidget {
  const InheritedNumber({
    required this.number,
    required super.child,
    super.key,
  });

  final int number;

  static InheritedNumber? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<InheritedNumber>();
  }

  static InheritedNumber of(BuildContext context) {
    final InheritedNumber? result = maybeOf(context);
    assert(result != null, 'No InheritedNumber found in context');
    return result!;
  }

  @override
  bool updateShouldNotify(InheritedNumber oldWidget) =>
      number != oldWidget.number;
}

class RandomizerPage extends StatefulWidget {
  @override
  State<RandomizerPage> createState() => _RandomizerPageState();
}

class _RandomizerPageState extends State<RandomizerPage> {
  final rand = Random();
  late int _randomNumber = rand.nextInt(100);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: InheritedNumber(
        number: _randomNumber,
        child: Center(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisSize: MainAxisSize.min,
            children: [
              ElevatedButton(
                onPressed: () {
                  setState(() {
                    _randomNumber = rand.nextInt(100);
                  });
                },
                child: const Text('Refresh'),
              ),
              const SizedBox(height: 16),
              Text('Number in RandomizerPage: $_randomNumber'),
              const SizedBox(height: 16),
              _Child(),
            ],
          ),
        ),
      ),
    );
  }
}

class _Child extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text(
      'Number in _Child: ${InheritedNumber.of(context).number}',
    );
  }
}

Результат выполнения

Результат выполнения примера 5

Виджет 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 вы работаете. Посмотрите на следующий код:

class SomeWidget extends StatelessWidget {
  @override
 Widget build(BuildContext context) {
   return InheritedNumber(
     number: 1,
     child: Text(InheritedNumber.of(context).number.toString()),
   )
 }
}

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

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

class SomeWidget extends StatelessWidget {
  @override
 Widget build(BuildContext context) {
   return InheritedNumber(
     number: 1,
     child: Builder(
        builder: (context) => Text(
          InheritedNumber.of(context).number.toString(),
        ),
      ),
   )
 }
}

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

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

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

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

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

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

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