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

Меня зовут Александр, я Flutter-разработчик в Surf, и сегодня мы сделаем ещё один небольшой шаг к этим крутейшим возможностям — мы научимся создавать собственные RenderObject. 

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

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

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

Деревья, деревья, деревья

Без деревьев во Flutter никуда. Вы много раз слышали про 3 главных дерева Flutter:

  • Widget Tree;

  • Element Tree;

  • RenderObject Tree.

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

 Основные деревья Flutter:

Widget Tree состоит из Widgets самого разного типа. Мы знаем, что Widget — это immutable декларативная конфигурация некоторого элемента интерфейса, отображаемого на экране.

При изменении конфигурации экземпляры виджетов создаются и удаляются, Widget Tree перестраивается соответствующим образом.

Обратим внимание, что не для каждого Widget из Widget Tree в RenderObject Tree содержится связанный с ним элемент. Он есть только для RenderObjectWidget.

Мы чаще всего используем StatelessWidget и StatefulWidget для создания виджетов. Но, в конце концов, в цепочке из StatelessWidget и StatefulWidget будут созданы  RenderObjectWidget — мы ведь хотим отобразить на экране какое-то содержимое, пользовательские интерфейсы разрабатываем — у которых уже будет своё представление в RenderObject Tree.

Element Tree состоит из долгоживущих объектов Elements, которые связывают между собой Widgets и RenderObjects.

Создание Widget приводит к созданию Element. Виджет впоследствии получает доступ к связанному с ним элементу в методе build(BuildContext context).

Elements покадрово анализируют изменения в дереве виджетов. Определяют последствия этих изменений и обрабатывают их соответствующим образом.

RenderObject Tree cостоит из долгоживущих объектов RenderObject.

RenderObject преобразуют значения, полученные из конфигурации Widget,  в «рисующие» вызовы, которые выполняются на GPU, чтобы отрисовать что-то на экране в соответствии с полученной конфигурацией.

Но какие задачи решает RenderObject?

Не будем вдаваться в детали процесса построения деревьев, это тема для отдельной статьи. Но зафиксируем, что на основе immutable-конфигурации для отображаемых на экране сущностей Widget Tree формируются 2 дерева из долгоживущих mutable объектов Element Tree и RenderObject Tree. 

Так, через виджеты — к элементам, а от элементов — к рендер-объектам, Flutter поэтапно создает изображение на экране устройства.

RenderObjectWidget — особый тип виджета

RenderObjectWidget отличается от StatelessWidget и StatefulWidget. У него нет метода  build, который приводит к созданию дочернего виджета. Но есть методы createRenderObject и updateRenderObject для создания и обновления RenderObject .

Именно RenderObjectWidget в конечном итоге приводят к тому, что на экране устройства появляется изображение. При этом основная задача RenderObjectWidget заключается не в том, чтобы создавать поддерево виджетов, а в том, чтобы добавлять в Render Tree соответствующие RenderObject .

Создание RenderObjectWidget приводит к созданию особого элемента  RenderObjectElement в дереве элементов.

В RenderObjectElement есть методы, которые позволяют взаимодействовать с RenderObject Tree и RenderObject:

  • insertRenderObjectChild;

  • moveRenderObjectChild;

  • removeRenderObjectChild;

  • attachRenderObject;

  • detachRenderObject.

И в какой-то момент именно RenderObjectElement вызовет метод createRenderObject у связанного с ним RenderObjectWidget. А это приведёт к созданию RenderObject.

Затем RenderObjectElement установит созданный RenderObject в качестве дочернего для уже существующего родительского RenderObject.

Так и формируется RenderObject Tree, а созданный RenderObject уже добавляется в него.

Основные виды RenderObjectWidget:

  • LeafRenderObjectWidget — создают одиночный RenderObject без дочерних элементов. Это такой лист в RenderObject Tree, который отвечает только за собственное отображение, без поддержки дочерних виджетов;

  • SingleChildRenderObjectWidget — предназначен для создания виджетов с одним дочерним элементом, которые управляют собственным RenderObject и могут влиять на дочерний;

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

RenderObject

Остановимся на зонах ответственности RenderObject. Спойлер — их очень много, гораздо больше, чем можно себе представить. Но мы с вами рассмотрим только те, которые играют ключевую роль при создании собственного RenderObject.

1. Layout  — определение размера, позиционирование дочерних RenderObject при их наличии.

Мы должны понять, насколько большим будет этот RenderObject и сколько места в итоге он займёт на экране.

В методе performLayout при измерении размеров используют свойство constraints  — ограничения размера, полученные от родительского RenderObject.

Логика performLayout также может зависеть от размера дочерних RenderObject для текущего  RenderObject.

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

Следует отметить, что фаза layout — это то самое место, где вступает в дело известный механизм разметки экрана Flutter: "Constraints go down, sizes go up, parent sets position".

2. Painting  — отрисовка RenderObject на экране.

Метод paint позволяет сформировать очередь «рисующих» команд. Они будут выполнены на объекте PaintingContext, который он получает в качестве параметра. Как правило, этот контекст для рисования — объект Canvas.

Впоследствии набор этих «рисующих» команд будет обработан движком Skia/Impeller и преобразован в конкретные визуальные объекты на экране.

Уже начинаете чувствовать, как у вас появляются больше власти над каждым пикселем?

3. Hit Testing — так во Flutter называется механизм определения элемента интерфейса, который ответственен за обработку прикосновения к экрану или нажатия мышью.

Во Flutter только RenderObjects знают координаты своего расположения на экране и свои размеры. Поэтому только RenderObjects могут быть ответственны за реализацию Hit Testing.

4. Accessibility и Semantics — RenderObject ответственны за передачу смысловой информации об отображаемом элементе интерфейса. Ее будут использовать инструменты средств доступности содержимого, предоставляемыми ОС и платформой, на которой запущено наше приложение.

Эта информация о RenderObject будет передаваться с помощью метода  describeSemanticsConfiguration.

Встроенные RenderObject

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

Flutter содержит ряд встроенных классов, которые наследуют от RenderObject и сужают эту абстракцию. Так они реализуют способы компоновки отображаемого на экране содержимого.

  • RenderBox — реализует отрисовку и размещение прямоугольных RenderObject в двумерной системе координат с началом координат в левом верхнем углу. Используется для отрисовки большинства RenderObject во Flutter-приложениях.

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

Протоколы RenderBox и RenderSliver используют разные модели constraints и реализуют разные способы отрисовки элементов на экране. 

Из-за различий в логике компоновки layout и отрисовки paint, RenderBox и RenderSliver  несовместимы напрямую. Иными словами, мы не можем расположить виджет, использующий RenderBox, внутри виджета, использующего RenderSliver, и наоборот.

Поэтому для совместного использования этих виджетов нужны специальные адаптеры. Например, SliverToBoxAdapter и RenderViewport — они преобразуют одну модель в другую и не забывают про корректную компоновку и отрисовку.

На практике чаще всего используются именно эти — более узкие и специфичные — абстракции. Но ничто не мешает самостоятельно унаследовать от RenderObject и создать собственную систему позиционирования содержимого. Например, для системы координат отличной от двумерной Декартовой. Кстати, получится большая и любопытная задача.

RenderView

Отдельно расскажем об особом наследнике RenderObject — классе RenderView.

Экземпляр RenderView — вершина RenderObject Tree. Flutter автоматически создаёт RenderView при запуске приложения.

RenderView представляет всю доступную для отображения содержимого и вывода  RenderObject Tree поверхность экрана. Он содержит уникальный дочерний RenderBox, который заполняет всю эту поверхность собой.

RenderView ответственен также за инициализацию конвейера рендеринга rendering pipeline. Это механизм, который отвечает за многоэтапный процесс преобразования виджетов в то, что будет изображено на экране устройства. А ещё — за обновление этого изображения в соответствие с изменённой конфигурацией виджетов.

Каждый RenderObject может получить доступ к RenderView — для этого нужно обратиться к свойству pipelineOwner.rootNode .

Когда стоит задуматься о создании собственного RenderObject

При работе с собственными RenderObject одна из самых главных сложностей — придумать причину его создания. 

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

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

  • одной из причин для создания пользовательского RenderObject может быть необходимость собственных, уникальных правил взаимодействия соседних виджетов. Если, к примеру, отрисовка одного виджета зависит от того, как отрисован соседний виджет, или от размера его содержимого;

  • если нужно реализовать уникальную логику обработки касаний или создавать сложную геометрию для определения нажатия по виджету с нестандартной формой, например, многоугольника;

  • если хочется реализовать логику ограничений размера виджета, например, «дочерний виджет должен занимать не более четверти размера родительского виджета»;

  • если требуется создать высокопроизводительные анимации, недоступные для стандартных виджетов, например, сделать морфинг между произвольными фигурами;

  • если нужна высокопроизводительная отрисовка элементов, например, в сложных графиках с сотнями элементов может потребоваться управлять их layout и paint  вручную;

  • если хочется реализовать уникальное поведение прокрутки и кастомное размещение прокручиваемых элементов, которые невозможно сделать средствами стандартных Sliver из библиотеки Flutter. Такой подход обеспечит полный контроль над отрисовкой и взаимодействием — это важно при работе с большими списками, динамическим содержимым или специфическими UX-требованиями.

Вот такая картина у нас получилась. Пора переходить к практике.

Реализуем собственный RenderObject

Представим, что нам требуется реализовать галерею изображений, размеры которых нам не известны заранее. Мы должны размещать изображения по принципу «кирпичной стены» и замостить ими всё свободное пространство. Только в нашем случае у каждого «кирпича» может быть произвольный размер. Такое расположение элементов интерфейса называют Masonry Grid или Masonry Layout.

Этот приём можно часто встретить в разных приложениях — в Pinterest, к примеру, или на маркетплейсах.

Пример Masonry Grid галереи
Пример Masonry Grid галереи

Размещение каждого «кирпича» в стене зависит от свободного пространства в родителе и от характеристик соседних «кирпичей». Такая постановка задачи отлично подходит под первую из рассмотренных выше причин для создания собственного RenderObject.

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

MasonryGridRenderObjectWidget

Как мы уже знаем, чтобы RenderObject появился на свет, нам потребуется создать RenderObjectWidget.

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

Рассмотрим его код:

class MasonryGridRenderObjectWidget extends MultiChildRenderObjectWidget {
  final int columnsCount;
  final double spacing;

  const MasonryGridRenderObjectWidget({
    required this.columnsCount,
    this.spacing = 8.0,
    super.key,
    super.children,
  });

  @override
  MasonryGridRenderObject createRenderObject(BuildContext context) {
    // Create a new instance of MasonryGridRenderObject, pass initial configuration.
    return MasonryGridRenderObject(
	    columns: columnsCount,
	    spacing: spacing
	);
  }

  @override
  void updateRenderObject(
    BuildContext context,
    MasonryGridRenderObject renderObject,
  ) {
    // Update configuration of the existing instance of MasonryGridRenderObject.
    renderObject
      ..columns = columnsCount
      ..spacing = spacing;
  }
}
  • Определили класс MasonryGridRenderObjectWidget, наследующий от MultiChildRenderObjectWidget.

  • Определили методы createRenderObject и updateRenderObject.

Вместо build в MasonryGridRenderObjectWidget мы переопределили два других, не менее важных метода.

Метод createRenderObject() создаёт экземпляр MasonryGridRenderObject — RenderObject, который будет связан с этим виджетом.

В этом методе происходит передача начальной конфигурации, которая влияет на отрисовку MasonryGridRenderObject. В нашем случае, это — количество колонок columnsCount, среди которых нужно распределить дочерние RenderObject, и spacing величина свободного пространства между дочерними RenderObject.

updateRenderObject() — используют для обновления MasonryGridRenderObject, когда конфигурация связанного с ним MasonryGridRenderObjectWidget будет изменена.

Как мы помним, экземпляры Widgets immutable. Иными словами, при изменении их конфигурации Flutter удалит текущий и создаст совершенно новый экземпляр виджета. Конечно же, это справедливо и для нашего MasonryGridRenderObjectWidget.

А вот RenderObject — долгоживущие mutable-объекты (у Flutter на это есть огромный список причин, который можно свести к одному слову — оптимизация).

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

Так, фреймворк вызовет метод updateRenderObject(BuildContext context, MasonryGridRenderObject renderObject) в момент замены экземпляра этого виджета на новый. Ссылка на MasonryGridRenderObject будет передана в аргументе renderObject. А это позволит нам обновить его конфигурацию.

Как обрабатывать изменение этих значений — решает сам MasonryGridRenderObject. О реализации которого мы сейчас и поговорим.

MasonryGridRenderObject

Сначала разберёмся с определением класса MasonryGridRenderObject.

class MasonryGridRenderObject extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, MasonryParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, MasonryParentData> {
  MasonryGridRenderObject({required int columns, required double spacing})
    : _columns = columns,
      _spacing = spacing;

	// The rest code...
}

Это громоздкое определение, пройдёмся по деталям.

Класс MasonryGridRenderObject наследует от RenderBox, поскольку очевидно, что наша галерея может быть изображена в виде прямоугольника в двумерной системе координат.

ContainerRenderObjectMixin

После — добавляем к классу примесь ContainerRenderObjectMixin<RenderBox, MasonryParentData>. 

Она даёт MasonryGridRenderObject способ хранить дочерние RenderObject в виде двусвязного списка. Поскольку у нашего MasonryGridRenderObject множество дочерних RenderObject, нам нужно где-то их хранить и уметь по ним перемещаться.

У примеси ContainerRenderObjectMixin следующее определение: ContainerRenderObjectMixin<ChildType extends RenderObject, ParentDataType extends ContainerParentDataMixin<ChildType>> .

  • ChildType определяет тип дочернего RenderObject, в нашем случае это будут  RenderBox. Так мы сможем отображать в галерее любые виджеты, удовлетворяющие протоколу RenderBox.

  • ParentDataType определяет тип данных для поля parentData, которое должно быть доступно каждому дочернему объекту. В нашем случае тип поля parentData для дочерних RenderObject будет MasonryParentData.

Flutter требует, чтобы каждый RenderBox содержал поле parentData — именно в нём записываются результаты позиционирования. Родительские RenderObject размещают дочерние RenderObject исходя из полученных измерений и информации, сохранённой в parentData.

Класс MasonryParentData:

import 'package:flutter/rendering.dart';

class MasonryParentData extends ContainerBoxParentData<RenderBox> {}

Здесь нам достаточно, чтобы MasonryParentData просто наследовал от ContainerBoxParentData<RenderBox>.

Поскольку для удобства мы добавили собственный тип данных для parentData —  MasonryParentData, то в MasonryGridRenderObject мы должны переопределить метод setupParentData.

@override
void setupParentData(RenderBox child) {
  if (child.parentData is! MasonryParentData) {
    child.parentData = MasonryParentData();
  }
}

Это позволяет указать тип parentData для дочерних RenderBox. Метод вызывается для дочернего RenderBox перед тем, как он добавится в список дочерних.

Визуально это можно представить так:

-  MasonryGridRenderObject 

-  childRenderBox 

-  parentData  -  MasonryParentData(offset: Offset(...)) 

-   childRenderBox 

-  parentData  -  MasonryParentData(offset: Offset(...)) 

-  ... 

Без примеси ContainerRenderObjectMixin нам пришлось бы самостоятельно организовывать хранение списка детей и реализовывать логику по их связке.

RenderBoxContainerDefaultsMixin

Примесь RenderBoxContainerDefaultsMixin даёт доступ к ряду полезных методов для RenderBox с дочерними объектами, которые управляются примесью ContainerRenderObjectMixin .

Она добавляет базовые реализации типичных операций для RenderBox с множеством потомков:

  • defaultPaint() — стандартная отрисовка всех дочерних RenderBox;

  • defaultHitTestChildren() — стандартная проверка на касание;

  • visitChildren() — обход всех дочерних RenderBox.

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

Конструктор MasonryGridRenderObject, приватные свойства, getters, setters

Во Flutter принято объявлять свойства пользовательских RenderObject приватными, что мы, собственно, и сделали.

Код MasonryGridRenderObject на текущий момент:

class MasonryGridRenderObject extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, MasonryParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, MasonryParentData> {
  MasonryGridRenderObject({required int columns, required double spacing})
    : _columns = columns,
      _spacing = spacing;

  int _columns;
  set columns(int value) {
    // Guard sentence for equality check.
    if (_columns == value) return;

    _columns = value;
  }

  double _spacing;
  set spacing(double value) {
    // Guard sentence for equality check.
    if (_spacing == value) return;

    _spacing = value;
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! MasonryParentData) {
      child.parentData = MasonryParentData();
    }
  }

  // The rest code...
}

Поскольку RenderObject — mutable-объекты, мы можем изменять значения их свойств.

Во Flutter при работе с RenderObject для этого есть особое соглашение.

Для каждого свойства класса RenderObject мы должны написать getter и setter:

  • для getter мы просто возвращаем значение из соответствующего приватного поля;

  • для setter мы должны проверить, отличаются ли текущее и присваиваемое значения друг от друга и присваивать новое значение только в случае, если эти отличия есть.

Именно через эти setters и происходит обновление значений свойств MasonryGridRenderObject при вызове метода updateRenderObject в MasonryGridRenderObjectWidget.

Теперь перейдём к реализации основных методов, которые обеспечивают выполнение ключевых задач RenderObject — мы говорили о них в начале статьи.

Реализация performLayout

Именно в методе performLayout определяется логика определения размеров и позиционирования дочерних элементов.

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

   @override
void performLayout() {
  // Based on the constraints setted by the parent RenderObject
  // and the configuration accepted from the RenderObjectWidget
  // calculate width of each column.
  final totalWidth = constraints.maxWidth;
  final columnsCount = _columns;
  final columnsSpacing = _spacing;
  final columnWidth =
      (totalWidth - (columnsCount - 1) * columnsSpacing) / columnsCount;

  // Init the list of columns heights.
  final columnHeights = List<double>.filled(columnsCount, 0.0);

  // For each child RenderObject calculate its size and offset (perform layout and positioning).
  RenderBox? child = firstChild;
  while (child != null) {
    // Access parentData for this child RenderBox.
    final MasonryParentData parentData =
        child.parentData as MasonryParentData;

    // Call child RenderBox layout method.
    // Pass down the constraints from the parent (this RenderBox).
    child.layout(
      BoxConstraints(maxWidth: columnWidth, minWidth: columnWidth),
      parentUsesSize: true,
    );

    // Find the index of column with the minimum height.
    int minHeightColumnIndex = 0;
    for (int i = 1; i < columnsCount; i++) {
      if (columnHeights[i] < columnHeights[minHeightColumnIndex]) {
        minHeightColumnIndex = i;
      }
    }

    // Calculate the offset for the child RenderBox.
    final dx = minHeightColumnIndex * (columnWidth + columnsSpacing);
    final dy =
        columnHeights[minHeightColumnIndex] == 0.0
            ? 0.0
            : columnHeights[minHeightColumnIndex] + columnsSpacing;

    // Set the offset for this child RenderBox in its parentData field.
    parentData.offset = Offset(dx, dy);

    // Update the column height.
    columnHeights[minHeightColumnIndex] = dy + child.size.height;

    // Do the same stuff for the next child.
    child = parentData.nextSibling;
  }

  /// Lastly we must initialise the size property for the MasonryGridRenderObject.
  /// It will take all the available width and the maximum of the colums height.
  final gridHeight = columnHeights.reduce((a, b) => a > b ? a : b);
  size = constraints.constrain(Size(totalWidth, gridHeight));
}

Обозначим ключевые моменты.

MasonryGridRenderObject получает ограничения constraints от своего родителя.

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

Для этого мы определяем ряд вспомогательных переменных columnsCount, columnsSpacing, columnWidth, columnHeights.

После этого инициируем в цикле перебор списка дочерних RenderBox, чтобы выполнить для них layout и позиционирование. Здесь примечателен вызов:

// Call child RenderBox layout method.
// Pass down the constraints from the parent (this RenderBox).
child.layout(
  BoxConstraints(maxWidth: columnWidth, minWidth: columnWidth),
  parentUsesSize: true,
);

Метод layout — основной способ заставить дочерний RenderBox рассчитать свой размер size с учётом переданных ограничений constraints. Помните, constraints go down? Этот метод нужно вызывать для каждого ребёнка во время performLayout().

Параметр parentUsesSize: true сообщает Flutter, что именно родительский RenderBox будет использовать размеры дочерних в своих расчётах и будет нести ответственность за их позиционирование.

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

В цикле для каждого потомка мы получаем доступ к свойству parentData и инициализируем хранимое в нём значение offset. Оно отвечает за конкретную позицию этого RenderBox на экране.

// Set the offset for this child RenderBox in its parentData field.
parentData.offset = Offset(dx, dy);

После завершения работы цикла и вычисления размеров и позиционирования всех потомков,  MasonryGridRenderObject  должен определить собственное значение sizes. В нашем примере оно зависит от размеров дочерних RenderBox.

/// Lastly we must initialise the size property for the MasonryGridRenderObject.
/// It will take all the available width and the maximum of the colums height.
final gridHeight = columnHeights.reduce((a, b) => a > b ? a : b);
size = constraints.constrain(Size(totalWidth, gridHeight));

Итак, в performLayout мы определились с размерами и координатами для нашего MasonryGridRenderObject и его потомков. Теперь пойдём «рисовать».

Реализация paint

В нашем случае реализация paint будет лаконичной:

@override
void paint(PaintingContext context, Offset offset) {
  // Nothing fancy in our case, just call defaultPaint to paint each child RenderBox.
  defaultPaint(context, offset);
}

Здесь мы вызываем defaultPaint, доступ к которому обеспечивается примесью RenderBoxContainerDefaultsMixin и выполняем их отрисовку.

Заглянем в исходный код defaultPaint:

void defaultPaint(PaintingContext context, Offset offset) {
  ChildType? child = firstChild;
  while (child != null) {
    final ParentDataType childParentData = child.parentData! as ParentDataType;
    context.paintChild(child, childParentData.offset + offset);
    child = childParentData.nextSibling;
  }
}

Видим, что метод defaultPaint просто обходит список всех дочерних RenderBox и вызывает для каждого из них paintChild.

Отметим также, что при реализации других RenderObjectWidget, например, наследующих от LeafRenderObjectWidget, нам придётся напрямую взаимодействовать с полем canvas параметра PaintingContext context. Это нужно, чтобы изобразить фактическое представление для листа дерева. А впрочем, это уже совершенно другая история.

А пока перейдём реализации hit testing.

Реализация hitTestChildren

И опять реализация будет простой.

@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
  // Delegate hit testing logic to the child RenderBoxes.
  return defaultHitTestChildren(result, position: position);
}

Мы переопределили метод hitTestChildren у RenderBox и делегировали логику проверки нажатия на RenderBox потомка в метод defaultHitTestChildren. Он возвращает true, если касание пальцем или нажатие мышью попало хотя бы в одно из них.

Если бы мы не переопределили hitTestChildren, то нажатие на карточку в галерее не попало бы в обработку самой карточки (она может содержать GestureDetector, InkWell, и другие), а было бы перехвачено MasonryGridRenderObject.

Последняя важная задача RenderObject — определение семантики, чтобы обеспечить работоспособность средств доступности содержимого.

Реализация describeSemanticsConfiguration

Реализация метода describeSemanticsConfiguration поможет сделать нашу галерею доступной для тех, кто использует экранных дикторов — например, VoiceOver на iOS или TalkBack на Android.

Метод describeSemanticsConfiguration настраивает смысловое описание вашего рендер-объекта, что он «значит» для системы доступности.

Мы можем указать:

  • роль объекта. Например, isButton, isImage, isHeader;

  • доступный текст label, value, hint;

  • действия onTap, onScrollUp, onIncrase.

Или же отключить семантику совсем.

@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
  super.describeSemanticsConfiguration(config);
  // Setting isSemanticBoundary will mark this RenderBox as a SemanticsNode.
  // That will allow screen readers to extract the config semantic information about it.
  config.isSemanticBoundary = true;
  config.label = 'Mansory Grid';
  config.textDirection = TextDirection.ltr;
}

Вот мы и закончили с реализацией MasonryGridRenderObject и можем увидеть финальный результат.

Перед этим взглянем на полный код MasonryGridRenderObject:

import 'package:flutter/rendering.dart';
import 'package:masonry_grid_render_object/masonry_grid/masonry_parent_data.dart';

class MasonryGridRenderObject extends RenderBox
    with
        ContainerRenderObjectMixin<RenderBox, MasonryParentData>,
        RenderBoxContainerDefaultsMixin<RenderBox, MasonryParentData> {
  MasonryGridRenderObject({required int columns, required double spacing})
    : _columns = columns,
      _spacing = spacing;

  int _columns;
  set columns(int value) {
    // Guard sentence for equality check.
    if (_columns == value) return;

    _columns = value;
  }

  double _spacing;
  set spacing(double value) {
    // Guard sentence for equality check.
    if (_spacing == value) return;

    _spacing = value;
  }

  @override
  void setupParentData(RenderBox child) {
    if (child.parentData is! MasonryParentData) {
      child.parentData = MasonryParentData();
    }
  }

  @override
  void performLayout() {
    // Based on the constraints setted by the parent RenderObject
    // and the configuration accepted from the RenderObjectWidget
    // calculate width of each column.
    final totalWidth = constraints.maxWidth;
    final columnsCount = _columns;
    final columnsSpacing = _spacing;
    final columnWidth =
        (totalWidth - (columnsCount - 1) * columnsSpacing) / columnsCount;

    // Init the list of columns heights.
    final columnHeights = List<double>.filled(columnsCount, 0.0);

    // For each child RenderObject calculate its size and offset (perform layout and positioning).
    RenderBox? child = firstChild;
    while (child != null) {
      // Access parentData for this child RenderBox.
      final MasonryParentData parentData =
          child.parentData as MasonryParentData;

      // Call child RenderBox layout method.
      // Pass down the constraints from the parent (this RenderBox).
      child.layout(
        BoxConstraints(maxWidth: columnWidth, minWidth: columnWidth),
        parentUsesSize: true,
      );

      // Find the index of column with the minimum height.
      int minHeightColumnIndex = 0;
      for (int i = 1; i < columnsCount; i++) {
        if (columnHeights[i] < columnHeights[minHeightColumnIndex]) {
          minHeightColumnIndex = i;
        }
      }

      // Calculate the offset for the child RenderBox.
      final dx = minHeightColumnIndex * (columnWidth + columnsSpacing);
      final dy =
          columnHeights[minHeightColumnIndex] == 0.0
              ? 0.0
              : columnHeights[minHeightColumnIndex] + columnsSpacing;

      // Set the offset for this child RenderBox in its parentData field.
      parentData.offset = Offset(dx, dy);

      // Update the column height.
      columnHeights[minHeightColumnIndex] = dy + child.size.height;

      // Do the same stuff for the next child.
      child = parentData.nextSibling;
    }

    /// Lastly we must initialise the size property for the MasonryGridRenderObject.
    /// It will take all the available width and the maximum of the colums height.
    final gridHeight = columnHeights.reduce((a, b) => a > b ? a : b);
    size = constraints.constrain(Size(totalWidth, gridHeight));
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    // Nothing fancy in our case, just call defaultPaint to paint each child RenderBox.
    defaultPaint(context, offset);
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    // Delegate hit testing logic to the child RenderBoxes.
    return defaultHitTestChildren(result, position: position);
  }

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    // Setting isSemanticBoundary will mark this RenderBox as a SemanticsNode.
    // That will allow screen readers to extract the config semantic information about it.
    config.isSemanticBoundary = true;
    config.label = 'Mansory Grid';
    config.textDirection = TextDirection.ltr;
  }
}

Отобразим нашу галерею

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

Создадим StatefulWidget виджет HomeScreen, который будет хранить текущие настройки для MasonryGridRenderObjectWidget.

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

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final int _tilesCount = 30;
  late int mansoryGridColumnsCount;
  late int spacing;
  late List<MasonryTileItem> tiles;

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

    mansoryGridColumnsCount = 3;
    spacing = 8;
    tiles = initTiles();
  }

  List<MasonryTileItem> initTiles() {
    return List.generate(
      _tilesCount,
      (i) => MasonryTileItem(
        color:
            Colors.primaries[Random().nextInt(_tilesCount) %
                Colors.primaries.length],
        height: 100.0 + Random().nextInt(150),
      ),
    );
  }

  void handleUpdateGalleryPressed() {
    setState(() {
      tiles = initTiles();
    });
  }

  void handleColumnsCountChanged(int columnCount) {
    setState(() {
      mansoryGridColumnsCount = columnCount;
    });
  }

  void handleSpacingChanged(int spacingAmount) {
    setState(() {
      spacing = spacingAmount;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text('Masonry Grid'),
      ),
      body: Column(
        children: [
          TextButton(
            onPressed: handleUpdateGalleryPressed,
            child: Text('Обновить галерею'),
          ),
          Padding(
            padding: EdgeInsets.all(8),
            child: StepSliderExample(
              label: 'Количество колонок',
              value: mansoryGridColumnsCount,
              onChanged: handleColumnsCountChanged,
            ),
          ),
          Padding(
            padding: EdgeInsets.all(8),
            child: StepSliderExample(
              label: 'Расстояние между элементами',
              value: spacing,
              min: 0,
              max: 16,
              onChanged: handleSpacingChanged,
            ),
          ),
          Expanded(
            child: SingleChildScrollView(
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: MasonryGridRenderObjectWidget(
                  columnsCount: mansoryGridColumnsCount,
                  spacing: spacing.toDouble(),
                  children:
                      tiles
                          .map(
                            (tile) => MasonryGridTile(
                              backgroundColor: tile.color,
                              height: tile.height,
                            ),
                          )
                          .toList(),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
  1. Свойство mansoryGridColumnsCount определяет количество колонок в галерее.

  2. Свойство spacing — размер свободного пространства между элементами галереи.

  3. Свойство tiles хранит список карточек, которые будут отображаться в галерее. Каждая карточка описана простым классом MasonryTileItem полями color и height.

  4. Метод initTiles создаёт список карточек MasonryTileItem со случайными цветами и высотой.

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

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

Попробуем запустить и изменить параметры.

Галерея отобразилась и карточки расположены ожидаемым образом. Получилась неплохая кирпичная кладка ?

Но есть важный момент: галерея почему-то не обновляет содержимое при изменении ползунков с настройками количества колонок и величиной отступов. В чём же дело?

RenderObject setters и markNeeds* методы

Причина этого в том, что Flutter самостоятельно вызывает ключевые методы RenderObject только в момент создания RenderObject. А дальше ответственность за их вызов полностью лежит на разработчике. Мы должны самостоятельно определять, когда и какой метод необходимо вызвать повторно.

У ключевых методов RenderObject есть соответствующие markNeeds* методы:

  • markNeedsLayout — performLayout;

  • markNeedsPaint — paint;

  • markNeedsSemanticsUpdate — describeSemanticsConfiguration.

Вызов markNeeds* метода приведёт к вызову соответствующего ему метода RenderObject.

Мы самостоятельно вызываем markNeeds* методы, когда произошло что-то, что ��ребует обновления соответствующей характеристики RenderObject. А мы уже знаем, что RenderObject получает обновлённую конфигурацию от своего RenderObjectWidget в своих же setters.

Добавим вызов markNeeds* в setters нашего MasonryGridRenderObject.

int _columns;
set columns(int value) {
  // Guard sentence for equality check.
  if (_columns == value) return;

  _columns = value;

  // Here is! Columns count changed, we definitely need to perform new layout.
  markNeedsLayout();
}

double _spacing;
set spacing(double value) {
  // Guard sentence for equality check.
  if (_spacing == value) return;

  _spacing = value;

  // The same here!
  markNeedsLayout();
}

Отметим, что метод markNeedsLayout включает в себя вызов markNeedsPaint. В общем, бесмысленно вызывать их одновременно.

Попробуем «поиграть» с ползунками.

Конечно, SingleChildScrollView в данном случае — не самое оптимальное решение. Реализация собственного Sliver, который обеспечивает производительную работу такой галереи — отличная тема для отдельной статьи.

Теперь всё работает как надо, можем любоваться нашей галереей.

Что в итоге

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

Изучили основы системы рендеринга:

  • поняли, как в этом контексте связаны между собой основные деревья Flutter;

  • познакомились с RenderObjectWidget;

  • узнали о деталях устройства RenderObject — как размещаются и отрисовываются дочерние элементы, как обрабатываются касания.

Реализовали простую версию Masonry Grid галереи, которую невозможно корректно построить без контроля на уровне RenderObject. Конечно, наш пример чисто учебный и создан, чтобы познакомить вас с основными концепциями и принципами устройства мира RenderObject. 

Понимание RenderObject помогает лучше разобраться в том, как устроен Flutter. Даже если вы не собираетесь создавать свои RenderObject, знание этой части фреймворка сделает вас увереннее в работе с интерфейсами и поможет быстрее находить и исправлять ошибки. В общем, полезно знать, чтобы разрабатывать круче и проще.

Комментарии (0)