3.13. RenderObject: продвинутые концепции

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

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

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

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

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

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

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

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

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

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

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

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

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

Виджеты-контейнеры, такие как ColumnStack и 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.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

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

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