3.13. RenderObject: продвинутые концепции
Автор: Дмитрий Золотов
RenderObject с одним объектом
Ранее мы рассматривали реализацию RenderObject для leaf-объектов, которые отвечают только за измерение своего размера, отрисовку и обработку семантической информации и касаний.
Но в действительности бывают ситуации, когда RenderObject не должен быть самостоятельной единицей информации, а выступает в роли декоратора или объекта, управляющего размещением другого RenderObject.
Мы рассмотрим реализацию на примере RenderObject, который будет задавать размер для дочернего объекта не более чем вполовину от собственного размера (от BoxConstraints.biggest), позиционировать его по центру своей области отрисовки и дополнительно создавать рамку вокруг вложенного объекта.
С точки зрения реализации будут сделаны следующие изменения:
- метод
performLayoutдолжен разместить дочерний объект по центру, предварительно выполнив его измерение; - метод
paintтеперь не только отображает собственное изображение (рамка), но и запрашивает отрисовку вложенного объекта; - базовый класс для создания виджета будет
SingleChildRenderObjectWidget(он принимает один аргументchildс типомWidget).
Код
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const HalfDecoratorApp());
}
class HalfDecorator extends SingleChildRenderObjectWidget {
const HalfDecorator({
required super.child,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
RenderHalfDecorator();
}
class RenderHalfDecorator extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
@override
void paint(PaintingContext context, Offset offset) {
context.canvas.drawRect(
offset & size,
Paint()
..style = PaintingStyle.stroke
..color = Colors.green);
final position = Offset(
(size.width - child!.size.width) / 2,
(size.height - child!.size.height) / 2,
);
context.paintChild(child!, offset + position);
context.canvas.drawRect(
offset + position & child!.size,
Paint()
..style = PaintingStyle.stroke
..color = Colors.yellow
..strokeWidth = 2,
);
}
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.biggest;
@override
void performLayout() {
//дочерний объект ограничиваем размером от 0 до половины нашего размера
child?.layout(constraints.copyWith(
minWidth: 0,
minHeight: 0,
maxWidth: constraints.maxWidth / 2,
maxHeight: constraints.maxHeight / 2,
));
//собственный размер - максимально возможный
size = constraints.biggest;
}
}
class HalfDecoratorApp extends StatelessWidget {
const HalfDecoratorApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(useMaterial3: false),
home: const Scaffold(
body: Center(
child: SizedBox(
width: 256,
height: 256,
child: HalfDecorator(
child: Text(
'I am decorated',
style: TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
),
),
),
);
}
}
При вызове метода 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.
Код
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class ChessboardItem extends SingleChildRenderObjectWidget {
final Color background;
const ChessboardItem({
required this.background,
required super.child,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
RenderChessboardItem(background);
}
class BackgroundColorParentData extends ParentData {
Color background;
BackgroundColorParentData(this.background);
}
class RenderChessboardItem extends RenderProxyBox {
final Color background;
RenderChessboardItem(this.background);
@override
double getMaxIntrinsicHeight(double width) {
super.getMaxIntrinsicHeight(width);
return child!.getMaxIntrinsicHeight(width);
}
@override
double getMinIntrinsicHeight(double width) {
super.getMinIntrinsicHeight(width);
return child!.getMinIntrinsicHeight(width);
}
@override
double getMaxIntrinsicWidth(double height) {
super.getMaxIntrinsicWidth(height);
return child!.getMaxIntrinsicWidth(height);
}
@override
double getMinIntrinsicWidth(double height) {
super.getMinIntrinsicWidth(height);
return child!.getMinIntrinsicWidth(height);
}
@override
void performLayout() {
child!.layout(constraints);
size = constraints.biggest;
}
@override
void paint(PaintingContext context, Offset offset) {
context.canvas.drawRect(
offset & size,
Paint()
..color =
(child!.parentData as BackgroundColorParentData).background);
context.paintChild(child!, offset);
}
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! ParentData) {
child.parentData = BackgroundColorParentData(background);
}
}
}
class RenderChessboardContainer extends RenderBox
with SlottedContainerRenderObjectMixin<int, RenderChessboardItem> {
@override
void paint(PaintingContext context, Offset offset) {
context.canvas.drawRect(
offset & size,
Paint()
..style = PaintingStyle.fill
..color = Colors.blueAccent);
if (maxSizes != null) {
double y = 0;
for (final (idx, c) in children.indexed) {
double x = 0;
bool even = (idx ~/ 2) % 2 == 0;
final pos = (idx % 2) * 2 + (even ? 0 : 1);
for (int i = 0; i < pos; i++) {
x += maxSizes![i % 2];
}
context.paintChild(c, offset + Offset(x, y));
if (idx % 2 == 1) {
y += maxSizes![even ? 0 : 1];
}
}
}
}
List<double>? maxSizes;
@override
void performLayout() {
size = constraints.biggest;
const presetHeight = 128.0;
maxSizes = List.generate(2, (index) => 0.0);
for (final (idx, c) in children.indexed) {
final row = (idx ~/ 2) % 2;
final eval = c.getMaxIntrinsicWidth(presetHeight);
maxSizes![row] = max(maxSizes![row], eval);
}
for (final (idx, c) in children.indexed) {
final row = (idx ~/ 2) % 2;
c.layout(
BoxConstraints.tightFor(
width: maxSizes![row],
height: maxSizes![row],
),
);
}
}
}
class ChessboardContainer
extends SlottedMultiChildRenderObjectWidget<int, RenderChessboardItem> {
final List<Widget> children;
const ChessboardContainer({required this.children, super.key});
@override
Widget? childForSlot(int slot) => children[slot];
@override
SlottedContainerRenderObjectMixin<int, RenderChessboardItem>
createRenderObject(BuildContext context) => RenderChessboardContainer();
@override
Iterable<int> get slots => List.generate(children.length, (index) => index);
}
void main() {
runApp(ChessboardApp());
}
class ChessboardApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: SafeArea(
child: ChessboardContainer(
children: [
ChessboardItem(
background: Colors.green,
child: Text(
'Item1',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
ChessboardItem(
background: Colors.pink,
child: Text(
'Item2-long',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
ChessboardItem(
background: Colors.red,
child: Text(
'Item3-very-long',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
ChessboardItem(
background: Colors.deepPurpleAccent,
child: Text(
'Item4-very-very-long',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
ChessboardItem(
background: Colors.brown,
child: Text(
'Item5-very-long',
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
],
),
),
),
);
}
}
Прокручиваемые виджеты
Использование слоя обрезки (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), а также о направлении прокрутки, предыдущем и следующем состоянии.
Код
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(const ScrollViewPortApp());
}
final data = List.generate(1000, (index) => Text('$index'));
class ScrollViewPortApp extends StatelessWidget {
const ScrollViewPortApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
//перед списком добавляем наш заголовок
const PaddedSliver(
child: SliverToBoxAdapter(
child: Text(
'HEADER',
style: TextStyle(
fontSize: 32,
color: Colors.blue,
),
),
),
),
SliverList(
delegate: SliverChildListDelegate(data),
),
],
),
),
),
);
}
}
// Реализация виджета для создания RenderObject в модели ограничений RenderSliver
class PaddedSliver extends SingleChildRenderObjectWidget {
const PaddedSliver({
required super.child,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) => RenderPaddedSliver();
}
class RenderPaddedSliver extends RenderProxySliver {
@override
void performLayout() {
assert(child != null);
child!.layout(constraints);
geometry = child!.geometry?.copyWith(
paintOrigin: 8, //смещение к содержанию
layoutExtent: child!.geometry!.layoutExtent + 8, //смещение до начала видимой области
paintExtent: child!.geometry!.paintExtent + 24, //смещение до границы содержания
maxPaintExtent: child!.geometry!.paintExtent + 24,
);
}
}
Более подробно использование виджетов, основанных на 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 для хранения координат вершин.
Например, реализация может выглядеть следующим образом
class CubeConstraints extends Constraints {
CubeConstraints.zero()
: minConstraint = Vector3.zero(),
maxConstraint = Vector3.zero();
CubeConstraints.tight(Vector3 constraint)
: minConstraint = constraint,
maxConstraint = constraint;
CubeConstraints(this.minConstraint, this.maxConstraint);
Vector3 minConstraint;
Vector3 maxConstraint;
@override
bool get isNormalized => true;
@override
bool get isTight => minConstraint == maxConstraint;
}
Для взаимодействия дочерних объектов с родительским также нужно будет реализовать класс для хранения данных ParentData.
class CubeParentData extends ParentData {
Vector3 offset = Vector3.zero();
}
Для удобства реализации также можно создать аналог RenderBox для работы в трёхмерном пространстве.
Код
abstract class RenderCube extends RenderObject {
Matrix4 _worldToScreen;
Matrix4 get worldToScreen => _worldToScreen;
RenderCube(this._worldToScreen);
set worldToScreen(Matrix4 matrix) {
_worldToScreen = matrix;
markNeedsLayout();
}
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! CubeParentData) {
child.parentData = CubeParentData();
}
}
@override
void debugAssertDoesMeetConstraints() {}
@override
bool get sizedByParent => false;
@override
void performLayout() {}
@override
void performResize() {}
Vector3 get size;
@override
Rect get semanticBounds => paintBounds;
}
Для примера создадим реализацию простой трёхмерной фигуры на основе RenderCube (единичный куб). В методе layout выполняем преобразования 3D-координат в проекцию на плоскость экрана после применения локальных преобразований для позиционирования, масштабирования и поворота, которые будут использоваться для определения границ прямоугольника отрисовки и семантики и в методе paint для построения каркасного изображения куба.
Код
class RenderShapeCube extends RenderCube {
Vector3 _center;
Matrix4? _transform; // преобразования в локальной системе координат
Vector3 get center => _center;
Matrix4? get transform => _transform;
set transform(Matrix4? _transform) {
this._transform = _transform;
markNeedsLayout();
markNeedsPaint();
}
set center(Vector3 _center) {
this._center = _center;
markNeedsLayout();
markNeedsPaint();
}
RenderShapeCube(this._center, this._transform, super.worldToScreen);
@override
void paint(PaintingContext context, Offset offset) {
// рисуем куб по точкам на экране
// 1---------5
// /| /|
// / | / |
// 0---------4 |
// | | | |
// | | | |
// | 3------|--7
// | / | /
// |/ |/
// 2---------6
final faces = [
[0, 4, 5, 1],
[2, 6, 7, 3],
[0, 1, 3, 2],
[4, 5, 7, 6],
[0, 4, 6, 2],
[1, 5, 7, 3],
];
if (edges.isEmpty || edges.length < 8) return;
context.pushTransform(
true,
offset,
Matrix4.translation(Vector3(offset.dx, offset.dy, 0)),
(context, offset) {
final path = Path();
for (final face in faces) {
path.moveTo(edges[face[0]].x, edges[face[0]].y);
path.lineTo(edges[face[1]].x, edges[face[1]].y);
path.lineTo(edges[face[2]].x, edges[face[2]].y);
path.lineTo(edges[face[3]].x, edges[face[3]].y);
path.lineTo(edges[face[0]].x, edges[face[0]].y);
}
context.canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.green,
);
},
);
}
@override
void performLayout() {
// рассчитываем координаты вершин куба при layout
super.performLayout();
edges = <Vector3>[];
for (int axe1 = 0; axe1 < 2; axe1++) {
for (int axe2 = 0; axe2 < 2; axe2++) {
for (int axe3 = 0; axe3 < 2; axe3++) {
final v = Vector3(
center.x + ((1 - axe1 * 2) / 2) * size.x,
center.y + ((1 - axe2 * 2) / 2) * size.y,
center.z + ((1 - axe3 * 2) / 2) * size.z,
);
if (_transform != null) {
v.applyMatrix4(_transform!);
}
v.applyProjection(worldToScreen);
edges.add(v);
}
}
}
}
List<Vector3> edges = [];
// определяем визуальную границу на экране
@override
Rect get paintBounds {
if (edges.isEmpty) return Rect.zero;
double minX = edges.map((e) => e.x).reduce(min);
double minY = edges.map((e) => e.y).reduce(min);
double maxX = edges.map((e) => e.x).reduce(max);
double maxY = edges.map((e) => e.y).reduce(max);
return Rect.fromLTRB(minX, minY, maxX, maxY);
}
@override
Vector3 get size => Vector3.all(1);
}
И добавим виджет-обёртку для создания RenderObject:
Код
class ShapeCube extends LeafRenderObjectWidget {
final Vector3 center;
final Matrix4 worldToScreen;
final Matrix4? transform;
const ShapeCube({
required this.center,
required this.worldToScreen,
this.transform,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) => RenderShapeCube(center, transform, worldToScreen);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
(renderObject as RenderShapeCube)
..center = center
..transform = transform
..worldToScreen = worldToScreen;
}
}
Однако при попытке добавления виджета в приложение мы обнаружим ошибку, что для RenderView ожидается использование только RenderBox. Для решения этой проблемы можно создать RenderObject-адаптер, который будет размещать внутри себя RenderCube, но при этом сам реализовывать протокол RenderBox:
Код
class CubeToWidgetAdapter extends SingleChildRenderObjectWidget {
const CubeToWidgetAdapter({
super.key,
super.child,
});
@override
RenderObject createRenderObject(BuildContext context) =>
RenderCubeToWidgetAdapter();
}
class RenderCubeToWidgetAdapter extends RenderBox
with RenderObjectWithChildMixin<RenderCube> {
@override
void performLayout() {
child!.layout(constraints);
size = constraints.biggest;
}
@override
void paint(PaintingContext context, Offset offset) =>
context.paintChild(
child!,
Offset(constraints.biggest.width / 2, constraints.biggest.height / 2),
);
}
И создадим виджет для отображения 3D-объекта с вращением в пространстве поверхности экрана:
Код
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:vector_math/vector_math_64.dart'
show Matrix3, Matrix4, Quaternion, Vector3;
void main() {
runApp(const CubeApp());
}
class CubeApp extends StatelessWidget {
const CubeApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: CubeAppWidget(),
),
);
}
}
class CubeAppWidget extends StatefulWidget {
const CubeAppWidget({super.key});
@override
State<CubeAppWidget> createState() => _CubeAppWidgetState();
}
class _CubeAppWidgetState extends State<CubeAppWidget>
with SingleTickerProviderStateMixin {
//анимация вращения
late AnimationController animationController =
AnimationController(vsync: this, duration: const Duration(seconds: 10));
@override
void initState() {
super.initState();
animationController.repeat();
}
@override
void dispose() {
animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
//матрица для перспективной проекции
final worldToScreen = (Matrix4.identity()..setEntry(3, 2, 0.002));
return AnimatedBuilder(
animation: animationController,
//в RenderView может быть только RenderBox, поэтому добавляем адаптер, который создаст область для отображения 3D
builder: (context, _) => CubeToWidgetAdapter(
//в адаптер уже можем передавать 3D-фигуры
child: ShapeCube(
transform: Matrix4.compose(
Vector3.zero(),
Quaternion.fromRotation(
Matrix3.rotationX(animationController.value * 2 * pi)
.multiplied(
Matrix3.rotationY(animationController.value * 2 * pi * 3),
),
),
Vector3.all(200)),
center: Vector3.zero(),
worldToScreen: worldToScreen,
),
),
);
}
}
//положение дочернего объекта сохраняем в родительском контейнере
class CubeParentData extends ParentData {
Vector3 offset = Vector3.zero();
}
//реализация ограничений в модели 3D
class CubeConstraints extends Constraints {
CubeConstraints.zero()
: minConstraint = Vector3.zero(),
maxConstraint = Vector3.zero();
const CubeConstraints.tight(Vector3 constraint)
: minConstraint = constraint,
maxConstraint = constraint;
const CubeConstraints(this.minConstraint, this.maxConstraint);
final Vector3 minConstraint;
final Vector3 maxConstraint;
@override
bool get isNormalized => true;
@override
bool get isTight => minConstraint == maxConstraint;
}
// RenderObject для отображения куба
class RenderShapeCube extends RenderCube {
Vector3 _center;
Matrix4? _transform; //преобразования в локальной системе координат
Vector3 get center => _center;
Matrix4? get transform => _transform;
//при поворотах, масштабированиях или перемещении делаем повторное измерение и отрисовку
set transform(Matrix4? transform) {
_transform = transform;
markNeedsLayout();
markNeedsPaint();
}
set center(Vector3 center) {
_center = center;
markNeedsLayout();
markNeedsPaint();
}
RenderShapeCube(this._center, this._transform, super.worldToScreen);
@override
void paint(PaintingContext context, Offset offset) {
//рисуем куб по точкам на экране
// 1---------5
// /| /|
// / | / |
// 0---------4 |
// | | | |
// | | | |
// | 3------|--7
// | / | /
// |/ |/
// 2---------6
final faces = [
[0, 4, 5, 1],
[2, 6, 7, 3],
[0, 1, 3, 2],
[4, 5, 7, 6],
[0, 4, 6, 2],
[1, 5, 7, 3],
];
if (edges.isEmpty || edges.length < 8) return;
//создаем фигуры со смещением в расположение начала координат на экране
context.pushTransform(
true,
offset,
Matrix4.translation(Vector3(offset.dx, offset.dy, 0)),
(context, offset) {
final path = Path();
for (final face in faces) {
path.moveTo(edges[face[0]].x, edges[face[0]].y);
path.lineTo(edges[face[1]].x, edges[face[1]].y);
path.lineTo(edges[face[2]].x, edges[face[2]].y);
path.lineTo(edges[face[3]].x, edges[face[3]].y);
path.lineTo(edges[face[0]].x, edges[face[0]].y);
}
context.canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = Colors.green,
);
},
);
}
@override
void performLayout() {
//рассчитываем координаты вершин куба при layout
super.performLayout();
edges = <Vector3>[];
for (int axe1 = 0; axe1 < 2; axe1++) {
for (int axe2 = 0; axe2 < 2; axe2++) {
for (int axe3 = 0; axe3 < 2; axe3++) {
final v = Vector3(
center.x + ((1 - axe1 * 2) / 2) * size.x,
center.y + ((1 - axe2 * 2) / 2) * size.y,
center.z + ((1 - axe3 * 2) / 2) * size.z,
);
if (_transform != null) {
v.applyMatrix4(_transform!);
}
v.applyProjection(worldToScreen);
edges.add(v);
}
}
}
}
List<Vector3> edges = [];
//определяем визуальную границу на экране
@override
Rect get paintBounds {
if (edges.isEmpty) return Rect.zero;
double minX = edges.map((e) => e.x).reduce(min);
double minY = edges.map((e) => e.y).reduce(min);
double maxX = edges.map((e) => e.x).reduce(max);
double maxY = edges.map((e) => e.y).reduce(max);
return Rect.fromLTRB(minX, minY, maxX, maxY);
}
@override
Vector3 get size => Vector3.all(1);
}
//абстрактная 3D-фигура (аналог RenderBox)
abstract class RenderCube extends RenderObject {
Matrix4 _worldToScreen;
Matrix4 get worldToScreen => _worldToScreen;
RenderCube(this._worldToScreen);
set worldToScreen(Matrix4 matrix) {
_worldToScreen = matrix;
markNeedsLayout();
}
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! CubeParentData) {
child.parentData = CubeParentData();
}
}
@override
void debugAssertDoesMeetConstraints() {}
@override
bool get sizedByParent => false;
@override
void performLayout() {}
@override
void performResize() {}
Vector3 get size;
@override
Rect get semanticBounds => paintBounds;
}
//Виджет, который порождает RenderObject с кубом
class ShapeCube extends LeafRenderObjectWidget {
final Vector3 center;
final Matrix4 worldToScreen;
final Matrix4? transform;
const ShapeCube({
required this.center,
required this.worldToScreen,
this.transform,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
RenderShapeCube(center, transform, worldToScreen);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
//при изменении конфигурации обращается к set-методам, которые вызывает методы mark*
(renderObject as RenderShapeCube)
..center = center
..transform = transform
..worldToScreen = worldToScreen;
}
}
class CubeToWidgetAdapter extends SingleChildRenderObjectWidget {
const CubeToWidgetAdapter({
super.key,
super.child,
});
@override
RenderObject createRenderObject(BuildContext context) =>
RenderCubeToWidgetAdapter();
}
class RenderCubeToWidgetAdapter extends RenderBox
with RenderObjectWithChildMixin<RenderCube> {
@override
void performLayout() {
child!.layout(constraints);
size = constraints.biggest;
}
//адаптер позиционирует 3D-фигуру в центр области
@override
void paint(PaintingContext context, Offset offset) => context.paintChild(
child!,
Offset(constraints.biggest.width / 2, constraints.biggest.height / 2),
);
}
Вот и всё! Это было тяжело, но вы справились и научились работать с RenderObject.