В этом параграфе мы обсудим внутреннюю организацию RenderObject — механизма, который добавляет визуальное представление и оживляет наше приложение, создавая из пикселей стилизацию в Material Design или Human Interface Guidelines.

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

Но прежде чем уходить в детали, давайте в общих чертах вспомним, как Flutter собирает интерфейс.

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

Вы уже знакомы со «сборочным конвейером» — способами описания конфигурации и композиции деталей (дерево виджетов), определением их структуры и взаимоотношений в итоговом продукте (дерево элементов, доступное через BuildContext). А с «фабрикой» — пока нет.

Как вы наверняка догадались, RenderObject и есть та самая «фабрика». Этот механизм реализует физику и визуальное представление элементов, их поведение при взаимодействии с окружающей средой, определение ограничений их размеров (чтобы они поместились внутрь более крупных объектов) и многое другое.

Разберём его подробнее.

Что делает RenderObject

RenderObject находится наиболее близко к Flutter Engine и непосредственно использует значительную часть Bindings (RendererBinding, SchedulerBinding, GestureBinding, PaintingBinding, SemanticsBinding), которые отвечают за доступ к низкоуровневой реализации взаимодействия с графической подсистемой платформы, планировщиком кадров, детектором событий взаимодействия с устройствами ввода или экраном, а также за передачу в операционную систему семантической информации о положении и назначении визуальных элементов на экране.

В зоне ответственности RenderObject находятся такие действия, как:

  • Создание визуального представления на предоставленном контексте для рисования (фаза paint). Например, через графические библиотеки Skia/Impeller на экране мобильного телефона. RenderObject использует множество оптимизаций и может переиспользовать ранее полученное изображение, сохранённое в растровом кэше (подробнее в параграфе про Bindings).
  • Размещение дочерних RenderObject (фаза layout). Например, в случае расположения нескольких объектов под управлением родительского в виджетах Flex/Stack и других.
  • Определение собственного размера (метод performLayout). При измерении используются ограничения от родителя, доступные в аргументе constraints, плюс могут также учитываться размеры дочерних объектов. После измерения размер может быть извлечён из свойства size.
  • Реакция на события. Например, прикосновения к экрану или перемещения курсора мыши. Исходное событие обрабатывается в hitTest и создаёт список событий, который обрабатывается в handleEvents.
  • Передача информации о содержании и возможных операциях над RenderObject (describeSemanticsConfiguration) для использования с альтернативными устройствами ввода-вывода.
  • Управление деревом RenderObject — связь с родительским объектом ссылкой через parent, передача информации в родительский объект через parentData, добавление и удаление новых дочерних объектов.
  • Регистрация диагностической информации о свойствах объекта для отображения в DevTools через переопределение метода debugFillProperties.
  • Предоставление доступа к растровому изображению дерева виджетов. Например, это можно использовать для применения пиксельных фильтров к визуальному представлению виджета или создания скриншотов. Реализация методов растеризации представлена в RenderRepaintBoundary.

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

flt

  • RenderBox — ориентирован на работу с плоскими двумерными поверхностями (например, в области для рисования на экране мобильного устройства или окна приложения) и двумерную компоновку объектов на экране. Этот класс реализует модель иерархического размещения, где объекты-контейнеры позиционируют внутри себя дочерние объекты с использованием информации об их размерах и об ограничениях от родительских объектов.
  • RenderSliver — использует модель одномерного размещения в прокручиваемых по горизонтали или вертикали объектах. Применяется для создания элементов списков, зависящих от положения прокрутки. Например, для заголовков, изменяющихся в процессе прокрутки, или заголовков, которые присоединяются к верхнему краю и остаются неподвижными при дальнейшей прокрутке.
  • RenderView — основной контейнер для размещения всех остальных RenderObject, в большинстве случаев соответствует экрану телефона или окну приложения для Web/Desktop. Доступ к RenderView из любого RenderObject может быть получен через обращение к свойству pipelineOwner.rootNode (более подробно про pipelineOwner можно прочитать в параграфе про Bindings). RenderView создаётся в методе initRenderView в RendererBinding на основе конфигурации экрана и указателя на платформенную поверхность для рисования.
  • Вы можете создать собственный RenderObject, использующий другую модель ограничений, например для позиционирования в полярной системе координат или для размещения виджетов в трёхмерном пространстве.

Ниже мы последовательно разберёмся с каждым аспектом RenderObject и покажем, как их использовать для решения реальных задач. Все классы, которые понадобятся нам в примерах, могут быть импортированы через import 'package:flutter/rendering.dart'.

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

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

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

Для минимально возможного определения такого RenderObject нам нужно будет решить следующие задачи:

  1. Определить размер RenderBox. Для простоты размер в первом примере будет фиксированный, впоследствии добавим поддержку внешних ограничений.
  2. Нарисовать аналоговые часы для указанного времени, предусмотреть возможность изменения значения времени через конфигурацию связанного виджета.

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

RenderObject создаётся при добавлении RenderObjectElement в дерево элементов в методе виджета createRenderObject. Созданный RenderObject присоединяется к родительскому объекту, который обнаруживается при поиске RenderObjectElement по дереву элементов вверх. Изменение конфигурации виджета приводит к вызову метода updateRenderObject, в котором реализуется обновление свойств RenderObject для соответствия новой конфигурации.

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

Так, например, для дерева виджетов с изображения ниже создаётся соответствующее дерево элементов, которое преобразуется в дерево RenderObject. Обратите внимание, что изначальное дерево виджетов было меньше: Image — это StatefulWidget и раскрывается в дополнительные виджеты Semantics и RawImage после вызова метода build.

Кроме того, дерево RenderObject содержит меньше объектов, чем дерево элементов (поскольку не все элементы имеют визуальное представление).

flt

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

Также в родительском объекте сохраняется информация от дочерних объектов через свойство parentData, которое первоначально инициализируется при присоединении объекта в дерево в методе setupParentData.

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

Для определения последовательности операций отрисовки RenderBox переопределим реализацию двух методов — performLayout для определения размера RenderObject и paint для создания визуального представления. Метод paint принимает два аргумента — PaintingContext и offset.

PaintingContext обеспечивает возможность создания многослойного изображения с применением визуальных эффектов и кэширования и предоставляет canvas для создания изображения. Более подробно про Canvas можно почитать в этом параграфе.

Offset определяет смещение в пространстве экрана, на которое дополнительно могут накладываться трансформации, применённые ранее. Значение смещения определяется родительским объектом при вызове метода paintChild из PaintingContext.

1        import 'package:flutter/material.dart';
2        import 'dart:math';
3
4        void main() {
5          runApp(MyApp());
6        }
7
8        class MyApp extends StatelessWidget {
9          @override
10          Widget build(BuildContext context) {
11            return MaterialApp(
12              theme: ThemeData.dark(),
13              debugShowCheckedModeBanner: false,
14              home: const Scaffold(
15                body: Center(
16                  child: MyClockApp(),
17                ),
18              ),
19            );
20          }
21        }
22
23        class ClockRenderBox extends RenderBox {
24          final Size _ownSize;  //размер области отрисовки
25          final Offset _offset; //дополнительное смещение
26          final double _hour;   //значение часов (в 12-ти часовом формате)
27          final double _minute; //значение минут (0-59)
28
29          ClockRenderBox(
30            this._ownSize,
31            this._offset,
32            this._hour,
33            this._minute,
34          );
35
36          @override
37          void performLayout() => size = _ownSize;
38
39          @override
40          void paint(PaintingContext context, Offset offset) {
41            final center = _ownSize.center(offset);
42            final radius = _ownSize.shortestSide / 2;
43            final hourToRads = _hour / 12 * 2 * pi;
44            final minsToRads = _minute / 60 * 2 * pi;
45            final paintHours = Paint()
46              ..style = PaintingStyle.fill
47              ..strokeWidth = 5
48              ..color = Colors.white;
49            final paintMins = Paint()
50              ..style = PaintingStyle.fill
51              ..strokeWidth = 2
52              ..color = Colors.grey;
53            context.canvas.drawLine(
54              _offset + center,
55              _offset +
56                  center +
57                  Offset(
58                    radius / 2 * cos(pi / 2 - hourToRads),
59                    -radius / 2 * sin(pi / 2 - hourToRads),
60                  ),
61              paintHours,
62            );
63            context.canvas.drawLine(
64              _offset + center,
65              _offset +
66                  center +
67                  Offset(
68                    radius * cos(pi / 2 - minsToRads),
69                    -radius * sin(pi / 2 - minsToRads),
70                  ),
71              paintMins,
72            );
73          }
74        }
75
76        class MyClockApp extends StatelessWidget {
77          const MyClockApp({super.key});
78
79          @override
80          Widget build(BuildContext context) {
81            return WidgetToRenderBoxAdapter(
82              renderBox: ClockRenderBox(
83                const Size.square(256),
84                const Offset(64, 64),
85                13.0,
86                39.0,
87              ),
88            );
89          }
90        }

Эта реализация работает корректно, но после создания мы не будем иметь возможности внести изменения в свойства отображаемого объекта. Для любого RenderObject можно вручную выполнять управление его дочерними объектами, и мы можем только пересоздать новый экземпляр объекта и заменить его через dropChild/adoptChild.

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

Давайте посмотрим, какие шаги выполняются фреймворком для каждого RenderObject при первом запуске. Для этого изучим исходный код метода drawFrame в RendererBinding (комментарии автора кода):

1pipelineOwner.flushLayout();
2pipelineOwner.flushCompositingBits();
3pipelineOwner.flushPaint();
4if (sendFramesToEngine) {
5  renderView.compositeFrame(); // this sends the bits to the GPU
6  pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
7  _firstFrameSent = true;
8}
  • На первом этапе (flushLayout) все RenderObject измеряют вложенные объекты в соответствии с полученными от них размерами — или используя другой алгоритм, который не опирается на размеры, как, например, в Stack.
  • Второй этап (compositingBits) объединяет кэшированные изображения в пределах композиционного слоя, который создаётся в ближайшем RenderRepaintBoundary. Возможность переиспользования растрового изображения возникает достаточно часто, когда изменяется положение объекта, но не его содержание, или объект не изменяется.
  • На третьем этапе (paint) создаются новые изображения для всех RenderObject, которые запросили перерисовку. Этот шаг может быть пропущен, если кэшированная версия по-прежнему актуальна.
  • На четвертом этапе (compositeFrame) полученная сцена из кэшированных и отрисованных слоёв отправляется в Flutter Engine и преобразуется в растровое изображение на экране.
  • Ну и на последнем этапе (flushSemantics) в операционную систему отправляется информация о расположении объектов на экране и их назначении (подсказки для голосового помощника, возможные действия над объектами и так далее).

Объект pipelineOwner создается фреймворком и отвечает за взаимодействие с деревом RenderObject. Он обеспечивает реализацию жизненного цикла RenderObject и сохраняет список объектов, которые были модифицированы с момента последнего вызова drawFrame (то есть с предыдущего кадра).

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

  • markNeedsLayout — добавляет объект в ожидающие переразметки. Используется, например, при изменении свойств, влияющих на относительное положение объекта или на измерение размера RenderObject, например при добавлении отступов. На следующем drawFrame будет вызван метод performLayout, если для RenderObject свойство sizedByParent принимает значение false. Обратите внимание, что в случае изменения размера и наличия родительского контейнера, который этот размер использует, также нужно вызвать метод markParentNeedsLayout, о нём ниже.
  • markNeedsCompositingBitsUpdate — помечает объект как требующий перекомпоновки составных частей. Чаще всего необходимо вызывать в случае, когда для вложенного объекта ожидается изменение визуального представления или применяется какой-либо эффект.
  • markNeedsPaint — добавляет объект в список требующих обновления визуального представления (на следующем drawFrame будет вызван метод paint).
  • markNeedsSemanticUpdate — вызывается при необходимости сообщения новой семантической информации. Например, при изменении надписи на кнопке нужно об этом уведомить операционную систему.
  • markParentNeedsLayout — сообщение родительскому объекту об изменении размера от дочернего объекта. Например, если родительский объект выполняет относительное позиционирование вложенных объектов — как RenderFlex, который соответствует виджетам Column/Row/Flex.
  • markNeedsLayoutForSizedByParentChange — вызывается в случае изменения значения флага sizedByParent, при этом объект измеряет себя самостоятельно или размер ему сообщает родительский объект.
  • reassemble — метод вызывает первые четыре метода из этого списка для себя и всего поддерева RenderObject, может быть использован при необходимости форсированного обновления части дерева целиком.

Для нашего случая необходимо при изменении любого свойства вызывать метод markNeedsPaint(), поскольку и значение времени, и положение/размер стрелок влияют на растровое отображение часов. При изменении размера также вызовем метод markNeedsLayout() для обновления сохранённого в поле size размера.

Для отслеживания изменения значений в конфигурации виджета во фреймворке чаще всего используется следующий подход:

  • виджет с конфигурацией часов наследуется от базового класса LeafRenderObjectWidget;
  • в методе createRenderObject создаётся соответствующий RenderObject для отображения часов;
  • в RenderObject создаются set-методы для изменения значений свойств и вызова необходимых методов markNeeds* (в качестве побочного эффекта). Установка пометки необходимости обновления имеет смысл, только если значение действительно изменилось;
  • в методе updateRenderObject передаются изменения значений из конфигурации виджета в RenderObject.

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

Относительно измерения размера существует две тактики для RenderObject:

  • sizedByParent возвращает true, в этом случае размер может быть получен из свойства size в RenderBox (при изменении также вызывается performResize);
  • sizedByParent возвращает false, размер определяется виджетом самостоятельно в методе computeDryLayout с использованием ограничений от родителя и сохраняется в size внутри обязательно реализованного метода performLayout . Это значение возвращается по умолчанию.

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

Вот как это будет выглядеть в коде:

1        import 'package:flutter/material.dart';
2        import 'dart:math';
3
4        class Clock extends LeafRenderObjectWidget {
5          final Size size;
6          final Offset offset;
7          final double hour;
8          final double minute;
9
10          const Clock({
11            required this.size,
12            required this.offset,
13            required this.hour,
14            required this.minute,
15            super.key,
16          });
17
18          @override
19          RenderObject createRenderObject(BuildContext context) =>
20              ClockRenderBox(size, offset, hour, minute);
21
22          @override
23          void updateRenderObject(
24              BuildContext context, covariant RenderObject renderObject) {
25            final clockRenderObject = renderObject as ClockRenderBox;
26            clockRenderObject
27              ..ownSize = size
28              ..offset = offset
29              ..hour = hour
30              ..minute = minute;
31          }
32        }
33
34        class ClockRenderBox extends RenderBox {
35          Size _size;
36          Offset _offset;
37          double _hour;
38          double _minute;
39
40          ClockRenderBox(
41            this._size,
42            this._offset,
43            this._hour,
44            this._minute,
45          );
46
47          @override
48          get sizedByParent => false;
49
50          @override
51          void performLayout() => size = _size;
52
53          set ownSize(Size newSize) {
54            if (newSize != _size) {
55              _size = newSize;
56              markNeedsPaint();
57              markNeedsLayout();
58            }
59          }
60
61          set offset(Offset offset) {
62            if (offset != _offset) {
63              _offset = offset;
64              markNeedsPaint();
65            }
66          }
67
68          set hour(double hour) {
69            if (hour != _hour) {
70              _hour = hour;
71              markNeedsPaint();
72              markNeedsSemanticsUpdate();
73            }
74          }
75
76          set minute(double minute) {
77            if (minute != _minute) {
78              _minute = minute;
79              markNeedsPaint();
80              markNeedsSemanticsUpdate();
81            }
82          }
83
84          @override
85          void paint(PaintingContext context, Offset offset) {
86            final center = size.center(offset + _offset);
87            final radius = size.shortestSide / 2;
88            final hourToRads = _hour / 12 * 2 * pi;
89            final minsToRads = _minute / 60 * 2 * pi;
90            final paintHours = Paint()
91              ..style = PaintingStyle.fill
92              ..strokeWidth = 5
93              ..color = Colors.white;
94            final paintMins = Paint()
95              ..style = PaintingStyle.fill
96              ..strokeWidth = 2
97              ..color = Colors.grey;
98            context.canvas.drawLine(
99              center,
100              center +
101                  Offset(
102                    radius / 2 * cos(pi / 2 - hourToRads),
103                    -radius / 2 * sin(pi / 2 - hourToRads),
104                  ),
105              paintHours,
106            );
107            context.canvas.drawLine(
108              center,
109              center +
110                  Offset(
111                    radius * cos(pi / 2 - minsToRads),
112                    -radius * sin(pi / 2 - minsToRads),
113                  ),
114              paintMins,
115            );
116          }
117        }
118
119        class ClockData {
120          Offset offset = Offset.zero;
121          Size size = const Size.square(128);
122          double hour = 0;
123          double minute = 0;
124        }
125
126        class MyClockApp extends StatefulWidget {
127          const MyClockApp({super.key});
128
129          @override
130          State<MyClockApp> createState() => _MyClockAppState();
131        }
132
133        class _MyClockAppState extends State<MyClockApp> {
134          final clockData = ClockData();
135
136          @override
137          Widget build(BuildContext context) {
138            return MaterialApp(
139              theme: ThemeData.dark(useMaterial3: false),
140              home: Scaffold(
141                body: SafeArea(
142                  child:
143                      Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
144                    ElevatedButton(
145                      onPressed: () =>
146                          setState(() => clockData.offset += const Offset(1, 1)),
147                      child: const Text('Shift'),
148                    ),
149                    ElevatedButton(
150                      onPressed: () => setState(() => clockData.size *= 1.1),
151                      child: const Text('Resize'),
152                    ),
153                    ElevatedButton(
154                      onPressed: () => setState(() => clockData.hour++),
155                      child: const Text('Increment hour'),
156                    ),
157                    ElevatedButton(
158                      onPressed: () => setState(() => clockData.minute++),
159                      child: const Text('Increment min'),
160                    ),
161                    Clock(
162                      size: clockData.size,
163                      offset: clockData.offset,
164                      hour: clockData.hour,
165                      minute: clockData.minute,
166                    ),
167                  ]),
168                ),
169              ),
170            );
171          }
172        }
173
174        void main() {
175          runApp(const MyClockApp());
176        }

Теперь сделаем так, чтобы размер наших часов был не фиксированным, а зависел от размеров родителя.

Измерение, позиционирование и RenderBox-протокол

Изначально RenderObject не использует никакую систему координат и делегирует реализацию абстрактного измерения на дочерние объекты. Для определения размера RenderObject используется метод performLayout, который вызывается, если sizedByParent возвращает false.

В противном случае, если sizedByParent возвращает true, размер дочернего объекта определяется на этапе разметки родительского объекта. Результатом выполнения performLayout должно быть изменение поля size , в которое сохраняется расчётный размер RenderObject. Также на этом этапе могут быть переданы данные в родительский объект через свойство parentData для управления позиционированием дочерних объектов внутри родительского.

Для управления измерением и размещением мы будем использовать реализацию пустой абстракции Constraints.

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

  • строгим (граничные значения совпадают);
  • только сверху (минимальный размер — 0, 0);
  • только снизу (максимальный размер — бесконечность).

Напомним основные идеи протокола RenderBox:

  • Родительские виджеты (на самом деле связанные с ними RenderObject) передают ограничения к дочерним RenderObject.
  • Дочерние RenderObject измеряют себя и корректируют размер с учётом ограничений. При этом размер может остаться неизменным, уменьшиться при превышении верхнего ограничения или увеличиться, если от родителя пришла более высокая нижняя граница, чем ожидал объект.
  • Родительские RenderObject размещают дочерние RenderObject исходя из полученных измерений и информации, сохранённой в parentData. Например, позиционируют по центру относительно максимального ограничения, как делает виджет Center и связанный с ним RenderPositionedBox.

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

1  @override
2  Size computeDryLayout(BoxConstraints constraints) => constraints.constrain(_size);
3
4  @override
5  void performLayout() => size = constraints.constrain(_size);
6

В коде добавим LimitedBox для установки ограничений от родительского объекта и увидим, что масштабирование часов будет ограничено указанным размером.

1        import 'package:flutter/material.dart';
2        import 'dart:math';
3
4        class Clock extends LeafRenderObjectWidget {
5          final Size size;
6          final Offset offset;
7          final double hour;
8          final double minute;
9
10          const Clock({
11            required this.size,
12            required this.offset,
13            required this.hour,
14            required this.minute,
15            super.key,
16          });
17
18          @override
19          RenderObject createRenderObject(BuildContext context) =>
20              ClockRenderBox(size, offset, hour, minute);
21
22          @override
23          void updateRenderObject(
24              BuildContext context, covariant RenderObject renderObject) {
25            final clockRenderObject = renderObject as ClockRenderBox;
26            clockRenderObject
27              ..ownSize = size
28              ..offset = offset
29              ..hour = hour
30              ..minute = minute;
31          }
32        }
33
34        class ClockRenderBox extends RenderBox {
35          Size _size;
36          Offset _offset;
37          double _hour;
38          double _minute;
39
40          ClockRenderBox(
41            this._size,
42            this._offset,
43            this._hour,
44            this._minute,
45          );
46
47          @override
48          get sizedByParent => false;
49
50          @override
51          Size computeDryLayout(BoxConstraints constraints) =>
52              constraints.constrain(_size);
53
54          @override
55          void performLayout() => size = constraints.constrain(_size);
56
57          set ownSize(Size newSize) {
58            if (newSize != _size) {
59              _size = newSize;
60              markNeedsPaint();
61              markNeedsLayout();
62            }
63          }
64
65          set offset(Offset offset) {
66            if (offset != _offset) {
67              _offset = offset;
68              markNeedsPaint();
69            }
70          }
71
72          set hour(double hour) {
73            if (hour != _hour) {
74              _hour = hour;
75              markNeedsPaint();
76              markNeedsSemanticsUpdate();
77            }
78          }
79
80          set minute(double minute) {
81            if (minute != _minute) {
82              _minute = minute;
83              markNeedsPaint();
84              markNeedsSemanticsUpdate();
85            }
86          }
87
88          @override
89          void paint(PaintingContext context, Offset offset) {
90            final center = size.center(offset + _offset);
91            final radius = size.shortestSide / 2;
92            final hourToRads = _hour / 12 * 2 * pi;
93            final minsToRads = _minute / 60 * 2 * pi;
94            final paintHours = Paint()
95              ..style = PaintingStyle.fill
96              ..strokeWidth = 5
97              ..color = Colors.white;
98            final paintMins = Paint()
99              ..style = PaintingStyle.fill
100              ..strokeWidth = 2
101              ..color = Colors.grey;
102            context.canvas.drawLine(
103              center,
104              center +
105                  Offset(
106                    radius / 2 * cos(pi / 2 - hourToRads),
107                    -radius / 2 * sin(pi / 2 - hourToRads),
108                  ),
109              paintHours,
110            );
111            context.canvas.drawLine(
112              center,
113              center +
114                  Offset(
115                    radius * cos(pi / 2 - minsToRads),
116                    -radius * sin(pi / 2 - minsToRads),
117                  ),
118              paintMins,
119            );
120          }
121        }
122
123        class ClockData {
124          Offset offset = Offset.zero;
125          Size size = const Size.square(128);
126          double hour = 0;
127          double minute = 0;
128        }
129
130        class MyClockApp extends StatefulWidget {
131          const MyClockApp({super.key});
132
133          @override
134          State<MyClockApp> createState() => _MyClockAppState();
135        }
136
137        class _MyClockAppState extends State<MyClockApp> {
138          final clockData = ClockData();
139
140          @override
141          Widget build(BuildContext context) {
142            return MaterialApp(
143              theme: ThemeData.dark(useMaterial3: false),
144              home: Scaffold(
145                body: SafeArea(
146                  child:
147                      Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
148                    ElevatedButton(
149                      onPressed: () =>
150                          setState(() => clockData.offset += const Offset(1, 1)),
151                      child: const Text('Shift'),
152                    ),
153                    ElevatedButton(
154                      onPressed: () => setState(() => clockData.size *= 1.1),
155                      child: const Text('Resize'),
156                    ),
157                    ElevatedButton(
158                      onPressed: () => setState(() => clockData.hour++),
159                      child: const Text('Increment hour'),
160                    ),
161                    ElevatedButton(
162                      onPressed: () => setState(() => clockData.minute++),
163                      child: const Text('Increment min'),
164                    ),
165                    //добавили constraints, ограничивающие изменение размера до квадрата со стороной 200
166                    LimitedBox(
167                      maxWidth: 200,
168                      maxHeight: 200,
169                      child: Clock(
170                        size: clockData.size,
171                        offset: clockData.offset,
172                        hour: clockData.hour,
173                        minute: clockData.minute,
174                      ),
175                    ),
176                  ]),
177                ),
178              ),
179            );
180          }
181        }
182
183        void main() {
184          runApp(const MyClockApp());
185        }

Модель Constraints допускает создание альтернативной системы ограничений, которая отличается от размещения двумерных объектов в пространстве экрана, и ниже мы рассмотрим пример с SliverConstraints для позиционирования объектов в прокручиваемых списках.

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

Реакция на действия пользователя

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

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

Обработка начинается с вершины дерева, в котором расположен RenderView, занимающий доступное пространство экрана для приложения, при этом каждый RenderObject передаёт сообщение своим дочерним объектам, логика которых реализуется в методе hitTestChildren.

Если RenderObject размещён в каком-либо контейнере, например в Column, сообщение будет отправлено последовательно всем дочерним объектам независимо от координаты точки касания. Обработка сообщения завершится на первом объекте, который вернёт true, поэтому важно делать дополнительную проверку принадлежности координат точки касания прямоугольнику, включающему наш объект. С точки зрения проверки координаты точка касания должна находиться в интервале от (0, 0) до size.

Поскольку RenderObject не хранит информацию о связанном виджете, для отправки уведомлений о произошедшем событии нужно использовать callback-функции, которые передаются в виджет и дальше сохраняются в RenderObject.

1class ClockRenderBox extends RenderBox {
2  Size _size;
3  Offset _offset;
4  double _hour;
5  double _minute;
6  ValueSetter<double> onUpdateMinutes;
7
8  ClockRenderBox(
9    this._size,
10    this._offset,
11    this._hour,
12    this._minute,
13    this.onUpdateMinutes,
14  );
15
16  @override
17  bool hitTest(BoxHitTestResult result, {required Offset position}) {
18    // проверка, что точка касания находится внутри прямоугольника RenderObject
19    if (!(Offset.zero & size).contains(position)) return false;
20    //регистрация события касания (будет передано в handleEvent)
21    result.add(BoxHitTestEntry(this, position));
22    return true;
23  }
24
25  @override
26  void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
27	  // entry.localPosition здесь получит значение position из hitTest
28    final center = size / 2;
29    final position = entry.localPosition;
30    double angle =
31        atan2(position.dx - center.width, position.dy - center.height) + pi;
32    if (angle > 2 * pi) {
33      angle = angle - 2 * pi;
34    }
35    final minutes = (2 * pi - angle) / (2 * pi) * 60;
36    onUpdateMinutes(minutes);
37  }
38
39...
40}
41
42class Clock extends LeafRenderObjectWidget {
43  final Size size;
44  final Offset offset;
45  final double hour;
46  final double minute;
47  final ValueSetter<double> onUpdateMinutes;
48
49  const Clock({
50    required this.size,
51    required this.offset,
52    required this.hour,
53    required this.minute,
54    required this.onUpdateMinutes,
55    super.key,
56  });
57//...
58}
59

При вызове виджета также передаём callback:

1            Clock(
2              size: clockData.size,
3              offset: clockData.offset,
4              hour: clockData.hour,
5              minute: clockData.minute,
6              onUpdateMinutes: (minutes) {
7                setState(() => clockData.minute = minutes);
8              },
9            ),
10

Информация об обработке события может быть сохранена в объект HitTestResult. Он собирает информацию о событиях взаимодействия с RenderObject, а также о применённых трансформациях для трансляции экранной системы координат в координаты внутри виджета.

Полученные HitTestResult в дальнейшем передаются в метод-обработчик handleEvents, который также получает более подробную информацию о событии в PointerEvent и значение относительных координат точки касания, полученное через BoxHitTestEntry.

Вот как будет выглядеть наш код:

1        import 'package:flutter/material.dart';
2        import 'dart:math';
3
4        import 'package:flutter/rendering.dart';
5
6        class Clock extends LeafRenderObjectWidget {
7          final Size size;     //размер области отрисовки
8          final Offset offset; //дополнительное смещение
9          final double hour;   //часы
10          final double minute; //минуты
11          final ValueSetter<double> onUpdateMinutes;  //действие при изменении минут
12
13          const Clock({
14            required this.size,
15            required this.offset,
16            required this.hour,
17            required this.minute,
18            required this.onUpdateMinutes,
19            super.key,
20          });
21
22          @override
23          RenderObject createRenderObject(BuildContext context) =>
24              ClockRenderBox(size, offset, hour, minute, onUpdateMinutes);
25
26          @override
27          void updateRenderObject(
28              BuildContext context, covariant RenderObject renderObject) {
29            final clockRenderObject = renderObject as ClockRenderBox;
30            clockRenderObject
31              ..ownSize = size
32              ..offset = offset
33              ..hour = hour
34              ..minute = minute;
35          }
36        }
37
38        class ClockRenderBox extends RenderBox {
39          Size _size;
40          Offset _offset;
41          double _hour;
42          double _minute;
43          ValueSetter<double> onUpdateMinutes;
44
45          ClockRenderBox(
46            this._size,
47            this._offset,
48            this._hour,
49            this._minute,
50            this.onUpdateMinutes,
51          );
52
53          @override
54          bool hitTest(BoxHitTestResult result, {required Offset position}) {
55            //проверка, что касание экрана произошло в прямоугольнике часов
56            if (!(Offset.zero & size).contains(position)) return false;
57            //если да, добавляем событие
58            result.add(BoxHitTestEntry(this, position));
59            return true;
60          }
61
62          @override
63          void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
64            //entry.localPosition здесь получит значение position из hitTest
65            final center = size / 2;
66            //переводим координаты точки касания в соответствующее значение угла
67            final position = entry.localPosition;
68            double angle =
69                atan2(position.dx - center.width, position.dy - center.height) + pi;
70            if (angle > 2 * pi) {
71              angle = angle - 2 * pi;
72            }
73            final minutes = (2 * pi - angle) / (2 * pi) * 60;
74            onUpdateMinutes(minutes);
75          }
76
77          @override
78          get sizedByParent => false;
79
80          @override
81          Size computeDryLayout(BoxConstraints constraints) =>
82              constraints.constrain(_size);
83
84          @override
85          void performLayout() => size = constraints.constrain(_size);
86
87          set ownSize(Size newSize) {
88            if (newSize != _size) {
89              _size = newSize;
90              markNeedsPaint();
91              markNeedsLayout();
92            }
93          }
94
95          set offset(Offset offset) {
96            if (offset != _offset) {
97              _offset = offset;
98              markNeedsPaint();
99            }
100          }
101
102          set hour(double hour) {
103            if (hour != _hour) {
104              _hour = hour;
105              markNeedsPaint();
106              markNeedsSemanticsUpdate();
107            }
108          }
109
110          set minute(double minute) {
111            if (minute != _minute) {
112              _minute = minute;
113              markNeedsPaint();
114              markNeedsSemanticsUpdate();
115            }
116          }
117
118          @override
119          void paint(PaintingContext context, Offset offset) {
120            final center = size.center(offset + _offset);
121            final radius = size.shortestSide / 2;
122            final hourToRads = _hour / 12 * 2 * pi;
123            final minsToRads = _minute / 60 * 2 * pi;
124            final paintHours = Paint()
125              ..style = PaintingStyle.fill
126              ..strokeWidth = 5
127              ..color = Colors.white;
128            final paintMins = Paint()
129              ..style = PaintingStyle.fill
130              ..strokeWidth = 2
131              ..color = Colors.grey;
132            context.canvas.drawLine(
133              center,
134              center +
135                  Offset(
136                    radius / 2 * cos(pi / 2 - hourToRads),
137                    -radius / 2 * sin(pi / 2 - hourToRads),
138                  ),
139              paintHours,
140            );
141            context.canvas.drawLine(
142              center,
143              center +
144                  Offset(
145                    radius * cos(pi / 2 - minsToRads),
146                    -radius * sin(pi / 2 - minsToRads),
147                  ),
148              paintMins,
149            );
150          }
151        }
152
153        class ClockData {
154          Offset offset = Offset.zero;
155          Size size = const Size.square(128);
156          double hour = 0;
157          double minute = 0;
158        }
159
160        class MyClockApp extends StatefulWidget {
161          const MyClockApp({super.key});
162
163          @override
164          State<MyClockApp> createState() => _MyClockAppState();
165        }
166
167        class _MyClockAppState extends State<MyClockApp> {
168          final clockData = ClockData();
169
170          @override
171          Widget build(BuildContext context) {
172            return MaterialApp(
173              theme: ThemeData.dark(useMaterial3: false),
174              home: Scaffold(
175                body: SafeArea(
176                  child:
177                      Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
178                    ElevatedButton(
179                      onPressed: () =>
180                          setState(() => clockData.offset += const Offset(1, 1)),
181                      child: const Text('Shift'),
182                    ),
183                    ElevatedButton(
184                      onPressed: () => setState(() => clockData.size *= 1.1),
185                      child: const Text('Resize'),
186                    ),
187                    ElevatedButton(
188                      onPressed: () => setState(() => clockData.hour++),
189                      child: const Text('Increment hour'),
190                    ),
191                    ElevatedButton(
192                      onPressed: () => setState(() => clockData.minute++),
193                      child: const Text('Increment min'),
194                    ),
195                    //ограничитель размера области отрисовки
196                    LimitedBox(
197                      maxWidth: 200,
198                      maxHeight: 200,
199                      child: Clock(
200                        size: clockData.size,
201                        offset: clockData.offset,
202                        hour: clockData.hour,
203                        minute: clockData.minute,
204                        onUpdateMinutes: (minutes) {
205                          setState(() => clockData.minute = minutes);
206                        },
207                      ),
208                    ),
209                  ]),
210                ),
211              ),
212            );
213          }
214        }
215
216        void main() {
217          runApp(const MyClockApp());
218        }

При обработке касаний также могут быть полезны методы из RenderBox.

Метод

Назначение

localToGlobal(Offset)

переход от локальных координат к экранным (координата 0,0 соответствует левому верхнему углу экрана, на мобильных устройствах находится под областью уведомлений)

globalToLocal(Offset)

для преобразований экранных координат в локальные для RenderObject

getTransforTo(otherRenderObject)

определение Matrix4-преобразования координат точки из локальной системы координат нашего RenderObject в систему координат другого RenderObject (например, для вывода визуальных отметок в родительский объект при касании дочернего)

Также для использования стандартного поведения для RenderObject с одним дочерним объектом (нажатие считается успешным при попадании точки касания в paintBounds для RenderObject) можно использовать базовый класс RenderProxyBoxWithHitBehavior.

Теперь сделаем стрелки полупрозрачными. В этом нам поможет PaintingContext.

Связь RenderObject и PaintingContext

PaintingContext — это класс, который расширяет возможности Canvas: позволяет создавать произвольные слои и добавлять визуальные эффекты. Например, прозрачность, различные преобразования растрового изображения — размытие, аффинные преобразования для масштабирования/сдвига/поворота и любых их комбинаций. Использование Canvas не отличается от рассмотренного ранее в параграфе про CustomPainter.

Углубляться в PaintingContext мы сейчас не будем — боимся вас запутать. Но если вам любопытно, то вот ссылка на документацию. Пока что коротко отметим пару нюансов, которые имеют отношение к RenderObject.

Итак, PaintingContext использует модель слоёв. Если вы хоть раз работали в Photoshop или любом другом редакторе изображений — вы имеете представление о том, что это такое.

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

Само собой, слои во Flutter устроены сложнее. Например, смещение изображения или добавление прозрачности — это тоже слои.

Для смещения изображения мы применяем OffsetLayer (контейнерный слой). Он всегда создаётся автоматически для RenderObject, у которого isRepaintBoundary возвращает true — тогда область перерисовки ограничивается областью экрана, занимаемой этим объектом.

Созданный OffsetLayer доступен через свойство layer. Если слой не был создан автоматически, будет использоваться ближайший контейнерный слой, который был найден выше по дереву, а значение layer будет null. Даже в этом случае слой может быть создан программно и записан в свойство layer, тогда и canvas будет использовать указанный слой для рисования.

Зная это, мы можем добавить эффект полупрозрачного отображения для стрелок наших часов и сдвинуть их в нужное место. Используем метод PaintingContext.pushOpacity(), который создаёт новый слой прозрачности.

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

1    context.pushOpacity(
2      center + offset,
3      64,    // прозрачность (0—255, 0 — полностью прозрачный)
4      (context, offset) {
5        context.canvas.drawLine(
6          offset,
7          offset +
8              Offset(
9                radius / 2 * cos(pi / 2 - hourToRads),
10                -radius / 2 * sin(pi / 2 - hourToRads),
11              ),
12          paintHours,
13        );
14        context.canvas.drawLine(
15          offset,
16          offset +
17              Offset(
18                radius * cos(pi / 2 - minsToRads),
19                -radius * sin(pi / 2 - minsToRads),
20              ),
21          paintMins,
22        );
23      },
24    );
25

Теперь добавим анимацию прозрачности для нашего RenderObject. Для этого разберёмся с моделью жизненного цикла и возможными состояниями объекта.

Жизненный цикл RenderObject

RenderObject создаётся без привязки к дереву (поля owner и parent равны null) и затем подключается в дерево в позицию, которая соответствует родительскому элементу в процессе встраивания нового элемента в своё дерево. Для этого в методе mount для RenderObjectElement создается экземпляр RenderObject через вызов createRenderObject из виджета.

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

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

При этом RenderObject может быть возвращён в другое место дерева — например, при использовании Hero-анимации (более подробно про анимации можно почитать в этом параграфе) или перемещении виджетов с глобальными ключами. В случае если RenderObject более не будет использоваться, вызывается метод dispose.

В нашем примере мы можем использовать методы жизненного цикла для создания анимации прозрачности. Здесь мы получим Ticker непосредственно в нашем классе, но более правильным будет решение создавать его в State виджета и передавать в конструктор при создании:

1        import 'package:flutter/material.dart';
2        import 'dart:math';
3
4        import 'package:flutter/rendering.dart';
5        import 'package:flutter/scheduler.dart';
6
7        class Clock extends LeafRenderObjectWidget {
8          final Size size;
9          final Offset offset;
10          final double hour;
11          final double minute;
12          final ValueSetter<double> onUpdateMinutes;
13          final ValueSetter<double> onUpdateHours;
14
15          const Clock({
16            required this.size,
17            required this.offset,
18            required this.hour,
19            required this.minute,
20            required this.onUpdateMinutes,
21            required this.onUpdateHours,
22            super.key,
23          });
24
25          @override
26          RenderObject createRenderObject(BuildContext context) => ClockRenderBox(
27                size,
28                offset,
29                hour,
30                minute,
31                onUpdateMinutes,
32                onUpdateHours,
33              );
34
35          @override
36          void updateRenderObject(
37              BuildContext context, covariant RenderObject renderObject) {
38            final clockRenderObject = renderObject as ClockRenderBox;
39            clockRenderObject
40              ..ownSize = size
41              ..offset = offset
42              ..hour = hour
43              ..minute = minute;
44          }
45        }
46
47        class ClockRenderBox extends RenderBox implements TickerProvider {
48          Size _size;
49          Offset _offset;
50          double _hour;
51          double _minute;
52          ValueSetter<double> onUpdateMinutes;
53          ValueSetter<double> onUpdateHours;
54          AnimationController? _animationController;
55
56          ClockRenderBox(
57            this._size,
58            this._offset,
59            this._hour,
60            this._minute,
61            this.onUpdateMinutes,
62            this.onUpdateHours,
63          );
64
65          @override
66          get sizedByParent => false;
67
68          @override
69          Size computeDryLayout(BoxConstraints constraints) =>
70              constraints.constrain(_size);
71
72          @override
73          void performLayout() => size = constraints.constrain(_size);
74
75          @override
76          void attach(PipelineOwner owner) {
77            super.attach(owner);
78            _animationController = AnimationController(
79              vsync: this,
80              lowerBound: 63,
81              upperBound: 255,
82              duration: const Duration(seconds: 1),
83            );
84            _animationController?.repeat();
85            _animationController?.addListener(markNeedsPaint);
86          }
87
88          @override
89          void detach() {
90            _animationController?.stop();
91            super.detach();
92          }
93
94          set ownSize(Size newSize) {
95            if (newSize != _size) {
96              _size = newSize;
97              markNeedsPaint();
98              markNeedsLayout();
99            }
100          }
101
102          set offset(Offset offset) {
103            if (offset != _offset) {
104              _offset = offset;
105              markNeedsPaint();
106            }
107          }
108
109          set hour(double hour) {
110            if (hour != _hour) {
111              _hour = hour;
112              markNeedsPaint();
113              markNeedsSemanticsUpdate();
114            }
115          }
116
117          set minute(double minute) {
118            if (minute != _minute) {
119              _minute = minute;
120              markNeedsPaint();
121              markNeedsSemanticsUpdate();
122            }
123          }
124
125          @override
126          void paint(PaintingContext context, Offset offset) {
127            final center = size.center(offset + _offset);
128            final radius = size.shortestSide / 2;
129            final hourToRads = _hour / 12 * 2 * pi;
130            final minsToRads = _minute / 60 * 2 * pi;
131            final paintHours = Paint()
132              ..style = PaintingStyle.fill
133              ..strokeWidth = 5
134              ..color = Colors.white;
135            final paintMins = Paint()
136              ..style = PaintingStyle.fill
137              ..strokeWidth = 2
138              ..color = Colors.grey;
139
140            context.pushOpacity(center, _animationController?.value.toInt() ?? 255,
141                (context, offset) {
142              context.canvas.drawLine(
143                offset,
144                offset +
145                    Offset(
146                      radius / 2 * cos(pi / 2 - hourToRads),
147                      -radius / 2 * sin(pi / 2 - hourToRads),
148                    ),
149                paintHours,
150              );
151              context.canvas.drawLine(
152                offset,
153                offset +
154                    Offset(
155                      radius * cos(pi / 2 - minsToRads),
156                      -radius * sin(pi / 2 - minsToRads),
157                    ),
158                paintMins,
159              );
160            });
161          }
162
163          @override
164          bool hitTest(BoxHitTestResult result, {required Offset position}) {
165            if (!(Offset.zero & size).contains(position)) return false;
166            result.add(BoxHitTestEntry(this, position));
167            return true;
168          }
169
170          @override
171          void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
172            final center = size / 2;
173            final position = entry.localPosition;
174            double angle =
175                atan2(position.dx - center.width, position.dy - center.height) + pi;
176            if (angle > 2 * pi) {
177              angle = angle - 2 * pi;
178            }
179            final minutes = (2 * pi - angle) / (2 * pi) * 60;
180            onUpdateMinutes(minutes);
181          }
182
183          Ticker? _ticker;
184
185          @override
186          Ticker createTicker(TickerCallback onTick) {
187            _ticker ??= Ticker(onTick);
188            return _ticker!;
189          }
190        }
191
192        class ClockData {
193          Offset offset = Offset.zero;
194          Size size = const Size.square(128);
195          double hour = 0;
196          double minute = 0;
197        }
198
199        class MyClockApp extends StatefulWidget {
200          const MyClockApp({super.key});
201
202          @override
203          State<MyClockApp> createState() => _MyClockAppState();
204        }
205
206        class _MyClockAppState extends State<MyClockApp> {
207          final clockData = ClockData();
208
209          @override
210          Widget build(BuildContext context) {
211            return MaterialApp(
212              theme: ThemeData.dark(useMaterial3: false),
213              home: Scaffold(
214                body: SafeArea(
215                  child:
216                      Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
217                    ElevatedButton(
218                      onPressed: () =>
219                          setState(() => clockData.offset += const Offset(1, 1)),
220                      child: const Text('Shift'),
221                    ),
222                    ElevatedButton(
223                      onPressed: () => setState(() => clockData.size *= 1.1),
224                      child: const Text('Resize'),
225                    ),
226                    ElevatedButton(
227                      onPressed: () => setState(() => clockData.hour++),
228                      child: const Text('Increment hour'),
229                    ),
230                    ElevatedButton(
231                      onPressed: () => setState(() => clockData.minute++),
232                      child: const Text('Increment min'),
233                    ),
234                    LimitedBox(
235                      maxWidth: 200,
236                      maxHeight: 200,
237                      child: Clock(
238                        size: clockData.size,
239                        offset: clockData.offset,
240                        hour: clockData.hour,
241                        minute: clockData.minute,
242                        onUpdateMinutes: (minutes) {
243                          setState(() => clockData.minute = minutes);
244                        },
245                        onUpdateHours: (hours) {
246                          setState(() => clockData.hour = hours);
247                        },
248                      ),
249                    ),
250                  ]),
251                ),
252              ),
253            );
254          }
255        }
256
257        void main() {
258          runApp(const MyClockApp());
259        }

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

Семантическая информация и обработка действий

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

  • Передать размеры активного прямоугольника, который может подсвечиваться при использовании режима управления жестами для перемещения фокуса между элементами. Границы прямоугольника также используются некоторыми инструментами blackbox-тестирования, такими как Appium. В RenderObject семантические границы определяются get-методом semanticBounds и по умолчанию совпадают по размерам и положению с исходным объектом.
  • Определить через describeSemanticsConfiguration семантику RenderObject (информация из неё копируется в SemanticNode и собирается в дерево семантики, которое отправляется в операционную систему или браузер). Семантика содержит большое количество полей и атрибутов, например:

Общая информация об объекте

hint

текстовая подсказка о назначении элемента (например, смысл действия при нажатии кнопки)

label

текстовое описание элемента (может использоваться синтезатором речи TalkBack / VoiceOver)

increasedValue / decreasedValue

новое значение после выполнения семантического действия увеличения/уменьшения значения

currentValueLength / maxValueLength

текущее/максимальное количество символов в редактируемом текстовом поле

elevation

значение по оси z для RenderObject относительно родительского объекта

hintOverrides

переопределение подсказок по умолчанию для платформы (например, уведомления, что это кнопка)

indexInParent

порядковый номер в родительском контейнере

Флаги, показывающие состояние/возможности объекта

isButton

true, если это кнопка (может принимать фокус и выполнять действие «Нажать»)

isFocusable

может принимать фокус (используется при навигации жестами)

isFocused

сейчас находится в фокусе

isHeader

является заголовком страницы

isHidden

не отображается (обычно игнорируется при озвучивании и навигации)

isInMutuallyExclusiveGroup

является частью взаимоисключающей группы (например, radio-кнопки)

isLink

является ссылкой (может быть предложено действие «Выполнить переход»)

isMultiline

является многострочным полем

isObscured

значение поля должно быть скрыто (не должно быть произнесено вслух)

isReadOnly

поле доступно только для чтения

isSelected

текущий объект выбран (для checkbox/radio)

isSlider

является ли объект слайдером

isTextField

объект является текстовым полем (с возможностью ввода текста голосом или любыми устройствами ввода)

isToggled

объект включен (например, применяется для Switch)

isSemanticBoundary

необходимо создать собственный узел в дереве семантической информации для этого RenderObject

isMergingSemanticsOfDescendant

объединяет семантическую информацию дочерних элементов в общий объект

Функции для вызова при получении сообщений об ожидаемых действиях

onCopy

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

onCut, onPaste

аналогично для действий «Вырезать» и «Вставить»

onIncrease / onDecrease

действия со счётчиком, подразумевающие изменение значения (или выбор среди вариантов)

onTap, onLongPress

при обычном/долгом нажатии на объект

setText

замена текста (например, при голосовом вводе)

setSelection

изменение/расширение выделения текста

onScrollUp, onScrollDown, onScrollLeft, onScrollRight, onMoveCursorForwardByCharacter, onMoveCursorForwardByWord, onMoveCursorBackwardByCharacter, onMoveCursorBackwardByWord

перемещение с помощью курсора или комбинаций клавиш / специальных жестов

customSemanticsActions

связь произвольных семантических действий, определяются через CustomSemanticsAction(label: 'keyword'), и соответствующих функций-обработчиков

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

1  @override
2  Rect get semanticBounds => Offset.zero & size;
3
4  @override
5  void describeSemanticsConfiguration(SemanticsConfiguration config) {
6    // текущее время, которое показывают часы
7    config.value = '$_hour hours and $_minute minutes';
8    //значение минутной стрелки после действий increment-decrement
9    config.decreasedValue = _minute.toInt().toString();
10    config.increasedValue = _minute.toInt().toString();
11
12		config.onDecrease = () {
13			// изменение времени (перемещение минутной стрелки назад)
14      _minute--;
15      if (_minute < 0) {
16        _minute = 60 + _minute;
17        _hour--;
18        if (_hour < 0) _hour = 24 + _hour;
19      }
20      onUpdateMinutes(_minute);
21      onUpdateHours(_hour);
22      markNeedsSemanticsUpdate();
23   };
24    config.onIncrease = () {
25			// изменение времени (перемещение минутной стрелки вперёд)
26      _minute++;
27      if (_minute >= 60) {
28				// также отслеживаем часовую стрелку
29        _minute = _minute - 60;
30        _hour = (_hour + 1) % 24;
31      }
32      onUpdateMinutes(_minute);
33      onUpdateHours(_hour);
34      markNeedsSemanticsUpdate();
35    };
36    config.onTap = () {
37      // семантическое действие «Нажать» переводит часовую стрелку
38      _hour = (_hour + 1) % 24;
39      onUpdateHours(_hour);
40      markNeedsSemanticsUpdate();
41    };
42    // голосовая подсказка для действия при нажатии на RenderObject часов
43    config.hint = 'Tap me to increment hours';
44  }

Более подробно использование виджетов семантической разметки и управления семантическим деревом будет рассмотрено в следующем параграфе.

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

Отладка RenderObject с использованием DevTools

Один из наиболее важных способов отладки RenderObject — инструменты DevTools, в которых можно получить информацию об эффективном дереве виджетов (полученном после вызовов build для StatelessWidget / StatefulWidget), соответствующем дереве элементов и связанным с ним состоянием (если есть), а также о присоединённом объекте RenderObject.

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

Чтобы описать объект, необходимо добавить к нему миксин Diagnosticable и переопределить метод debugFillProperties, который возвращает список свойств из реализаций класса DiagnosticableNode (примеры — в таблице ниже). Для описания дочерних объектов контейнера нужно переопределить метод debugDescribeChildren и добавить миксин DiagnosticableTreeMixin.

Поле

Описание

DiagnosticsProperty

именованное значение произвольного типа (может также содержать описание и множество параметров для настройки отображения), также определяется уровень сообщения (константы из DiagnosticsLevel: hidden — не показывать, fine, debug, warning, info, hint, summary, error, off), который может быть использован для фильтрации сообщений через аргумент minLevel

IterableProperty

список значений произвольного типа

EnumProperty

значение перечисляемого типа

FlagProperty

логическое именованное значение

StringProperty, IntProperty, DoubleProperty, ColorProperty, PercentProperty

используются для представления соответствующих типов данных

DiagnosticsBlock

группировка значений (содержит список children для DiagnosticsProperty)

DiagnosticableTreeNode

подэлемент дерева объектов, связанных с виджетом

ErrorSummary, ErrorDescription, ErrorHint

разные уровни ошибок (используются уровни DiagnosticsLevel error, info и hint)

Также может быть переопределён метод toDiagnosticsNode — для создания собственного представления RenderObject в DevTools.

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

1  @override
2  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
3    super.debugFillProperties(properties);
4    properties.add(DiagnosticsNode.message('This is a clock renderobject'));
5    properties.add(DiagnosticsProperty('hour', _hour));
6    properties.add(DiagnosticsProperty('minute', _minute));
7    properties.add(DiagnosticsProperty('offset', _offset));
8  }
9

Полученные значения в debugFillProperties используются при выводе RenderObject в DevTools или при вызове describeForError , который используется в стеке ошибки, связанном с некорректным поведением или разметкой в RenderObject.

Также список свойств можно получить через явный вызов toDiagnosticsNode().getProperties().

image

Кроме того, для отладки области отрисовки могут использоваться debug-флаги:

Поле

Описание

debugPaintSizeEnabled

показывать границы измеренных областей (с учётом полученных от RenderObject размеров)

debugPaintBaselinesEnabled

построить базовую линию (нижняя граница для текстовых виджетов, за исключением символов с «хвостиками»)

debugPaintLayerBordersEnabled

включить вокруг каждого слоя рамки для визуализации его границ

debugPaintPointersEnabled

подсвечивать RenderObject при возникновении события касания и получения от hitTest значения true (реализовано не во всех RenderObject, поддерживается в методе debugHandleEvent)

debugRepaintRainbowEnabled

изменять цвет рамки при каждом вызове paint от RenderObject

debugRepaintTextRainbowEnabled

изменять цвет текста при каждой перерисовке

debugPrintMarkNeedsLayoutStacks

отображать стек вызовов для каждого обращения к markNeedsLayout

debugPrintMarkNeedsPaintStacks

отображать стек вызовов для обращений к markNeedsPaint

debugProfileLayoutsEnabled

добавлять метки для вызова performLayout на линию времени в DevTools

debugProfilePaintsEnabled

добавлять метки для каждого обращения к paint на линию времени в DevTools

debugEnhanceLayoutTimelineArguments, debugEnhancePaintTimelineArguments

расширять информацию о событии вызова performLayout/paint с использованием диагностической информации из debugFillDiagnostics()

debugOnProfilePaint

определить функцию для вызова при каждой отрисовке RenderObject (принимает его как аргумент)

Также есть несколько флагов для отключения различных типов слоёв:

  • debugDisableClipLayers — отключить слои обрезки (Rect, RRect, Path);
  • debugDisablePhysicalShapeLayers — отключить создание слоёв PhysicalShape (поверхность Material с тенью);
  • debugDisableOpacityLayers — отключить создание слоёв с полупрозрачностью (для проверки их влияния на производительность).

При отладке также можно использовать функции для вывода в консоль текущих деревьев:

  • debugDumpRenderTree() — отобразить дерево RenderObject (начиная с RenderView);
  • debugDumpSemanticsTree() — вывести дерево SemanticsNode (используется подсистемой accessibility);
  • debugDumpLayerTree() — показывать дерево слоёв (с указанием типа и характеристик слоя, границ в координатах экрана, а также связи с деревом виджетов);
  • debugDumpApp() — вывести дерево виджетов и связанных с ними RenderObject (как в DevTools, но в виде строки в консоли).

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

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

RenderObject с одним объектом

Ранее мы рассматривали реализацию RenderObject для leaf-объектов, которые отвечают только за измерение своего размера, отрисовку и обработку семантической информации и касаний.

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

Мы рассмотрим реализацию на примере RenderObject, который будет задавать размер для дочернего объекта не более чем вполовину от собственного размера (от BoxConstraints.biggest), позиционировать его по центру своей области отрисовки и дополнительно создавать рамку вокруг вложенного объекта.

С точки зрения реализации будут сделаны следующие изменения:

  1. метод performLayout должен разместить дочерний объект по центру, предварительно выполнив его измерение;
  2. метод paint теперь не только отображает собственное изображение (рамка), но и запрашивает отрисовку вложенного объекта;
  3. базовый класс для создания виджета будет SingleChildRenderObjectWidget (он принимает один аргумент child с типом Widget).
1import 'package:flutter/material.dart';
2import 'package:flutter/rendering.dart';
3
4void main() {
5  runApp(const HalfDecoratorApp());
6}
7
8class HalfDecorator extends SingleChildRenderObjectWidget {
9  const HalfDecorator({
10    required super.child,
11    super.key,
12  });
13
14  @override
15  RenderObject createRenderObject(BuildContext context) =>
16      RenderHalfDecorator();
17}
18
19class RenderHalfDecorator extends RenderBox
20    with RenderObjectWithChildMixin<RenderBox> {
21  @override
22  void paint(PaintingContext context, Offset offset) {
23    context.canvas.drawRect(
24        offset & size,
25        Paint()
26          ..style = PaintingStyle.stroke
27          ..color = Colors.green);
28    final position = Offset(
29      (size.width - child!.size.width) / 2,
30      (size.height - child!.size.height) / 2,
31    );
32    context.paintChild(child!, offset + position);
33    context.canvas.drawRect(
34      offset + position & child!.size,
35      Paint()
36        ..style = PaintingStyle.stroke
37        ..color = Colors.yellow
38        ..strokeWidth = 2,
39    );
40  }
41
42  @override
43  Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
44
45  @override
46  void performLayout() {
47    //дочерний объект ограничиваем размером от 0 до половины нашего размера
48    child?.layout(constraints.copyWith(
49      minWidth: 0,
50      minHeight: 0,
51      maxWidth: constraints.maxWidth / 2,
52      maxHeight: constraints.maxHeight / 2,
53    ));
54    //собственный размер - максимально возможный
55    size = constraints.biggest;
56  }
57}
58
59class HalfDecoratorApp extends StatelessWidget {
60  const HalfDecoratorApp({super.key});
61
62  @override
63  Widget build(BuildContext context) {
64    return MaterialApp(
65      theme: ThemeData.dark(useMaterial3: false),
66      home: const Scaffold(
67        body: Center(
68          child: SizedBox(
69            width: 256,
70            height: 256,
71            child: HalfDecorator(
72              child: Text(
73                'I am decorated',
74                style: TextStyle(
75                  color: Colors.white,
76                  fontSize: 24,
77                ),
78              ),
79            ),
80          ),
81        ),
82      ),
83    );
84  }
85}

При вызове метода paint дочерний объект уже был измерен через вызов метода performLayout, его размер может быть получен через свойство child.size (child указывает на RenderObject, полученный из виджета, переданного в child в конструктор SingleChildRenderObjectWidget).

Из-за того что ограничения на размер вложенного объекта не строгие (от (0,0) до половины размера внешнего контейнера), текст имеет только горизонтальное ограничение. В случае если бы строка была ещё короче, ограничение определялось бы по реальной ширине текста. Высота текста определяется в зависимости от количества строк.

Метод layout каждого вложенного объекта должен быть вызван из функции performLayout. Эти ограничения могут различаться для каждого объекта и определяться на основе исходной информации и параметров, которые доступны через child.parentData.

Для удобства доступа к вложенному объекту можно использовать mixin RenderObjectWithChildMixin<RenderBox> (может быть указан более специфический тип), также он предоставляет реализации методов attach/detach, которые тоже используются для добавления/удаления дочерних объектов в дерево RenderObject.

Альтернативно можно наследоваться от RenderProxyBox (также предоставляет доступ к child). Следует отметить, что свойство child может использоваться не только для получения текущего вложенного RenderObject, но и для его динамической замены на другой RenderObject через переопределение значения свойства child.

Для делегирования отрисовки вложенного объекта используется метод paintChild для PaintingContext: context.paintChild(child!, offset + position). Изображение создаётся в текущем слое кроме случая, когда дочерний объект помечен как isRepaintBoundary=true, например, в RenderRepaint или по умолчанию в элементах списка ListView, в этом случае для него создаются свои слои OffsetLayer + PictureLayer. Совместное использование одного слоя может привести к избыточным перерисовкам при анимациях дочернего объекта, это нужно учитывать при проектировании RenderObject.

RenderObject со множеством вложенных объектов

Виджеты-контейнеры, такие как Column, Stack и Flex, выполняют позиционирование нескольких вложенных объектов. В реализации RenderObject для таких ситуаций есть несколько особенностей:

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

В качестве примера использования RenderObject для позиционирования нескольких объектов возьмём контейнер для размещения в виде шахматной доски. Из вложенных объектов мы будем получать данные о цвете фона (область для размещения объекта всегда будет иметь квадратную форму). Количество столбцов для простоты задачи всегда будет равно 4, а ширина и высота столбца/строки будут определяться размером наибольшего RenderObject.

Начнём решение задачи с конца: рассмотрим реализацию методов getMaxIntrinsicWidth/ Height и getMinIntrinsicWidth/Height. Эти методы используются для определения предпочтительных границ размера RenderObject и позволяют установить наименьшую и наибольшую возможную ширину для заданной высоты и аналогично для высоты с заданной шириной. Использование этих методов позволяет предварительно оценить ожидаемый размер RenderObject без вызова performLayout — его не следует вызывать более одного раза в кадр, в то время как сами эти методы можно вызывать многократно.

Следующая необходимая доработка — передача данных о цвете подложки от дочернего объекта к родительскому. Для решения этой задачи используется структура ParentData, которая инициализируется со стороны вложенного объекта в методе setupParentData. Поскольку тип объекта с данными для ParentData должен быть известен заранее, нам потребуется реализовать два объекта — и внешний контейнер, и внутренний объект-обёртку, который будет поддерживать сохранение данных о цвете подложки в объекте необходимого типа.

Для создания виджета с множеством вложенных элементов может быть использован один из двух базовых классов:

  • MultiChildRenderObjectWidget — для размещения произвольных виджетов без их упорядочивания и привязки к местоположению/области;
  • SlottedMultiChildRenderObjectWidget — для создания виджетов с определённым типом с привязкой к различным зонам контейнера (например, используется в ListTile и InputDecorator).

Мы для примера будем использовать SlottedMultiChildRenderObjectWidget, но дальше также покажем, как можно использовать MultiChildRenderObjectWidget. Для слотированного варианта в классе виджета должен быть определён метод childForSlot для извлечения дочернего виджета по известному идентификатору слота (их можно определить через enum или через целочисленный индекс), а соответствующий RenderObject должен использовать миксин SlottedContainerRenderObjectMixin с типом слота и RenderObject для элемента.

При использовании MultichildRenderObjectWidget при определении RenderObject полезно использовать миксин ContainerRenderObjectMixin для указания типа дочерних RenderObject, размещенных в контейнере, и типа объекта для хранения данных, доступных родительскому объекту.

Поскольку мы используем измерение ожидаемых размеров, нам нужно будет передавать вызовы методов get{Max/Min}Intrinsic{Width/Height} в дочерний объект. Для объекта с единственным дочерним элементом логика не отличается от описанной в предыдущем разделе, но в случае MultiChild в performLayout обрабатывается список children (здесь — Iterable<RenderChessboardItem>) для определения размеров и после этого обязательно выполняется layout для каждого объекта из этого списка с заданными ограничениями (строго определёнными размерами). Позиция отрисовки объектов определяется уже во время выполнения метода paint.

1import 'dart:math';
2
3import 'package:flutter/material.dart';
4import 'package:flutter/rendering.dart';
5
6class ChessboardItem extends SingleChildRenderObjectWidget {
7  final Color background;
8
9  const ChessboardItem({
10    required this.background,
11    required super.child,
12    super.key,
13  });
14
15  @override
16  RenderObject createRenderObject(BuildContext context) =>
17      RenderChessboardItem(background);
18}
19
20class BackgroundColorParentData extends ParentData {
21  Color background;
22
23  BackgroundColorParentData(this.background);
24}
25
26class RenderChessboardItem extends RenderProxyBox {
27  final Color background;
28
29  RenderChessboardItem(this.background);
30
31  @override
32  double getMaxIntrinsicHeight(double width) {
33    super.getMaxIntrinsicHeight(width);
34    return child!.getMaxIntrinsicHeight(width);
35  }
36
37  @override
38  double getMinIntrinsicHeight(double width) {
39    super.getMinIntrinsicHeight(width);
40    return child!.getMinIntrinsicHeight(width);
41  }
42
43  @override
44  double getMaxIntrinsicWidth(double height) {
45    super.getMaxIntrinsicWidth(height);
46    return child!.getMaxIntrinsicWidth(height);
47  }
48
49  @override
50  double getMinIntrinsicWidth(double height) {
51    super.getMinIntrinsicWidth(height);
52    return child!.getMinIntrinsicWidth(height);
53  }
54
55  @override
56  void performLayout() {
57    child!.layout(constraints);
58    size = constraints.biggest;
59  }
60
61  @override
62  void paint(PaintingContext context, Offset offset) {
63    context.canvas.drawRect(
64        offset & size,
65        Paint()
66          ..color =
67              (child!.parentData as BackgroundColorParentData).background);
68    context.paintChild(child!, offset);
69  }
70
71  @override
72  void setupParentData(covariant RenderObject child) {
73    if (child.parentData is! ParentData) {
74		child.parentData = BackgroundColorParentData(background);
75    }
76  }
77
78}
79
80class RenderChessboardContainer extends RenderBox
81    with SlottedContainerRenderObjectMixin<int, RenderChessboardItem> {
82  @override
83  void paint(PaintingContext context, Offset offset) {
84    //заполнение фона
85    context.canvas.drawRect(
86        offset & size,
87        Paint()
88          ..style = PaintingStyle.fill
89          ..color = Colors.blueAccent);
90    if (maxSizes != null) {
91      double y = 0;
92      for (final (idx, c) in children.indexed) {
93        double x = 0;
94        bool even = (idx ~/ 2) % 2 == 0;
95        final pos = (idx % 2) * 2 + (even ? 0 : 1);
96        for (int i = 0; i < pos; i++) {
97          x += maxSizes![i % 2];
98        }
99	//отрисовка вложенного объекта
100        context.paintChild(c, offset + Offset(x, y));
101        if (idx % 2 == 1) {
102          y += maxSizes![even ? 0 : 1];
103        }
104      }
105    }
106  }
107
108  List<double>? maxSizes;
109
110  @override
111  void performLayout() {
112    size = constraints.biggest;
113    const presetHeight = 128.0;
114    maxSizes = List.generate(2, (index) => 0.0);
115    for (final (idx, c) in children.indexed) {
116      final row = (idx ~/ 2) % 2;
117      final eval = c.getMaxIntrinsicWidth(presetHeight);
118      maxSizes![row] = max(maxSizes![row], eval);
119    }
120    //позиционируем по квадратам
121    for (final (idx, c) in children.indexed) {
122      final row = (idx ~/ 2) % 2;
123      c.layout(
124        BoxConstraints.tightFor(
125          width: maxSizes![row],
126          height: maxSizes![row],
127        ),
128      );
129    }
130  }
131}
132
133//Виджет для создания RenderObject
134class ChessboardContainer
135    extends SlottedMultiChildRenderObjectWidget<int, RenderChessboardItem> {
136  final List<Widget> children;
137
138  const ChessboardContainer({required this.children, super.key});
139
140  @override
141  Widget? childForSlot(int slot) => children[slot];
142
143  @override
144  SlottedContainerRenderObjectMixin<int, RenderChessboardItem>
145      createRenderObject(BuildContext context) => RenderChessboardContainer();
146
147  @override
148  Iterable<int> get slots => List.generate(children.length, (index) => index);
149}
150
151void main() {
152  runApp(ChessboardApp());
153}
154
155class ChessboardApp extends StatelessWidget {
156  @override
157  Widget build(BuildContext context) {
158    return const MaterialApp(
159      home: Scaffold(
160        body: SafeArea(
161          child: ChessboardContainer(
162            children: [
163              ChessboardItem(
164                background: Colors.green,
165                child: Text(
166                  'Item1',
167                  style: TextStyle(
168                    fontSize: 12,
169                    color: Colors.white,
170                  ),
171                ),
172              ),
173              ChessboardItem(
174                background: Colors.pink,
175                child: Text(
176                  'Item2-long',
177                  style: TextStyle(
178                    fontSize: 12,
179                    color: Colors.white,
180                  ),
181                ),
182              ),
183              ChessboardItem(
184                background: Colors.red,
185                child: Text(
186                  'Item3-very-long',
187                  style: TextStyle(
188                    fontSize: 12,
189                    color: Colors.white,
190                  ),
191                ),
192              ),
193              ChessboardItem(
194                background: Colors.deepPurpleAccent,
195                child: Text(
196                  'Item4-very-very-long',
197                  style: TextStyle(
198                    fontSize: 12,
199                    color: Colors.white,
200                  ),
201                ),
202              ),
203              ChessboardItem(
204                background: Colors.brown,
205                child: Text(
206                  'Item5-very-long',
207                  style: TextStyle(
208                    fontSize: 12,
209                    color: Colors.white,
210                  ),
211                ),
212              ),
213            ],
214          ),
215        ),
216      ),
217    );
218  }
219}

Прокручиваемые виджеты

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

В простой реализации виджета SingleChildScrollView (RenderObject имеет тип _RenderSingleChildViewport), вложенный RenderObject полностью отображается, а затем обрезается до видимой области. В более сложных ListView отображаются только те RenderObject, которые попадают в видимую область, плюс небольшой буфер с обеих сторон вдоль оси прокрутки.

Для прокручиваемых виджетов используется другой подход к измерению и размещению, чем для RenderBox, поскольку они имеют только один фиксированный размер и бесконечную длину в перпендикулярном направлении. Для размещения и передачи ограничений используются объекты класса SliverConstraints для определения направления основной и перпендикулярной оси, смещения прокручиваемого объекта, направления последней прокрутки и др. Эти данные применяются для принятия решений о положении видимой области и изменения размеров RenderObject, который в случае с прокручиваемыми виджетами должен наследоваться от RenderSliver.

Список RenderSliver-объектов может быть вставлен в приложение с использованием виджета CustomScrollView или вариантов ListView.custom / GridView.custom. Этот список, помимо виджетов, использующих реализацию протокола RenderSliver, может включать обычные реализации виджетов на основе протокола RenderBox через использование SliverToBoxAdapter или RenderSliverToBoxAdapter для встраивания реализаций RenderBox в прокручиваемый список, основанный на RenderSliverMultiBoxAdaptor.

В качестве примера создадим простой класс RenderSliver, который будет смещён относительно своего стандартного положения вниз, перекрывая следующие элементы. Функция размещения (performLayout) класса RenderSliver должна заполнить свойство geometry, объект которого определяет момент появления элемента в прокручиваемом списке (по основной оси), расположение следующего объекта, зону обнаружения касания и ширину по поперечной оси:

Поле

Описание

paintOrigin

смещение начальной линии объекта относительного его естественного положения

layoutExtent

положение следующего объекта в списке вдоль основной оси

scrollExtent

до какого смещения есть контент у текущего объекта

paintExtent

до какого смещения текущий объект должен быть отрисован (с учётом оставшейся области в прокручиваемом объекте, которая может быть извлечена из constraints.remainingPaintExtent)

maxPaintExtent

максимально возможное значение смещения, при котором объект ещё отрисовывается (без учёта оставшейся длины)

maxScrollObstructionExtent

максимальное смещение, при котором прикреплённый к краю объект ещё может менять свой размер (для любого смещения, превышающего это значение, объект сохраняет фиксированный размер и положение)

hitTestExtent

размер области между 0 и paintExtent, в которой принимаются события прикосновения / движения мыши

В constraints метод получает информацию о положении текущего элемента в прокручиваемом списке (scrollOffset), максимальном значении положения в списке (viewportMainAxisExtent), размере вдоль поперечной оси (crossAxisExtent), а также о направлении прокрутки, предыдущем и следующем состоянии.

1        import 'package:flutter/material.dart';
2        import 'package:flutter/rendering.dart';
3
4        void main() {
5          runApp(const ScrollViewPortApp());
6        }
7
8        final data = List.generate(1000, (index) => Text('$index'));
9
10        class ScrollViewPortApp extends StatelessWidget {
11          const ScrollViewPortApp({super.key});
12
13          @override
14          Widget build(BuildContext context) {
15            return MaterialApp(
16              home: Scaffold(
17                body: SafeArea(
18                  child: CustomScrollView(
19                    slivers: [
20                      //перед списком добавляем наш заголовок
21                      const PaddedSliver(
22                        child: SliverToBoxAdapter(
23                          child: Text(
24                            'HEADER',
25                            style: TextStyle(
26                              fontSize: 32,
27                              color: Colors.blue,
28                            ),
29                          ),
30                        ),
31                      ),
32                      SliverList(
33                        delegate: SliverChildListDelegate(data),
34                      ),
35                    ],
36                  ),
37                ),
38              ),
39            );
40          }
41        }
42
43        // Реализация виджета для создания RenderObject в модели ограничений RenderSliver
44        class PaddedSliver extends SingleChildRenderObjectWidget {
45          const PaddedSliver({
46            required super.child,
47            super.key,
48          });
49
50          @override
51          RenderObject createRenderObject(BuildContext context) => RenderPaddedSliver();
52        }
53
54        class RenderPaddedSliver extends RenderProxySliver {
55          @override
56          void performLayout() {
57            assert(child != null);
58            child!.layout(constraints);
59            geometry = child!.geometry?.copyWith(
60              paintOrigin: 8,   //смещение к содержанию
61              layoutExtent: child!.geometry!.layoutExtent + 8,   //смещение до начала видимой области
62              paintExtent: child!.geometry!.paintExtent + 24,    //смещение до границы содержания
63              maxPaintExtent: child!.geometry!.paintExtent + 24, 
64            );
65          }
66        }

Более подробно использование виджетов, основанных на RenderSliver, было рассмотрено в параграфе про Slivers.

Существующие RenderObject и виджеты

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

Задача

Какой виджет использовать

Примечание

Виджет с нестандартной отрисовкой

CustomPaint (или связанный с ним RenderCustomPaint)

делегирует выполнение метода paint на переданный объект класса CustomPainter (подробнее в этом параграфе — [ссылка на параграф по CustomPaint]);

Сложное размещение одного дочернего виджета

CustomSingleChildLayout

предоставляет методы для размещения и измерения виджета через реализацию SingleChildLayoutDelegate

Измерение и размещение нескольких виджетов

CustomMultiChildLayout

делегирует выполнение метода layout на реализацию класса MultiChildLayoutDelegate (позволяет выполнить оценку размера дочерних виджетов и их позиционирование на основе измерений)

Измерение виджета без отрисовки

Offstage

исключает вызов paint для child-виджета

Обнаружение событий прикосновения и жестов

Listener, RawGestureDetector, GestureDetector

Ограничить область перерисовки

RepaintBoundary

создаёт новый композиционный слой (OffsetLayer + PictureLayer) для переиспользования растрового изображения поддерева

Масштабирование и поворот содержания

Transform или RotatedBox

создаёт слой TransformLayer (кроме ситуации, когда используется только матрица сдвига Matrix4.translation, в этом случае сдвиг будет добавлен к offset следующего слоя и отдельный слой не будет создаваться)

Преобразования растрового изображения поддерева

ImageFiltered, BackdropFilter, ColorFiltered

создаёт соответствующие слои для преобразования растрового изображения

Обрезка содержания поддерева

ClipRect, ClipRRect, ClipPath, ClipOval, CustomClipper

создаёт слой для обрезки содержания по указанной фигуре

Создание собственной системы координат

Поскольку RenderObject не привязан изначально ни к какой системе координат и способу позиционирования объектов и делегирует эти задачи на реализации абстрактного класса Constraints и методов performLayout() / performResize(), то возможно создание собственной системы координат и размещения RenderObject в пространстве экрана. Мы рассмотрим простой пример создания трёхмерной системы координат с поддержкой перспективы (на основе использования преобразования с применением Matrix4).

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

Например, реализация может выглядеть следующим образом:

1class CubeConstraints extends Constraints {
2  CubeConstraints.zero()
3      : minConstraint = Vector3.zero(),
4        maxConstraint = Vector3.zero();
5
6  CubeConstraints.tight(Vector3 constraint)
7      : minConstraint = constraint,
8        maxConstraint = constraint;
9
10  CubeConstraints(this.minConstraint, this.maxConstraint);
11
12  Vector3 minConstraint;
13
14  Vector3 maxConstraint;
15
16  @override
17  bool get isNormalized => true;
18
19  @override
20  bool get isTight => minConstraint == maxConstraint;
21}
22

Для взаимодействия дочерних объектов с родительским также нужно будет реализовать класс для хранения данных ParentData.

1class CubeParentData extends ParentData {
2  Vector3 offset = Vector3.zero();
3}
4

Для удобства реализации также можно создать аналог RenderBox для работы в трёхмерном пространстве:

1abstract class RenderCube extends RenderObject {
2  Matrix4 _worldToScreen;
3
4  Matrix4 get worldToScreen => _worldToScreen;
5
6  RenderCube(this._worldToScreen);
7
8  set worldToScreen(Matrix4 matrix) {
9    _worldToScreen = matrix;
10    markNeedsLayout();
11  }
12
13  @override
14  void setupParentData(covariant RenderObject child) {
15    if (child.parentData is! CubeParentData) {
16      child.parentData = CubeParentData();
17    }
18  }
19
20  @override
21  void debugAssertDoesMeetConstraints() {}
22
23  @override
24  bool get sizedByParent => false;
25
26  @override
27  void performLayout() {}
28
29  @override
30  void performResize() {}
31
32  Vector3 get size;
33
34  @override
35  Rect get semanticBounds => paintBounds;
36}
37

Для примера создадим реализацию простой трёхмерной фигуры на основе RenderCube (единичный куб). В методе layout выполняем преобразования 3D-координат в проекцию на плоскость экрана после применения локальных преобразований для позиционирования, масштабирования и поворота, которые будут использоваться для определения границ прямоугольника отрисовки и семантики и в методе paint для построения каркасного изображения куба.

1class RenderShapeCube extends RenderCube {
2  Vector3 _center;
3
4  Matrix4? _transform; // преобразования в локальной системе координат
5
6  Vector3 get center => _center;
7
8  Matrix4? get transform => _transform;
9
10  set transform(Matrix4? _transform) {
11    this._transform = _transform;
12    markNeedsLayout();
13    markNeedsPaint();
14  }
15
16  set center(Vector3 _center) {
17    this._center = _center;
18    markNeedsLayout();
19    markNeedsPaint();
20  }
21
22  RenderShapeCube(this._center, this._transform, super.worldToScreen);
23
24  @override
25  void paint(PaintingContext context, Offset offset) {
26    // рисуем куб по точкам на экране
27    //      1---------5
28    //     /|        /|
29    //    / |       / |
30    //   0---------4  |
31    //   |  |      |  |
32    //   |  |      |  |
33    //   |  3------|--7
34    //   | /       | /
35    //   |/        |/
36    //   2---------6
37
38    final faces = [
39      [0, 4, 5, 1],
40      [2, 6, 7, 3],
41      [0, 1, 3, 2],
42      [4, 5, 7, 6],
43      [0, 4, 6, 2],
44      [1, 5, 7, 3],
45    ];
46    if (edges.isEmpty || edges.length < 8) return;
47    context.pushTransform(
48      true,
49      offset,
50      Matrix4.translation(Vector3(offset.dx, offset.dy, 0)),
51      (context, offset) {
52        final path = Path();
53        for (final face in faces) {
54          path.moveTo(edges[face[0]].x, edges[face[0]].y);
55          path.lineTo(edges[face[1]].x, edges[face[1]].y);
56          path.lineTo(edges[face[2]].x, edges[face[2]].y);
57          path.lineTo(edges[face[3]].x, edges[face[3]].y);
58          path.lineTo(edges[face[0]].x, edges[face[0]].y);
59        }
60        context.canvas.drawPath(
61          path,
62          Paint()
63            ..style = PaintingStyle.stroke
64            ..strokeWidth = 3
65            ..color = Colors.green,
66        );
67      },
68    );
69  }
70
71  @override
72  void performLayout() {
73    // рассчитываем координаты вершин куба при layout
74    super.performLayout();
75    edges = <Vector3>[];
76    for (int axe1 = 0; axe1 < 2; axe1++) {
77      for (int axe2 = 0; axe2 < 2; axe2++) {
78        for (int axe3 = 0; axe3 < 2; axe3++) {
79          final v = Vector3(
80            center.x + ((1 - axe1 * 2) / 2) * size.x,
81            center.y + ((1 - axe2 * 2) / 2) * size.y,
82            center.z + ((1 - axe3 * 2) / 2) * size.z,
83          );
84          if (_transform != null) {
85            v.applyMatrix4(_transform!);
86          }
87          v.applyProjection(worldToScreen);
88          edges.add(v);
89        }
90      }
91    }
92  }
93
94  List<Vector3> edges = [];
95
96  // определяем визуальную границу на экране
97  @override
98  Rect get paintBounds {
99    if (edges.isEmpty) return Rect.zero;
100    double minX = edges.map((e) => e.x).reduce(min);
101    double minY = edges.map((e) => e.y).reduce(min);
102    double maxX = edges.map((e) => e.x).reduce(max);
103    double maxY = edges.map((e) => e.y).reduce(max);
104    return Rect.fromLTRB(minX, minY, maxX, maxY);
105  }
106
107  @override
108  Vector3 get size => Vector3.all(1);
109}
110

И добавим виджет-обёртку для создания RenderObject:

1class ShapeCube extends LeafRenderObjectWidget {
2  final Vector3 center;
3
4  final Matrix4 worldToScreen;
5
6  final Matrix4? transform;
7
8  const ShapeCube({
9    required this.center,
10    required this.worldToScreen,
11    this.transform,
12    super.key,
13  });
14
15  @override
16  RenderObject createRenderObject(BuildContext context) => RenderShapeCube(center, transform, worldToScreen);
17
18  @override
19  void updateRenderObject(
20      BuildContext context, covariant RenderObject renderObject) {
21    (renderObject as RenderShapeCube)
22      ..center = center
23      ..transform = transform
24      ..worldToScreen = worldToScreen;
25  }
26}
27

Однако при попытке добавления виджета в приложение мы обнаружим ошибку, что для RenderView ожидается использование только RenderBox. Для решения этой проблемы можно создать RenderObject-адаптер, который будет размещать внутри себя RenderCube, но при этом сам реализовывать протокол RenderBox:

1class CubeToWidgetAdapter extends SingleChildRenderObjectWidget {
2  const CubeToWidgetAdapter({
3    super.key,
4    super.child,
5  });
6
7  @override
8  RenderObject createRenderObject(BuildContext context) =>
9      RenderCubeToWidgetAdapter();
10}
11
12class RenderCubeToWidgetAdapter extends RenderBox
13    with RenderObjectWithChildMixin<RenderCube> {
14  @override
15  void performLayout() {
16    child!.layout(constraints);
17    size = constraints.biggest;
18  }
19
20  @override
21  void paint(PaintingContext context, Offset offset) =>
22      context.paintChild(
23        child!,
24        Offset(constraints.biggest.width / 2, constraints.biggest.height / 2),
25      );
26}
27

И создадим виджет для отображения 3D-объекта с вращением в пространстве поверхности экрана:

1        import 'dart:math';
2
3        import 'package:flutter/material.dart';
4        import 'package:flutter/rendering.dart';
5        import 'package:vector_math/vector_math_64.dart'
6            show Matrix3, Matrix4, Quaternion, Vector3;
7
8        void main() {
9          runApp(const CubeApp());
10        }
11
12        class CubeApp extends StatelessWidget {
13          const CubeApp({super.key});
14
15          @override
16          Widget build(BuildContext context) {
17            return const MaterialApp(
18              home: Scaffold(
19                body: CubeAppWidget(),
20              ),
21            );
22          }
23        }
24
25        class CubeAppWidget extends StatefulWidget {
26          const CubeAppWidget({super.key});
27
28          @override
29          State<CubeAppWidget> createState() => _CubeAppWidgetState();
30        }
31
32        class _CubeAppWidgetState extends State<CubeAppWidget>
33            with SingleTickerProviderStateMixin {
34          //анимация вращения
35          late AnimationController animationController =
36              AnimationController(vsync: this, duration: const Duration(seconds: 10));
37
38          @override
39          void initState() {
40            super.initState();
41            animationController.repeat();
42          }
43
44          @override
45          void dispose() {
46            animationController.dispose();
47            super.dispose();
48          }
49
50          @override
51          Widget build(BuildContext context) {
52            //матрица для перспективной проекции
53            final worldToScreen = (Matrix4.identity()..setEntry(3, 2, 0.002));
54            return AnimatedBuilder(
55              animation: animationController,
56              //в RenderView может быть только RenderBox, поэтому добавляем адаптер, который создаст область для отображения 3D
57              builder: (context, _) => CubeToWidgetAdapter(
58                //в адаптер уже можем передавать 3D-фигуры
59                child: ShapeCube(
60                  transform: Matrix4.compose(
61                      Vector3.zero(),
62                      Quaternion.fromRotation(
63                        Matrix3.rotationX(animationController.value * 2 * pi)
64                            .multiplied(
65                          Matrix3.rotationY(animationController.value * 2 * pi * 3),
66                        ),
67                      ),
68                      Vector3.all(200)),
69                  center: Vector3.zero(),
70                  worldToScreen: worldToScreen,
71                ),
72              ),
73            );
74          }
75        }
76
77        //положение дочернего объекта сохраняем в родительском контейнере
78        class CubeParentData extends ParentData {
79          Vector3 offset = Vector3.zero();
80        }
81
82        //реализация ограничений в модели 3D
83        class CubeConstraints extends Constraints {
84          CubeConstraints.zero()
85              : minConstraint = Vector3.zero(),
86                maxConstraint = Vector3.zero();
87
88          const CubeConstraints.tight(Vector3 constraint)
89              : minConstraint = constraint,
90                maxConstraint = constraint;
91
92          const CubeConstraints(this.minConstraint, this.maxConstraint);
93
94          final Vector3 minConstraint;
95
96          final Vector3 maxConstraint;
97
98          @override
99          bool get isNormalized => true;
100
101          @override
102          bool get isTight => minConstraint == maxConstraint;
103        }
104
105        // RenderObject для отображения куба
106        class RenderShapeCube extends RenderCube {
107          Vector3 _center;    
108
109          Matrix4? _transform; //преобразования в локальной системе координат
110
111          Vector3 get center => _center;
112
113          Matrix4? get transform => _transform;
114
115          //при поворотах, масштабированиях или перемещении делаем повторное измерение и отрисовку
116          set transform(Matrix4? transform) {
117            _transform = transform;
118            markNeedsLayout();
119            markNeedsPaint();
120          }
121
122          set center(Vector3 center) {
123            _center = center;
124            markNeedsLayout();
125            markNeedsPaint();
126          }
127
128          RenderShapeCube(this._center, this._transform, super.worldToScreen);
129
130          @override
131          void paint(PaintingContext context, Offset offset) {
132            //рисуем куб по точкам на экране
133            //      1---------5
134            //     /|        /|
135            //    / |       / |
136            //   0---------4  |
137            //   |  |      |  |
138            //   |  |      |  |
139            //   |  3------|--7
140            //   | /       | /
141            //   |/        |/
142            //   2---------6
143
144            final faces = [
145              [0, 4, 5, 1],
146              [2, 6, 7, 3],
147              [0, 1, 3, 2],
148              [4, 5, 7, 6],
149              [0, 4, 6, 2],
150              [1, 5, 7, 3],
151            ];
152            if (edges.isEmpty || edges.length < 8) return;
153            //создаем фигуры со смещением в расположение начала координат на экране
154            context.pushTransform(
155              true,
156              offset,
157              Matrix4.translation(Vector3(offset.dx, offset.dy, 0)),
158              (context, offset) {
159                final path = Path();
160                for (final face in faces) {
161                  path.moveTo(edges[face[0]].x, edges[face[0]].y);
162                  path.lineTo(edges[face[1]].x, edges[face[1]].y);
163                  path.lineTo(edges[face[2]].x, edges[face[2]].y);
164                  path.lineTo(edges[face[3]].x, edges[face[3]].y);
165                  path.lineTo(edges[face[0]].x, edges[face[0]].y);
166                }
167                context.canvas.drawPath(
168                  path,
169                  Paint()
170                    ..style = PaintingStyle.stroke
171                    ..strokeWidth = 3
172                    ..color = Colors.green,
173                );
174              },
175            );
176          }
177
178          @override
179          void performLayout() {
180            //рассчитываем координаты вершин куба при layout
181            super.performLayout();
182            edges = <Vector3>[];
183            for (int axe1 = 0; axe1 < 2; axe1++) {
184              for (int axe2 = 0; axe2 < 2; axe2++) {
185                for (int axe3 = 0; axe3 < 2; axe3++) {
186                  final v = Vector3(
187                    center.x + ((1 - axe1 * 2) / 2) * size.x,
188                    center.y + ((1 - axe2 * 2) / 2) * size.y,
189                    center.z + ((1 - axe3 * 2) / 2) * size.z,
190                  );
191                  if (_transform != null) {
192                    v.applyMatrix4(_transform!);
193                  }
194                  v.applyProjection(worldToScreen);
195                  edges.add(v);
196                }
197              }
198            }
199          }
200
201          List<Vector3> edges = [];
202
203          //определяем визуальную границу на экране
204          @override
205          Rect get paintBounds {
206            if (edges.isEmpty) return Rect.zero;
207            double minX = edges.map((e) => e.x).reduce(min);
208            double minY = edges.map((e) => e.y).reduce(min);
209            double maxX = edges.map((e) => e.x).reduce(max);
210            double maxY = edges.map((e) => e.y).reduce(max);
211            return Rect.fromLTRB(minX, minY, maxX, maxY);
212          }
213
214          @override
215          Vector3 get size => Vector3.all(1);
216        }
217
218        //абстрактная 3D-фигура (аналог RenderBox)
219        abstract class RenderCube extends RenderObject {
220          Matrix4 _worldToScreen;
221
222          Matrix4 get worldToScreen => _worldToScreen;
223
224          RenderCube(this._worldToScreen);
225
226          set worldToScreen(Matrix4 matrix) {
227            _worldToScreen = matrix;
228            markNeedsLayout();
229          }
230
231          @override
232          void setupParentData(covariant RenderObject child) {
233            if (child.parentData is! CubeParentData) {
234              child.parentData = CubeParentData();
235            }
236          }
237
238          @override
239          void debugAssertDoesMeetConstraints() {}
240
241          @override
242          bool get sizedByParent => false;
243
244          @override
245          void performLayout() {}
246
247          @override
248          void performResize() {}
249
250          Vector3 get size;
251
252          @override
253          Rect get semanticBounds => paintBounds;
254        }
255
256        //Виджет, который порождает RenderObject с кубом
257        class ShapeCube extends LeafRenderObjectWidget {
258          final Vector3 center;
259
260          final Matrix4 worldToScreen;
261
262          final Matrix4? transform;
263
264          const ShapeCube({
265            required this.center,
266            required this.worldToScreen,
267            this.transform,
268            super.key,
269          });
270
271          @override
272          RenderObject createRenderObject(BuildContext context) =>
273              RenderShapeCube(center, transform, worldToScreen);
274
275          @override
276          void updateRenderObject(
277              BuildContext context, covariant RenderObject renderObject) {
278            //при изменении конфигурации обращается к set-методам, которые вызывает методы mark*
279            (renderObject as RenderShapeCube)
280              ..center = center
281              ..transform = transform
282              ..worldToScreen = worldToScreen;
283          }
284        }
285
286        class CubeToWidgetAdapter extends SingleChildRenderObjectWidget {
287          const CubeToWidgetAdapter({
288            super.key,
289            super.child,
290          });
291
292          @override
293          RenderObject createRenderObject(BuildContext context) =>
294              RenderCubeToWidgetAdapter();
295        }
296
297        class RenderCubeToWidgetAdapter extends RenderBox
298            with RenderObjectWithChildMixin<RenderCube> {
299          @override
300          void performLayout() {
301            child!.layout(constraints);
302            size = constraints.biggest;
303          }
304
305          //адаптер позиционирует 3D-фигуру в центр области
306          @override
307          void paint(PaintingContext context, Offset offset) => context.paintChild(
308                child!,
309                Offset(constraints.biggest.width / 2, constraints.biggest.height / 2),
310              );
311        }

Вот и всё! Это было тяжело, но вы справились и научились работать с RenderObject.

В следующем параграфе мы подробнее разберёмся с семантической разметкой интерфейса (о ней мы уже чуть-чуть поговорили) и научимся создавать приложения, которыми смогут пользоваться люди с ограниченными возможностями здоровья.

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

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

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.7. CustomPainter: продвинутые концепции
Следующий параграф3.9. Accessibility