В этом параграфе мы обсудим внутреннюю организацию RenderObject
— механизма, который добавляет визуальное представление и оживляет наше приложение, создавая из пикселей стилизацию в Material Design или Human Interface Guidelines.
А заодно научимся создавать собственные объекты и применять оптимизации для достижения максимальной производительности.
Но прежде чем уходить в детали, давайте в общих чертах вспомним, как Flutter собирает интерфейс.
Итак, представим, что мы можем создать уникальный автомобиль, комбинируя его из частей и задавая характеристики для каждой части — например, цвет или мощность отдельных элементов. Детали создаются по нашему описанию на фабрике и поступают на сборочный конвейер, где объединяются в более крупные части и в конечном счёте — в готовый автомобиль.
Вы уже знакомы со «сборочным конвейером» — способами описания конфигурации и композиции деталей (дерево виджетов), определением их структуры и взаимоотношений в итоговом продукте (дерево элементов, доступное через BuildContext
). А с «фабрикой» — пока нет.
Как вы наверняка догадались, RenderObject
и есть та самая «фабрика». Этот механизм реализует физику и визуальное представление элементов, их поведение при взаимодействии с окружающей средой, определение ограничений их размеров (чтобы они поместились внутрь более крупных объектов) и многое другое.
Разберём его подробнее.
Что делает RenderObject
RenderObject
находится наиболее близко к Flutter Engine и непосредственно использует значительную часть Bindings (RendererBinding
, SchedulerBinding
, GestureBinding
, PaintingBinding
, SemanticsBinding
), которые отвечают за доступ к низкоуровневой реализации взаимодействия с графической подсистемой платформы, планировщиком кадров, детектором событий взаимодействия с устройствами ввода или экраном, а также за передачу в операционную систему семантической информации о положении и назначении визуальных элементов на экране.
В зоне ответственности RenderObject
находятся такие действия, как:
- Создание визуального представления на предоставленном контексте для рисования (фаза
paint
). Например, через графические библиотеки Skia/Impeller на экране мобильного телефона.RenderObject
использует множество оптимизаций и может переиспользовать ранее полученное изображение, сохранённое в растровом кэше (подробнее в параграфе про Bindings). - Размещение дочерних
RenderObject
(фазаlayout
). Например, в случае расположения нескольких объектов под управлением родительского в виджетахFlex
/Stack
и других. - Определение собственного размера (метод
performLayout
). При измерении используются ограничения от родителя, доступные в аргументеconstraints
, плюс могут также учитываться размеры дочерних объектов. После измерения размер может быть извлечён из свойстваsize
. - Реакция на события. Например, прикосновения к экрану или перемещения курсора мыши. Исходное событие обрабатывается в
hitTest
и создаёт список событий, который обрабатывается вhandleEvents
. - Передача информации о содержании и возможных операциях над
RenderObject
(describeSemanticsConfiguration
) для использования с альтернативными устройствами ввода-вывода. - Управление деревом
RenderObject
— связь с родительским объектом ссылкой черезparent
, передача информации в родительский объект черезparentData
, добавление и удаление новых дочерних объектов. - Регистрация диагностической информации о свойствах объекта для отображения в DevTools через переопределение метода
debugFillProperties
. - Предоставление доступа к растровому изображению дерева виджетов. Например, это можно использовать для применения пиксельных фильтров к визуальному представлению виджета или создания скриншотов. Реализация методов растеризации представлена в
RenderRepaintBoundary
.
С точки зрения фреймворка RenderObject
— это абстрактный класс, который не привязан к определённой системе координат и не ограничивает возможные способы компоновки объектов на экране. В большинстве практических задач вместо класса RenderObject
используется один из расширяющих его классов:
RenderBox
— ориентирован на работу с плоскими двумерными поверхностями (например, в области для рисования на экране мобильного устройства или окна приложения) и двумерную компоновку объектов на экране. Этот класс реализует модель иерархического размещения, где объекты-контейнеры позиционируют внутри себя дочерние объекты с использованием информации об их размерах и об ограничениях от родительских объектов.RenderSliver
— использует модель одномерного размещения в прокручиваемых по горизонтали или вертикали объектах. Применяется для создания элементов списков, зависящих от положения прокрутки. Например, для заголовков, изменяющихся в процессе прокрутки, или заголовков, которые присоединяются к верхнему краю и остаются неподвижными при дальнейшей прокрутке.RenderView
— основной контейнер для размещения всех остальныхRenderObject
, в большинстве случаев соответствует экрану телефона или окну приложения для Web/Desktop. Доступ кRenderView
из любогоRenderObject
может быть получен через обращение к свойствуpipelineOwner.rootNode
(более подробно проpipelineOwner
можно прочитать в параграфе про Bindings).RenderView
создаётся в методеinitRenderView
вRendererBinding
на основе конфигурации экрана и указателя на платформенную поверхность для рисования.- Вы можете создать собственный
RenderObject
, использующий другую модель ограничений, например для позиционирования в полярной системе координат или для размещения виджетов в трёхмерном пространстве.
Ниже мы последовательно разберёмся с каждым аспектом RenderObject
и покажем, как их использовать для решения реальных задач. Все классы, которые понадобятся нам в примерах, могут быть импортированы через import 'package:flutter/rendering.dart'
.
Пример использования RenderObject
Чтобы разобраться с механизмом работы RenderObject
, рассмотрим простой пример — изображение аналоговых часов.
Оно состоит из двух линий разной длины и толщины, которые обозначают часовую и минутную стрелку. В этом примере мы расширим RenderObject
с помощью RenderBox
, причём этот класс не управляет никакими другими вложенными объектами.
Для минимально возможного определения такого RenderObject
нам нужно будет решить следующие задачи:
- Определить размер
RenderBox
. Для простоты размер в первом примере будет фиксированный, впоследствии добавим поддержку внешних ограничений. - Нарисовать аналоговые часы для указанного времени, предусмотреть возможность изменения значения времени через конфигурацию связанного виджета.
RenderObject
обычно не создаются вручную, а управляются фреймворком с тем же жизненным циклом, что и у элемента. В действительности за создание и обновление RenderObject
отвечает базовый класс RenderObjectElement
, экземпляр объекта которого непосредственно создаётся в RenderObjectWidget
.
RenderObject
создаётся при добавлении RenderObjectElement
в дерево элементов в методе виджета createRenderObject
. Созданный RenderObject
присоединяется к родительскому объекту, который обнаруживается при поиске RenderObjectElement
по дереву элементов вверх. Изменение конфигурации виджета приводит к вызову метода updateRenderObject
, в котором реализуется обновление свойств RenderObject
для соответствия новой конфигурации.
Дерево RenderObject
— подмножество дерева виджетов, поскольку некоторые виджеты собираются из других виджетов, при этом сами не содержат связанных RenderObject
. Кроме того, в самом фреймворке и в библиотеках представлено множество виджетов, у которых нет визуального представления: они обеспечивают передачу данных, реализацию управления состоянием и многое другое. Для таких виджетов тоже не создаётся связанный RenderObject
.
Так, например, для дерева виджетов с изображения ниже создаётся соответствующее дерево элементов, которое преобразуется в дерево RenderObject
. Обратите внимание, что изначальное дерево виджетов было меньше: Image
— это StatefulWidget
и раскрывается в дополнительные виджеты Semantics
и RawImage
после вызова метода build
.
Кроме того, дерево RenderObject
содержит меньше объектов, чем дерево элементов (поскольку не все элементы имеют визуальное представление).
RenderObject
объединяются в дерево через сохранение родительского объекта в своём свойстве parent
и накопление списка дочерних объектов на этапе монтирования в дерево. Ссылка на родительский объект может использоваться для поиска объекта определённого типа по дереву вверх. Например, для нахождения ближайшего RenderRepaintBoundary
с собственным композиционным слоем для ограничения зоны обновления. Список дочерних объектов используется в методах visitChildren
и visitChildrenForSemantics
для выполнения действий с обходом дерева.
Также в родительском объекте сохраняется информация от дочерних объектов через свойство parentData
, которое первоначально инициализируется при присоединении объекта в дерево в методе setupParentData
.
На первом этапе мы будем встраивать новый RenderObject
через специальный виджет WidgetToRenderBoxAdapter
, который будет использовать наш объект для создания собственного визуального отображения.
Для определения последовательности операций отрисовки RenderBox
переопределим реализацию двух методов — performLayout
для определения размера RenderObject
и paint
для создания визуального представления. Метод paint
принимает два аргумента — PaintingContext
и offset
.
PaintingContext
обеспечивает возможность создания многослойного изображения с применением визуальных эффектов и кэширования и предоставляет canvas
для создания изображения. Более подробно про Canvas можно почитать в этом параграфе.
Offset
определяет смещение в пространстве экрана, на которое дополнительно могут накладываться трансформации, применённые ранее. Значение смещения определяется родительским объектом при вызове метода paintChild
из PaintingContext
.
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(),
debugShowCheckedModeBanner: false,
home: const Scaffold(
body: Center(
child: MyClockApp(),
),
),
);
}
}
class ClockRenderBox extends RenderBox {
final Size _ownSize; //размер области отрисовки
final Offset _offset; //дополнительное смещение
final double _hour; //значение часов (в 12-ти часовом формате)
final double _minute; //значение минут (0-59)
ClockRenderBox(
this._ownSize,
this._offset,
this._hour,
this._minute,
);
@override
void performLayout() => size = _ownSize;
@override
void paint(PaintingContext context, Offset offset) {
final center = _ownSize.center(offset);
final radius = _ownSize.shortestSide / 2;
final hourToRads = _hour / 12 * 2 * pi;
final minsToRads = _minute / 60 * 2 * pi;
final paintHours = Paint()
..style = PaintingStyle.fill
..strokeWidth = 5
..color = Colors.white;
final paintMins = Paint()
..style = PaintingStyle.fill
..strokeWidth = 2
..color = Colors.grey;
context.canvas.drawLine(
_offset + center,
_offset +
center +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
_offset + center,
_offset +
center +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
}
}
class MyClockApp extends StatelessWidget {
const MyClockApp({super.key});
@override
Widget build(BuildContext context) {
return WidgetToRenderBoxAdapter(
renderBox: ClockRenderBox(
const Size.square(256),
const Offset(64, 64),
13.0,
39.0,
),
);
}
}
Эта реализация работает корректно, но после создания мы не будем иметь возможности внести изменения в свойства отображаемого объекта. Для любого RenderObject
можно вручную выполнять управление его дочерними объектами, и мы можем только пересоздать новый экземпляр объекта и заменить его через dropChild
/adoptChild
.
Но при этом фреймворк не сможет оптимизировать обновление, поскольку будет рассматривать RenderObject
как новый и повторно запускать все этапы измерения, размещения и отрисовки объекта. Это негативно повлияет на производительность приложения. Более правильным решением будет использование методов RenderObject
для отправки уведомлений о необходимости выполнения одного или нескольких этапов обновления при обнаружении изменения свойств.
Давайте посмотрим, какие шаги выполняются фреймворком для каждого RenderObject
при первом запуске. Для этого изучим исходный код метода drawFrame
в RendererBinding
(комментарии автора кода):
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
if (sendFramesToEngine) {
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
_firstFrameSent = true;
}
- На первом этапе (
flushLayout
) всеRenderObject
измеряют вложенные объекты в соответствии с полученными от них размерами — или используя другой алгоритм, который не опирается на размеры, как, например, вStack
. - Второй этап (
compositingBits
) объединяет кэшированные изображения в пределах композиционного слоя, который создаётся в ближайшемRenderRepaintBoundary
. Возможность переиспользования растрового изображения возникает достаточно часто, когда изменяется положение объекта, но не его содержание, или объект не изменяется. - На третьем этапе (
paint
) создаются новые изображения для всехRenderObject
, которые запросили перерисовку. Этот шаг может быть пропущен, если кэшированная версия по-прежнему актуальна. - На четвертом этапе (
compositeFrame
) полученная сцена из кэшированных и отрисованных слоёв отправляется в Flutter Engine и преобразуется в растровое изображение на экране. - Ну и на последнем этапе (
flushSemantics
) в операционную систему отправляется информация о расположении объектов на экране и их назначении (подсказки для голосового помощника, возможные действия над объектами и так далее).
Объект pipelineOwner
создается фреймворком и отвечает за взаимодействие с деревом RenderObject
. Он обеспечивает реализацию жизненного цикла RenderObject
и сохраняет список объектов, которые были модифицированы с момента последнего вызова drawFrame
(то есть с предыдущего кадра).
Как вы помните, у элементов есть флаг dirty
— он сигнализирует о необходимости обновления. RenderObject
вместо флага использует несколько методов — они подсказывают pipelineOwner
, какие действия нужно выполнить на следующем кадре:
markNeedsLayout
— добавляет объект в ожидающие переразметки. Используется, например, при изменении свойств, влияющих на относительное положение объекта или на измерение размера RenderObject, например при добавлении отступов. На следующемdrawFrame
будет вызван методperformLayout
, если дляRenderObject
свойствоsizedByParent
принимает значениеfalse
. Обратите внимание, что в случае изменения размера и наличия родительского контейнера, который этот размер использует, также нужно вызвать методmarkParentNeedsLayout
, о нём ниже.markNeedsCompositingBitsUpdate
— помечает объект как требующий перекомпоновки составных частей. Чаще всего необходимо вызывать в случае, когда для вложенного объекта ожидается изменение визуального представления или применяется какой-либо эффект.markNeedsPaint
— добавляет объект в список требующих обновления визуального представления (на следующемdrawFrame
будет вызван методpaint
).markNeedsSemanticUpdate
— вызывается при необходимости сообщения новой семантической информации. Например, при изменении надписи на кнопке нужно об этом уведомить операционную систему.markParentNeedsLayout
— сообщение родительскому объекту об изменении размера от дочернего объекта. Например, если родительский объект выполняет относительное позиционирование вложенных объектов — какRenderFlex
, который соответствует виджетамColumn
/Row
/Flex
.markNeedsLayoutForSizedByParentChange
— вызывается в случае изменения значения флагаsizedByParent
, при этом объект измеряет себя самостоятельно или размер ему сообщает родительский объект.reassemble
— метод вызывает первые четыре метода из этого списка для себя и всего поддереваRenderObject
, может быть использован при необходимости форсированного обновления части дерева целиком.
Для нашего случая необходимо при изменении любого свойства вызывать метод markNeedsPaint()
, поскольку и значение времени, и положение/размер стрелок влияют на растровое отображение часов. При изменении размера также вызовем метод markNeedsLayout()
для обновления сохранённого в поле size
размера.
Для отслеживания изменения значений в конфигурации виджета во фреймворке чаще всего используется следующий подход:
- виджет с конфигурацией часов наследуется от базового класса
LeafRenderObjectWidget
; - в методе
createRenderObject
создаётся соответствующийRenderObject
для отображения часов; - в
RenderObject
создаются set-методы для изменения значений свойств и вызова необходимых методовmarkNeeds*
(в качестве побочного эффекта). Установка пометки необходимости обновления имеет смысл, только если значение действительно изменилось; - в методе
updateRenderObject
передаются изменения значений из конфигурации виджета вRenderObject
.
Также изменение значения часов и минут влияет на семантику отображаемого объекта. Вы это ещё не проходили, нюансы о семантике будут в следующем параграфе. Пока просто скажем, что нам нужно вызвать метод markNeedSemanticUpdate()
, ниже мы добавим отдельный метод для получения семантической информации.
Относительно измерения размера существует две тактики для RenderObject
:
sizedByParent
возвращаетtrue
, в этом случае размер может быть получен из свойстваsize
вRenderBox
(при изменении также вызываетсяperformResize
);sizedByParent
возвращаетfalse
, размер определяется виджетом самостоятельно в методеcomputeDryLayout
с использованием ограничений от родителя и сохраняется вsize
внутри обязательно реализованного методаperformLayout
. Это значение возвращается по умолчанию.
Теперь, поскольку размер известен, мы можем добавлять виджет с этим RenderObject
в любые контейнеры размещения, например в Column
. Дополнительно создадим простой виджет, обновляющий значения свойств в связанном RenderObject
.
Вот как это будет выглядеть в коде:
import 'package:flutter/material.dart';
import 'dart:math';
class Clock extends LeafRenderObjectWidget {
final Size size;
final Offset offset;
final double hour;
final double minute;
const Clock({
required this.size,
required this.offset,
required this.hour,
required this.minute,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
ClockRenderBox(size, offset, hour, minute);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
final clockRenderObject = renderObject as ClockRenderBox;
clockRenderObject
..ownSize = size
..offset = offset
..hour = hour
..minute = minute;
}
}
class ClockRenderBox extends RenderBox {
Size _size;
Offset _offset;
double _hour;
double _minute;
ClockRenderBox(
this._size,
this._offset,
this._hour,
this._minute,
);
@override
get sizedByParent => false;
@override
void performLayout() => size = _size;
set ownSize(Size newSize) {
if (newSize != _size) {
_size = newSize;
markNeedsPaint();
markNeedsLayout();
}
}
set offset(Offset offset) {
if (offset != _offset) {
_offset = offset;
markNeedsPaint();
}
}
set hour(double hour) {
if (hour != _hour) {
_hour = hour;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
set minute(double minute) {
if (minute != _minute) {
_minute = minute;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final center = size.center(offset + _offset);
final radius = size.shortestSide / 2;
final hourToRads = _hour / 12 * 2 * pi;
final minsToRads = _minute / 60 * 2 * pi;
final paintHours = Paint()
..style = PaintingStyle.fill
..strokeWidth = 5
..color = Colors.white;
final paintMins = Paint()
..style = PaintingStyle.fill
..strokeWidth = 2
..color = Colors.grey;
context.canvas.drawLine(
center,
center +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
center,
center +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
}
}
class ClockData {
Offset offset = Offset.zero;
Size size = const Size.square(128);
double hour = 0;
double minute = 0;
}
class MyClockApp extends StatefulWidget {
const MyClockApp({super.key});
@override
State<MyClockApp> createState() => _MyClockAppState();
}
class _MyClockAppState extends State<MyClockApp> {
final clockData = ClockData();
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(useMaterial3: false),
home: Scaffold(
body: SafeArea(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ElevatedButton(
onPressed: () =>
setState(() => clockData.offset += const Offset(1, 1)),
child: const Text('Shift'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.size *= 1.1),
child: const Text('Resize'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.hour++),
child: const Text('Increment hour'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.minute++),
child: const Text('Increment min'),
),
Clock(
size: clockData.size,
offset: clockData.offset,
hour: clockData.hour,
minute: clockData.minute,
),
]),
),
),
);
}
}
void main() {
runApp(const MyClockApp());
}
Теперь сделаем так, чтобы размер наших часов был не фиксированным, а зависел от размеров родителя.
Измерение, позиционирование и RenderBox-протокол
Изначально RenderObject
не использует никакую систему координат и делегирует реализацию абстрактного измерения на дочерние объекты. Для определения размера RenderObject
используется метод performLayout
, который вызывается, если sizedByParent
возвращает false
.
В противном случае, если sizedByParent
возвращает true
, размер дочернего объекта определяется на этапе разметки родительского объекта. Результатом выполнения performLayout
должно быть изменение поля size
, в которое сохраняется расчётный размер RenderObject
. Также на этом этапе могут быть переданы данные в родительский объект через свойство parentData
для управления позиционированием дочерних объектов внутри родительского.
Для управления измерением и размещением мы будем использовать реализацию пустой абстракции Constraints
.
RenderBox
использует модель двумерной поверхности и соответствующий тип ограничений BoxConstraints
, описывающий диапазон возможных размеров от минимального до максимального. В частном случае ограничение может быть:
- строгим (граничные значения совпадают);
- только сверху (минимальный размер —
0, 0
); - только снизу (максимальный размер — бесконечность).
Напомним основные идеи протокола RenderBox
:
- Родительские виджеты (на самом деле связанные с ними
RenderObject
) передают ограничения к дочернимRenderObject
. - Дочерние
RenderObject
измеряют себя и корректируют размер с учётом ограничений. При этом размер может остаться неизменным, уменьшиться при превышении верхнего ограничения или увеличиться, если от родителя пришла более высокая нижняя граница, чем ожидал объект. - Родительские
RenderObject
размещают дочерниеRenderObject
исходя из полученных измерений и информации, сохранённой вparentData
. Например, позиционируют по центру относительно максимального ограничения, как делает виджетCenter
и связанный с нимRenderPositionedBox
.
Согласно протоколу RenderBox
, каждый расширяющий его класс должен учитывать ограничения при определении собственного размера. В методе performLayout
есть доступ к полю объекта constraints
, которое поступает от родительского объекта, и оно должно использоваться для определения собственного размера RenderObject
. В нашей реализации для учёта ограничений от родителей необходимо выполнить следующие изменения:
@override
Size computeDryLayout(BoxConstraints constraints) => constraints.constrain(_size);
@override
void performLayout() => size = constraints.constrain(_size);
В коде добавим LimitedBox
для установки ограничений от родительского объекта и увидим, что масштабирование часов будет ограничено указанным размером.
import 'package:flutter/material.dart';
import 'dart:math';
class Clock extends LeafRenderObjectWidget {
final Size size;
final Offset offset;
final double hour;
final double minute;
const Clock({
required this.size,
required this.offset,
required this.hour,
required this.minute,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
ClockRenderBox(size, offset, hour, minute);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
final clockRenderObject = renderObject as ClockRenderBox;
clockRenderObject
..ownSize = size
..offset = offset
..hour = hour
..minute = minute;
}
}
class ClockRenderBox extends RenderBox {
Size _size;
Offset _offset;
double _hour;
double _minute;
ClockRenderBox(
this._size,
this._offset,
this._hour,
this._minute,
);
@override
get sizedByParent => false;
@override
Size computeDryLayout(BoxConstraints constraints) =>
constraints.constrain(_size);
@override
void performLayout() => size = constraints.constrain(_size);
set ownSize(Size newSize) {
if (newSize != _size) {
_size = newSize;
markNeedsPaint();
markNeedsLayout();
}
}
set offset(Offset offset) {
if (offset != _offset) {
_offset = offset;
markNeedsPaint();
}
}
set hour(double hour) {
if (hour != _hour) {
_hour = hour;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
set minute(double minute) {
if (minute != _minute) {
_minute = minute;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final center = size.center(offset + _offset);
final radius = size.shortestSide / 2;
final hourToRads = _hour / 12 * 2 * pi;
final minsToRads = _minute / 60 * 2 * pi;
final paintHours = Paint()
..style = PaintingStyle.fill
..strokeWidth = 5
..color = Colors.white;
final paintMins = Paint()
..style = PaintingStyle.fill
..strokeWidth = 2
..color = Colors.grey;
context.canvas.drawLine(
center,
center +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
center,
center +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
}
}
class ClockData {
Offset offset = Offset.zero;
Size size = const Size.square(128);
double hour = 0;
double minute = 0;
}
class MyClockApp extends StatefulWidget {
const MyClockApp({super.key});
@override
State<MyClockApp> createState() => _MyClockAppState();
}
class _MyClockAppState extends State<MyClockApp> {
final clockData = ClockData();
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(useMaterial3: false),
home: Scaffold(
body: SafeArea(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ElevatedButton(
onPressed: () =>
setState(() => clockData.offset += const Offset(1, 1)),
child: const Text('Shift'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.size *= 1.1),
child: const Text('Resize'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.hour++),
child: const Text('Increment hour'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.minute++),
child: const Text('Increment min'),
),
//добавили constraints, ограничивающие изменение размера до квадрата со стороной 200
LimitedBox(
maxWidth: 200,
maxHeight: 200,
child: Clock(
size: clockData.size,
offset: clockData.offset,
hour: clockData.hour,
minute: clockData.minute,
),
),
]),
),
),
);
}
}
void main() {
runApp(const MyClockApp());
}
Модель Constraints
допускает создание альтернативной системы ограничений, которая отличается от размещения двумерных объектов в пространстве экрана, и ниже мы рассмотрим пример с SliverConstraints
для позиционирования объектов в прокручиваемых списках.
А пока давайте расширим наш пример и добавим поддержку реакции на касания в области часов для управления минутной стрелкой. В этом нам поможет обратная связь и механизм реакции RenderObject
на внешние события.
Реакция на действия пользователя
При возникновении событий дерево RenderObject
получает сообщение через вызов метода hitTest
. Событием может быть прикосновение к экрану на мобильных устройствах или перемещение курсора на десктопных. Далее мы будем говорить только о прикосновениях к экрану.
Итак, метод hitTest
принимает позицию касания в относительных координатах основного слоя RenderObject
. В результате выполнения hitTest
может быть возвращено true
, если нужно остановить обработку события прикосновения, или false
, чтобы передать это сообщение другим RenderObject
, расположенным в той же области экрана.
Обработка начинается с вершины дерева, в котором расположен RenderView
, занимающий доступное пространство экрана для приложения, при этом каждый RenderObject
передаёт сообщение своим дочерним объектам, логика которых реализуется в методе hitTestChildren
.
Если RenderObject
размещён в каком-либо контейнере, например в Column
, сообщение будет отправлено последовательно всем дочерним объектам независимо от координаты точки касания. Обработка сообщения завершится на первом объекте, который вернёт true
, поэтому важно делать дополнительную проверку принадлежности координат точки касания прямоугольнику, включающему наш объект. С точки зрения проверки координаты точка касания должна находиться в интервале от (0, 0)
до size
.
Поскольку RenderObject
не хранит информацию о связанном виджете, для отправки уведомлений о произошедшем событии нужно использовать callback-функции, которые передаются в виджет и дальше сохраняются в RenderObject
.
class ClockRenderBox extends RenderBox {
Size _size;
Offset _offset;
double _hour;
double _minute;
ValueSetter<double> onUpdateMinutes;
ClockRenderBox(
this._size,
this._offset,
this._hour,
this._minute,
this.onUpdateMinutes,
);
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// проверка, что точка касания находится внутри прямоугольника RenderObject
if (!(Offset.zero & size).contains(position)) return false;
//регистрация события касания (будет передано в handleEvent)
result.add(BoxHitTestEntry(this, position));
return true;
}
@override
void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
// entry.localPosition здесь получит значение position из hitTest
final center = size / 2;
final position = entry.localPosition;
double angle =
atan2(position.dx - center.width, position.dy - center.height) + pi;
if (angle > 2 * pi) {
angle = angle - 2 * pi;
}
final minutes = (2 * pi - angle) / (2 * pi) * 60;
onUpdateMinutes(minutes);
}
...
}
class Clock extends LeafRenderObjectWidget {
final Size size;
final Offset offset;
final double hour;
final double minute;
final ValueSetter<double> onUpdateMinutes;
const Clock({
required this.size,
required this.offset,
required this.hour,
required this.minute,
required this.onUpdateMinutes,
super.key,
});
//...
}
При вызове виджета также передаём callback:
Clock(
size: clockData.size,
offset: clockData.offset,
hour: clockData.hour,
minute: clockData.minute,
onUpdateMinutes: (minutes) {
setState(() => clockData.minute = minutes);
},
),
Информация об обработке события может быть сохранена в объект HitTestResult
. Он собирает информацию о событиях взаимодействия с RenderObject
, а также о применённых трансформациях для трансляции экранной системы координат в координаты внутри виджета.
Полученные HitTestResult
в дальнейшем передаются в метод-обработчик handleEvents
, который также получает более подробную информацию о событии в PointerEvent
и значение относительных координат точки касания, полученное через BoxHitTestEntry
.
Вот как будет выглядеть наш код:
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:flutter/rendering.dart';
class Clock extends LeafRenderObjectWidget {
final Size size; //размер области отрисовки
final Offset offset; //дополнительное смещение
final double hour; //часы
final double minute; //минуты
final ValueSetter<double> onUpdateMinutes; //действие при изменении минут
const Clock({
required this.size,
required this.offset,
required this.hour,
required this.minute,
required this.onUpdateMinutes,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) =>
ClockRenderBox(size, offset, hour, minute, onUpdateMinutes);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
final clockRenderObject = renderObject as ClockRenderBox;
clockRenderObject
..ownSize = size
..offset = offset
..hour = hour
..minute = minute;
}
}
class ClockRenderBox extends RenderBox {
Size _size;
Offset _offset;
double _hour;
double _minute;
ValueSetter<double> onUpdateMinutes;
ClockRenderBox(
this._size,
this._offset,
this._hour,
this._minute,
this.onUpdateMinutes,
);
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
//проверка, что касание экрана произошло в прямоугольнике часов
if (!(Offset.zero & size).contains(position)) return false;
//если да, добавляем событие
result.add(BoxHitTestEntry(this, position));
return true;
}
@override
void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
//entry.localPosition здесь получит значение position из hitTest
final center = size / 2;
//переводим координаты точки касания в соответствующее значение угла
final position = entry.localPosition;
double angle =
atan2(position.dx - center.width, position.dy - center.height) + pi;
if (angle > 2 * pi) {
angle = angle - 2 * pi;
}
final minutes = (2 * pi - angle) / (2 * pi) * 60;
onUpdateMinutes(minutes);
}
@override
get sizedByParent => false;
@override
Size computeDryLayout(BoxConstraints constraints) =>
constraints.constrain(_size);
@override
void performLayout() => size = constraints.constrain(_size);
set ownSize(Size newSize) {
if (newSize != _size) {
_size = newSize;
markNeedsPaint();
markNeedsLayout();
}
}
set offset(Offset offset) {
if (offset != _offset) {
_offset = offset;
markNeedsPaint();
}
}
set hour(double hour) {
if (hour != _hour) {
_hour = hour;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
set minute(double minute) {
if (minute != _minute) {
_minute = minute;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final center = size.center(offset + _offset);
final radius = size.shortestSide / 2;
final hourToRads = _hour / 12 * 2 * pi;
final minsToRads = _minute / 60 * 2 * pi;
final paintHours = Paint()
..style = PaintingStyle.fill
..strokeWidth = 5
..color = Colors.white;
final paintMins = Paint()
..style = PaintingStyle.fill
..strokeWidth = 2
..color = Colors.grey;
context.canvas.drawLine(
center,
center +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
center,
center +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
}
}
class ClockData {
Offset offset = Offset.zero;
Size size = const Size.square(128);
double hour = 0;
double minute = 0;
}
class MyClockApp extends StatefulWidget {
const MyClockApp({super.key});
@override
State<MyClockApp> createState() => _MyClockAppState();
}
class _MyClockAppState extends State<MyClockApp> {
final clockData = ClockData();
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(useMaterial3: false),
home: Scaffold(
body: SafeArea(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ElevatedButton(
onPressed: () =>
setState(() => clockData.offset += const Offset(1, 1)),
child: const Text('Shift'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.size *= 1.1),
child: const Text('Resize'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.hour++),
child: const Text('Increment hour'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.minute++),
child: const Text('Increment min'),
),
//ограничитель размера области отрисовки
LimitedBox(
maxWidth: 200,
maxHeight: 200,
child: Clock(
size: clockData.size,
offset: clockData.offset,
hour: clockData.hour,
minute: clockData.minute,
onUpdateMinutes: (minutes) {
setState(() => clockData.minute = minutes);
},
),
),
]),
),
),
);
}
}
void main() {
runApp(const MyClockApp());
}
При обработке касаний также могут быть полезны методы из RenderBox
.
Метод |
Назначение |
localToGlobal(Offset) |
переход от локальных координат к экранным (координата 0,0 соответствует левому верхнему углу экрана, на мобильных устройствах находится под областью уведомлений) |
globalToLocal(Offset) |
для преобразований экранных координат в локальные для RenderObject |
getTransforTo(otherRenderObject) |
определение Matrix4-преобразования координат точки из локальной системы координат нашего RenderObject в систему координат другого RenderObject (например, для вывода визуальных отметок в родительский объект при касании дочернего) |
Также для использования стандартного поведения для RenderObject
с одним дочерним объектом (нажатие считается успешным при попадании точки касания в paintBounds
для RenderObject
) можно использовать базовый класс RenderProxyBoxWithHitBehavior
.
Теперь сделаем стрелки полупрозрачными. В этом нам поможет PaintingContext
.
Связь RenderObject и PaintingContext
PaintingContext
— это класс, который расширяет возможности Canvas
: позволяет создавать произвольные слои и добавлять визуальные эффекты. Например, прозрачность, различные преобразования растрового изображения — размытие, аффинные преобразования для масштабирования/сдвига/поворота и любых их комбинаций. Использование Canvas
не отличается от рассмотренного ранее в параграфе про CustomPainter
.
Углубляться в PaintingContext
мы сейчас не будем — боимся вас запутать. Но если вам любопытно, то вот ссылка на документацию. Пока что коротко отметим пару нюансов, которые имеют отношение к RenderObject
.
Итак, PaintingContext
использует модель слоёв. Если вы хоть раз работали в Photoshop или любом другом редакторе изображений — вы имеете представление о том, что это такое.
Если нет — представьте что вы смотрите сверху на стопку прозрачной бумаги, где на каждом листе нарисовано какое-то изображение. Все вместе они создают цельную картину, а по отдельности каждый их них — слой. Причём верхние слои перекрывают нижние.
Само собой, слои во Flutter устроены сложнее. Например, смещение изображения или добавление прозрачности — это тоже слои.
Для смещения изображения мы применяем OffsetLayer
(контейнерный слой). Он всегда создаётся автоматически для RenderObject
, у которого isRepaintBoundary
возвращает true
— тогда область перерисовки ограничивается областью экрана, занимаемой этим объектом.
Созданный OffsetLayer
доступен через свойство layer
. Если слой не был создан автоматически, будет использоваться ближайший контейнерный слой, который был найден выше по дереву, а значение layer
будет null
. Даже в этом случае слой может быть создан программно и записан в свойство layer
, тогда и canvas
будет использовать указанный слой для рисования.
Зная это, мы можем добавить эффект полупрозрачного отображения для стрелок наших часов и сдвинуть их в нужное место. Используем метод PaintingContext.pushOpacity()
, который создаёт новый слой прозрачности.
Под ним слой изображения, из которого мы можем получить canvas
через новый контекст.
context.pushOpacity(
center + offset,
64, // прозрачность (0—255, 0 — полностью прозрачный)
(context, offset) {
context.canvas.drawLine(
offset,
offset +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
offset,
offset +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
},
);
Теперь добавим анимацию прозрачности для нашего RenderObject
. Для этого разберёмся с моделью жизненного цикла и возможными состояниями объекта.
Жизненный цикл RenderObject
RenderObject
создаётся без привязки к дереву (поля owner
и parent
равны null
) и затем подключается в дерево в позицию, которая соответствует родительскому элементу в процессе встраивания нового элемента в своё дерево. Для этого в методе mount
для RenderObjectElement
создается экземпляр RenderObject
через вызов createRenderObject
из виджета.
Ответственность за присоединение дочерних объектов лежит на родительском объекте, и для каждого из них в первый раз вызывается метод attach
, который запускается однократно и может использоваться для инициализации связанных объектов, создания подписок и других возможных действий.
Аналогично при исключении RenderObject
из дерева, если создавший его виджет был перемещён или удалён, у RenderObject
вызывается метод detach
, который также должен выполнить обращение к detach
для всех дочерних объектов.
При этом RenderObject
может быть возвращён в другое место дерева — например, при использовании Hero
-анимации (более подробно про анимации можно почитать в этом параграфе) или перемещении виджетов с глобальными ключами. В случае если RenderObject
более не будет использоваться, вызывается метод dispose
.
В нашем примере мы можем использовать методы жизненного цикла для создания анимации прозрачности. Здесь мы получим Ticker
непосредственно в нашем классе, но более правильным будет решение создавать его в State
виджета и передавать в конструктор при создании:
import 'package:flutter/material.dart';
import 'dart:math';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
class Clock extends LeafRenderObjectWidget {
final Size size;
final Offset offset;
final double hour;
final double minute;
final ValueSetter<double> onUpdateMinutes;
final ValueSetter<double> onUpdateHours;
const Clock({
required this.size,
required this.offset,
required this.hour,
required this.minute,
required this.onUpdateMinutes,
required this.onUpdateHours,
super.key,
});
@override
RenderObject createRenderObject(BuildContext context) => ClockRenderBox(
size,
offset,
hour,
minute,
onUpdateMinutes,
onUpdateHours,
);
@override
void updateRenderObject(
BuildContext context, covariant RenderObject renderObject) {
final clockRenderObject = renderObject as ClockRenderBox;
clockRenderObject
..ownSize = size
..offset = offset
..hour = hour
..minute = minute;
}
}
class ClockRenderBox extends RenderBox implements TickerProvider {
Size _size;
Offset _offset;
double _hour;
double _minute;
ValueSetter<double> onUpdateMinutes;
ValueSetter<double> onUpdateHours;
AnimationController? _animationController;
ClockRenderBox(
this._size,
this._offset,
this._hour,
this._minute,
this.onUpdateMinutes,
this.onUpdateHours,
);
@override
get sizedByParent => false;
@override
Size computeDryLayout(BoxConstraints constraints) =>
constraints.constrain(_size);
@override
void performLayout() => size = constraints.constrain(_size);
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_animationController = AnimationController(
vsync: this,
lowerBound: 63,
upperBound: 255,
duration: const Duration(seconds: 1),
);
_animationController?.repeat();
_animationController?.addListener(markNeedsPaint);
}
@override
void detach() {
_animationController?.stop();
super.detach();
}
set ownSize(Size newSize) {
if (newSize != _size) {
_size = newSize;
markNeedsPaint();
markNeedsLayout();
}
}
set offset(Offset offset) {
if (offset != _offset) {
_offset = offset;
markNeedsPaint();
}
}
set hour(double hour) {
if (hour != _hour) {
_hour = hour;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
set minute(double minute) {
if (minute != _minute) {
_minute = minute;
markNeedsPaint();
markNeedsSemanticsUpdate();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final center = size.center(offset + _offset);
final radius = size.shortestSide / 2;
final hourToRads = _hour / 12 * 2 * pi;
final minsToRads = _minute / 60 * 2 * pi;
final paintHours = Paint()
..style = PaintingStyle.fill
..strokeWidth = 5
..color = Colors.white;
final paintMins = Paint()
..style = PaintingStyle.fill
..strokeWidth = 2
..color = Colors.grey;
context.pushOpacity(center, _animationController?.value.toInt() ?? 255,
(context, offset) {
context.canvas.drawLine(
offset,
offset +
Offset(
radius / 2 * cos(pi / 2 - hourToRads),
-radius / 2 * sin(pi / 2 - hourToRads),
),
paintHours,
);
context.canvas.drawLine(
offset,
offset +
Offset(
radius * cos(pi / 2 - minsToRads),
-radius * sin(pi / 2 - minsToRads),
),
paintMins,
);
});
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!(Offset.zero & size).contains(position)) return false;
result.add(BoxHitTestEntry(this, position));
return true;
}
@override
void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
final center = size / 2;
final position = entry.localPosition;
double angle =
atan2(position.dx - center.width, position.dy - center.height) + pi;
if (angle > 2 * pi) {
angle = angle - 2 * pi;
}
final minutes = (2 * pi - angle) / (2 * pi) * 60;
onUpdateMinutes(minutes);
}
Ticker? _ticker;
@override
Ticker createTicker(TickerCallback onTick) {
_ticker ??= Ticker(onTick);
return _ticker!;
}
}
class ClockData {
Offset offset = Offset.zero;
Size size = const Size.square(128);
double hour = 0;
double minute = 0;
}
class MyClockApp extends StatefulWidget {
const MyClockApp({super.key});
@override
State<MyClockApp> createState() => _MyClockAppState();
}
class _MyClockAppState extends State<MyClockApp> {
final clockData = ClockData();
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark(useMaterial3: false),
home: Scaffold(
body: SafeArea(
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
ElevatedButton(
onPressed: () =>
setState(() => clockData.offset += const Offset(1, 1)),
child: const Text('Shift'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.size *= 1.1),
child: const Text('Resize'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.hour++),
child: const Text('Increment hour'),
),
ElevatedButton(
onPressed: () => setState(() => clockData.minute++),
child: const Text('Increment min'),
),
LimitedBox(
maxWidth: 200,
maxHeight: 200,
child: Clock(
size: clockData.size,
offset: clockData.offset,
hour: clockData.hour,
minute: clockData.minute,
onUpdateMinutes: (minutes) {
setState(() => clockData.minute = minutes);
},
onUpdateHours: (hours) {
setState(() => clockData.hour = hours);
},
),
),
]),
),
),
);
}
}
void main() {
runApp(const MyClockApp());
}
Далее мы сделаем так, чтобы наши часы могли использовать люди с ограниченными возможностями здоровья. В этом нам поможет передача семантической информации.
Семантическая информация и обработка действий
Для мобильных и веб-приложений в операционной системе или браузере поддерживаются возможности Accessibility
, в которую со стороны Flutter Engine необходимо отправить следующую информацию:
- Передать размеры активного прямоугольника, который может подсвечиваться при использовании режима управления жестами для перемещения фокуса между элементами. Границы прямоугольника также используются некоторыми инструментами blackbox-тестирования, такими как
Appium
. ВRenderObject
семантические границы определяются get-методомsemanticBounds
и по умолчанию совпадают по размерам и положению с исходным объектом. - Определить через
describeSemanticsConfiguration
семантикуRenderObject
(информация из неё копируется вSemanticNode
и собирается в дерево семантики, которое отправляется в операционную систему или браузер). Семантика содержит большое количество полей и атрибутов, например:
Общая информация об объекте |
|
hint |
текстовая подсказка о назначении элемента (например, смысл действия при нажатии кнопки) |
label |
текстовое описание элемента (может использоваться синтезатором речи TalkBack / VoiceOver) |
increasedValue / decreasedValue |
новое значение после выполнения семантического действия увеличения/уменьшения значения |
currentValueLength / maxValueLength |
текущее/максимальное количество символов в редактируемом текстовом поле |
elevation |
значение по оси z для RenderObject относительно родительского объекта |
hintOverrides |
переопределение подсказок по умолчанию для платформы (например, уведомления, что это кнопка) |
indexInParent |
порядковый номер в родительском контейнере |
Флаги, показывающие состояние/возможности объекта |
|
isButton |
true, если это кнопка (может принимать фокус и выполнять действие «Нажать») |
isFocusable |
может принимать фокус (используется при навигации жестами) |
isFocused |
сейчас находится в фокусе |
isHeader |
является заголовком страницы |
isHidden |
не отображается (обычно игнорируется при озвучивании и навигации) |
isInMutuallyExclusiveGroup |
является частью взаимоисключающей группы (например, radio-кнопки) |
isLink |
является ссылкой (может быть предложено действие «Выполнить переход») |
isMultiline |
является многострочным полем |
isObscured |
значение поля должно быть скрыто (не должно быть произнесено вслух) |
isReadOnly |
поле доступно только для чтения |
isSelected |
текущий объект выбран (для checkbox/radio) |
isSlider |
является ли объект слайдером |
isTextField |
объект является текстовым полем (с возможностью ввода текста голосом или любыми устройствами ввода) |
isToggled |
объект включен (например, применяется для Switch) |
isSemanticBoundary |
необходимо создать собственный узел в дереве семантической информации для этого RenderObject |
isMergingSemanticsOfDescendant |
объединяет семантическую информацию дочерних элементов в общий объект |
Функции для вызова при получении сообщений об ожидаемых действиях |
|
onCopy |
получено сообщение SemanticsAction.copy (может быть произнесено голосом или иным способом, предоставляемым хост-системой, например через контекстное меню) |
onCut, onPaste |
аналогично для действий «Вырезать» и «Вставить» |
onIncrease / onDecrease |
действия со счётчиком, подразумевающие изменение значения (или выбор среди вариантов) |
onTap, onLongPress |
при обычном/долгом нажатии на объект |
setText |
замена текста (например, при голосовом вводе) |
setSelection |
изменение/расширение выделения текста |
onScrollUp, onScrollDown, onScrollLeft, onScrollRight, onMoveCursorForwardByCharacter, onMoveCursorForwardByWord, onMoveCursorBackwardByCharacter, onMoveCursorBackwardByWord |
перемещение с помощью курсора или комбинаций клавиш / специальных жестов |
customSemanticsActions |
связь произвольных семантических действий, определяются через CustomSemanticsAction(label: 'keyword'), и соответствующих функций-обработчиков |
Добавим для нашего примера поддержку семантической информации о значении времени, отображаемого на часах, а также дополнительные описания семантических действий для изменения текущего времени:
@override
Rect get semanticBounds => Offset.zero & size;
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
// текущее время, которое показывают часы
config.value = '$_hour hours and $_minute minutes';
//значение минутной стрелки после действий increment-decrement
config.decreasedValue = _minute.toInt().toString();
config.increasedValue = _minute.toInt().toString();
config.onDecrease = () {
// изменение времени (перемещение минутной стрелки назад)
_minute--;
if (_minute < 0) {
_minute = 60 + _minute;
_hour--;
if (_hour < 0) _hour = 24 + _hour;
}
onUpdateMinutes(_minute);
onUpdateHours(_hour);
markNeedsSemanticsUpdate();
};
config.onIncrease = () {
// изменение времени (перемещение минутной стрелки вперёд)
_minute++;
if (_minute >= 60) {
// также отслеживаем часовую стрелку
_minute = _minute - 60;
_hour = (_hour + 1) % 24;
}
onUpdateMinutes(_minute);
onUpdateHours(_hour);
markNeedsSemanticsUpdate();
};
config.onTap = () {
// семантическое действие «Нажать» переводит часовую стрелку
_hour = (_hour + 1) % 24;
onUpdateHours(_hour);
markNeedsSemanticsUpdate();
};
// голосовая подсказка для действия при нажатии на RenderObject часов
config.hint = 'Tap me to increment hours';
}
Более подробно использование виджетов семантической разметки и управления семантическим деревом будет рассмотрено в следующем параграфе.
Теперь давайте сделаем так, чтобы мы могли легче находить возможные ошибки.
Отладка RenderObject с использованием DevTools
Один из наиболее важных способов отладки RenderObject
— инструменты DevTools, в которых можно получить информацию об эффективном дереве виджетов (полученном после вызовов build
для StatelessWidget
/ StatefulWidget
), соответствующем дереве элементов и связанным с ним состоянием (если есть), а также о присоединённом объекте RenderObject
.
Дополнительная информация для DevTools может быть добавлена как для RenderObject
, так и для элементов и виджетов, а также для любых объектов, которые используются в конфигурации виджета.
Чтобы описать объект, необходимо добавить к нему миксин Diagnosticable
и переопределить метод debugFillProperties
, который возвращает список свойств из реализаций класса DiagnosticableNode
(примеры — в таблице ниже). Для описания дочерних объектов контейнера нужно переопределить метод debugDescribeChildren
и добавить миксин DiagnosticableTreeMixin
.
Поле |
Описание |
DiagnosticsProperty |
именованное значение произвольного типа (может также содержать описание и множество параметров для настройки отображения), также определяется уровень сообщения (константы из DiagnosticsLevel: hidden — не показывать, fine, debug, warning, info, hint, summary, error, off), который может быть использован для фильтрации сообщений через аргумент minLevel |
IterableProperty |
список значений произвольного типа |
EnumProperty |
значение перечисляемого типа |
FlagProperty |
логическое именованное значение |
StringProperty, IntProperty, DoubleProperty, ColorProperty, PercentProperty |
используются для представления соответствующих типов данных |
DiagnosticsBlock |
группировка значений (содержит список children для DiagnosticsProperty) |
DiagnosticableTreeNode |
подэлемент дерева объектов, связанных с виджетом |
ErrorSummary, ErrorDescription, ErrorHint |
разные уровни ошибок (используются уровни DiagnosticsLevel error, info и hint) |
Также может быть переопределён метод toDiagnosticsNode
— для создания собственного представления RenderObject
в DevTools.
По умолчанию реализация debugFillProperties
в RenderObject
сохраняет информацию о parentData
, полученных ограничениях и измеренном размере объекта, но метод может быть переопределён для добавления собственных значений, важных для описания состояния объекта, например:
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsNode.message('This is a clock renderobject'));
properties.add(DiagnosticsProperty('hour', _hour));
properties.add(DiagnosticsProperty('minute', _minute));
properties.add(DiagnosticsProperty('offset', _offset));
}
Полученные значения в debugFillProperties
используются при выводе RenderObject
в DevTools или при вызове describeForError
, который используется в стеке ошибки, связанном с некорректным поведением или разметкой в RenderObject
.
Также список свойств можно получить через явный вызов toDiagnosticsNode().getProperties()
.
Кроме того, для отладки области отрисовки могут использоваться debug-флаги:
Поле |
Описание |
debugPaintSizeEnabled |
показывать границы измеренных областей (с учётом полученных от RenderObject размеров) |
debugPaintBaselinesEnabled |
построить базовую линию (нижняя граница для текстовых виджетов, за исключением символов с «хвостиками») |
debugPaintLayerBordersEnabled |
включить вокруг каждого слоя рамки для визуализации его границ |
debugPaintPointersEnabled |
подсвечивать RenderObject при возникновении события касания и получения от hitTest значения true (реализовано не во всех RenderObject, поддерживается в методе debugHandleEvent) |
debugRepaintRainbowEnabled |
изменять цвет рамки при каждом вызове paint от RenderObject |
debugRepaintTextRainbowEnabled |
изменять цвет текста при каждой перерисовке |
debugPrintMarkNeedsLayoutStacks |
отображать стек вызовов для каждого обращения к markNeedsLayout |
debugPrintMarkNeedsPaintStacks |
отображать стек вызовов для обращений к markNeedsPaint |
debugProfileLayoutsEnabled |
добавлять метки для вызова performLayout на линию времени в DevTools |
debugProfilePaintsEnabled |
добавлять метки для каждого обращения к paint на линию времени в DevTools |
debugEnhanceLayoutTimelineArguments, debugEnhancePaintTimelineArguments |
расширять информацию о событии вызова performLayout/paint с использованием диагностической информации из debugFillDiagnostics() |
debugOnProfilePaint |
определить функцию для вызова при каждой отрисовке RenderObject (принимает его как аргумент) |
Также есть несколько флагов для отключения различных типов слоёв:
debugDisableClipLayers
— отключить слои обрезки (Rect
,RRect
,Path
);debugDisablePhysicalShapeLayers
— отключить создание слоёвPhysicalShape
(поверхностьMaterial
с тенью);debugDisableOpacityLayers
— отключить создание слоёв с полупрозрачностью (для проверки их влияния на производительность).
При отладке также можно использовать функции для вывода в консоль текущих деревьев:
debugDumpRenderTree()
— отобразить деревоRenderObject
(начиная сRenderView
);debugDumpSemanticsTree()
— вывести деревоSemanticsNode
(используется подсистемой accessibility);debugDumpLayerTree()
— показывать дерево слоёв (с указанием типа и характеристик слоя, границ в координатах экрана, а также связи с деревом виджетов);debugDumpApp()
— вывести дерево виджетов и связанных с нимиRenderObject
(как в DevTools, но в виде строки в консоли).
На этом с часами всё — мы создали, декорировали и анимировали наш виджет — а ещё сделали так, чтобы им было удобно пользоваться людям с ограниченными возможностями.
Дальше мы рассмотрим оставшиеся нюансы 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],
),
);
}
}
}
//Виджет для создания RenderObject
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
.
В следующем параграфе мы подробнее разберёмся с семантической разметкой интерфейса (о ней мы уже чуть-чуть поговорили) и научимся создавать приложения, которыми смогут пользоваться люди с ограниченными возможностями здоровья.