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

Автор: Дмитрий Золотов

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

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

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

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

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

  1. метод performLayout должен разместить дочерний объект по центру, предварительно выполнив его измерение;
  2. метод paint теперь не только отображает собственное изображение (рамка), но и запрашивает отрисовку вложенного объекта;
  3. базовый класс для создания виджета будет 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.