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.
