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
79class RenderChessboardContainer extends RenderBox
80 with SlottedContainerRenderObjectMixin<int, RenderChessboardItem> {
81 @override
82 void paint(PaintingContext context, Offset offset) {
83 context.canvas.drawRect(
84 offset & size,
85 Paint()
86 ..style = PaintingStyle.fill
87 ..color = Colors.blueAccent);
88 if (maxSizes != null) {
89 double y = 0;
90 for (final (idx, c) in children.indexed) {
91 double x = 0;
92 bool even = (idx ~/ 2) % 2 == 0;
93 final pos = (idx % 2) * 2 + (even ? 0 : 1);
94 for (int i = 0; i < pos; i++) {
95 x += maxSizes![i % 2];
96 }
97 context.paintChild(c, offset + Offset(x, y));
98 if (idx % 2 == 1) {
99 y += maxSizes![even ? 0 : 1];
100 }
101 }
102 }
103 }
104
105 List<double>? maxSizes;
106
107 @override
108 void performLayout() {
109 size = constraints.biggest;
110 const presetHeight = 128.0;
111 maxSizes = List.generate(2, (index) => 0.0);
112 for (final (idx, c) in children.indexed) {
113 final row = (idx ~/ 2) % 2;
114 final eval = c.getMaxIntrinsicWidth(presetHeight);
115 maxSizes![row] = max(maxSizes![row], eval);
116 }
117 for (final (idx, c) in children.indexed) {
118 final row = (idx ~/ 2) % 2;
119 c.layout(
120 BoxConstraints.tightFor(
121 width: maxSizes![row],
122 height: maxSizes![row],
123 ),
124 );
125 }
126 }
127}
128
129class ChessboardContainer
130 extends SlottedMultiChildRenderObjectWidget<int, RenderChessboardItem> {
131 final List<Widget> children;
132
133 const ChessboardContainer({required this.children, super.key});
134
135 @override
136 Widget? childForSlot(int slot) => children[slot];
137
138 @override
139 SlottedContainerRenderObjectMixin<int, RenderChessboardItem>
140 createRenderObject(BuildContext context) => RenderChessboardContainer();
141
142 @override
143 Iterable<int> get slots => List.generate(children.length, (index) => index);
144}
145
146void main() {
147 runApp(ChessboardApp());
148}
149
150class ChessboardApp extends StatelessWidget {
151 @override
152 Widget build(BuildContext context) {
153 return const MaterialApp(
154 home: Scaffold(
155 body: SafeArea(
156 child: ChessboardContainer(
157 children: [
158 ChessboardItem(
159 background: Colors.green,
160 child: Text(
161 'Item1',
162 style: TextStyle(
163 fontSize: 12,
164 color: Colors.white,
165 ),
166 ),
167 ),
168 ChessboardItem(
169 background: Colors.pink,
170 child: Text(
171 'Item2-long',
172 style: TextStyle(
173 fontSize: 12,
174 color: Colors.white,
175 ),
176 ),
177 ),
178 ChessboardItem(
179 background: Colors.red,
180 child: Text(
181 'Item3-very-long',
182 style: TextStyle(
183 fontSize: 12,
184 color: Colors.white,
185 ),
186 ),
187 ),
188 ChessboardItem(
189 background: Colors.deepPurpleAccent,
190 child: Text(
191 'Item4-very-very-long',
192 style: TextStyle(
193 fontSize: 12,
194 color: Colors.white,
195 ),
196 ),
197 ),
198 ChessboardItem(
199 background: Colors.brown,
200 child: Text(
201 'Item5-very-long',
202 style: TextStyle(
203 fontSize: 12,
204 color: Colors.white,
205 ),
206 ),
207 ),
208 ],
209 ),
210 ),
211 ),
212 );
213 }
214}
Прокручиваемые виджеты
Использование слоя обрезки (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
.