В этом параграфе мы обсудим внутреннюю организацию 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
используется один из расширяющих его классов:
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
нам нужно будет решить следующие задачи:
- Определить размер
RenderBox
. Для простоты размер в первом примере будет фиксированный, впоследствии добавим поддержку внешних ограничений. - Нарисовать аналоговые часы для указанного времени, предусмотреть возможность изменения значения времени через конфигурацию связанного виджета.
RenderObject
обычно не создаются вручную, а управляются фреймворком с тем же жизненным циклом, что и у элемента. В действительности за создание и обновление RenderObject
отвечает базовый класс RenderObjectElement
, экземпляр объекта которого непосредственно создаётся в RenderObjectWidget
.
RenderObject
создаётся при добавлении RenderObjectElement
в дерево элементов в методе виджета createRenderObject
. Созданный RenderObject
присоединяется к родительскому объекту, который обнаруживается при поиске RenderObjectElement
по дереву элементов вверх. Изменение конфигурации виджета приводит к вызову метода updateRenderObject
, в котором реализуется обновление свойств RenderObject
для соответствия новой конфигурации.
Дерево RenderObject
— подмножество дерева виджетов, поскольку некоторые виджеты собираются из других виджетов, при этом сами не содержат связанных RenderObject
. Кроме того, в самом фреймворке и в библиотеках представлено множество виджетов, у которых нет визуального представления: они обеспечивают передачу данных, реализацию управления состоянием и многое другое. Для таких виджетов тоже не создаётся связанный RenderObject
.
Так, например, для дерева виджетов с изображения ниже создаётся соответствующее дерево элементов, которое преобразуется в дерево RenderObject
. Обратите внимание, что изначальное дерево виджетов было меньше: Image
— это StatefulWidget
и раскрывается в дополнительные виджеты Semantics
и RawImage
после вызова метода build
.
Кроме того, дерево RenderObject
содержит меньше объектов, чем дерево элементов (поскольку не все элементы имеют визуальное представление).
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](https://yastatic.net/s3/education-portal/media/image_7d715b5f6c_c2754c2c7e.webp)
Кроме того, для отладки области отрисовки могут использоваться 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
), позиционировать его по центру своей области отрисовки и дополнительно создавать рамку вокруг вложенного объекта.
С точки зрения реализации будут сделаны следующие изменения:
- метод
performLayout
должен разместить дочерний объект по центру, предварительно выполнив его измерение; - метод
paint
теперь не только отображает собственное изображение (рамка), но и запрашивает отрисовку вложенного объекта; - базовый класс для создания виджета будет
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
.
В следующем параграфе мы подробнее разберёмся с семантической разметкой интерфейса (о ней мы уже чуть-чуть поговорили) и научимся создавать приложения, которыми смогут пользоваться люди с ограниченными возможностями здоровья.