В этом параграфе мы обсудим внутреннюю организацию RenderObject — механизма, который добавляет визуальное представление и оживляет наше приложение, создавая из пикселей стилизацию в Material Design или Human Interface Guidelines.
Но прежде чем уходить в детали, давайте в общих чертах вспомним, как Flutter собирает интерфейс.
Коротко о том, как Flutter собирает интерфейс
Итак, представим, что мы можем создать уникальный автомобиль, комбинируя его из частей и задавая характеристики для каждой части — например, цвет или мощность отдельных элементов. Детали создаются по нашему описанию на фабрике и поступают на сборочный конвейер, где объединяются в более крупные части и в конечном счёте — в готовый автомобиль.
Вы уже знакомы со «сборочным конвейером» — способами описания конфигурации и композиции деталей (дерево виджетов), определением их структуры и взаимоотношений в итоговом продукте (дерево элементов, доступное через BuildContext). А с «фабрикой» — пока нет.
Как вы наверняка догадались, RenderObject и есть та самая «фабрика». Этот механизм реализует физику и визуальное представление элементов, их поведение при взаимодействии с окружающей средой, определение ограничений их размеров (чтобы они поместились внутрь более крупных объектов) и многое другое.
Разберём его подробнее.
Что делает RenderObject
RenderObject находится наиболее близко к Flutter Engine и непосредственно использует значительную часть сервисов связи (Bindings), которые отвечают за доступ к низкоуровневой реализации взаимодействия с графической подсистемой платформы, планировщиком кадров, детектором событий взаимодействия с устройствами ввода или экраном, а также за передачу в операционную систему семантической информации о положении и назначении визуальных элементов на экране.
Коротко вспомним уже знакомую вам иллюстрацию, чтобы быть в контексте:
В зоне ответственности RenderObject находятся такие действия, как:
- Создание визуального представления на предоставленном контексте для рисования (фаза
paint). Например, через графические библиотеки Skia/Impeller на экране мобильного телефона.RenderObjectиспользует множество оптимизаций и может переиспользовать ранее полученное изображение, сохранённое в растровом кэше. - Размещение дочерних
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можно прочитать в параграфе про сервисы связи).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.
Код
1 import 'package:flutter/material.dart';
2 import 'dart:math';
3
4 void main() {
5 runApp(MyApp());
6 }
7
8 class MyApp extends StatelessWidget {
9 @override
10 Widget build(BuildContext context) {
11 return MaterialApp(
12 theme: ThemeData.dark(),
13 debugShowCheckedModeBanner: false,
14 home: const Scaffold(
15 body: Center(
16 child: MyClockApp(),
17 ),
18 ),
19 );
20 }
21 }
22
23 class ClockRenderBox extends RenderBox {
24 final Size _ownSize; //размер области отрисовки
25 final Offset _offset; //дополнительное смещение
26 final double _hour; //значение часов (в 12-ти часовом формате)
27 final double _minute; //значение минут (0-59)
28
29 ClockRenderBox(
30 this._ownSize,
31 this._offset,
32 this._hour,
33 this._minute,
34 );
35
36 @override
37 void performLayout() => size = _ownSize;
38
39 @override
40 void paint(PaintingContext context, Offset offset) {
41 final center = _ownSize.center(offset);
42 final radius = _ownSize.shortestSide / 2;
43 final hourToRads = _hour / 12 * 2 * pi;
44 final minsToRads = _minute / 60 * 2 * pi;
45 final paintHours = Paint()
46 ..style = PaintingStyle.fill
47 ..strokeWidth = 5
48 ..color = Colors.white;
49 final paintMins = Paint()
50 ..style = PaintingStyle.fill
51 ..strokeWidth = 2
52 ..color = Colors.grey;
53 context.canvas.drawLine(
54 _offset + center,
55 _offset +
56 center +
57 Offset(
58 radius / 2 * cos(pi / 2 - hourToRads),
59 -radius / 2 * sin(pi / 2 - hourToRads),
60 ),
61 paintHours,
62 );
63 context.canvas.drawLine(
64 _offset + center,
65 _offset +
66 center +
67 Offset(
68 radius * cos(pi / 2 - minsToRads),
69 -radius * sin(pi / 2 - minsToRads),
70 ),
71 paintMins,
72 );
73 }
74 }
75
76 class MyClockApp extends StatelessWidget {
77 const MyClockApp({super.key});
78
79 @override
80 Widget build(BuildContext context) {
81 return WidgetToRenderBoxAdapter(
82 renderBox: ClockRenderBox(
83 const Size.square(256),
84 const Offset(64, 64),
85 13.0,
86 39.0,
87 ),
88 );
89 }
90 }
Эта реализация работает корректно, но после создания у нас не будет возможности внести изменения в свойства отображаемого объекта. Для любого RenderObject можно вручную выполнять управление его дочерними объектами, и мы можем только пересоздать новый экземпляр объекта и заменить его через dropChild/adoptChild.
Но при этом фреймворк не сможет оптимизировать обновление, поскольку будет рассматривать RenderObject как новый и повторно запускать все этапы измерения, размещения и отрисовки объекта. Это негативно повлияет на производительность приложения. Более правильным решением будет использование методов RenderObject для отправки уведомлений о необходимости выполнения одного или нескольких этапов обновления при обнаружении изменения свойств.
Давайте посмотрим, какие шаги выполняются фреймворком для каждого RenderObject при первом запуске. Для этого изучим исходный код метода drawFrame в RendererBinding (комментарии автора кода):
1pipelineOwner.flushLayout();
2pipelineOwner.flushCompositingBits();
3pipelineOwner.flushPaint();
4if (sendFramesToEngine) {
5 renderView.compositeFrame(); // this sends the bits to the GPU
6 pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
7 _firstFrameSent = true;
8}
- На первом этапе (
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.
Вот как это будет выглядеть в коде
1 import 'package:flutter/material.dart';
2 import 'dart:math';
3
4 class Clock extends LeafRenderObjectWidget {
5 final Size size;
6 final Offset offset;
7 final double hour;
8 final double minute;
9
10 const Clock({
11 required this.size,
12 required this.offset,
13 required this.hour,
14 required this.minute,
15 super.key,
16 });
17
18 @override
19 RenderObject createRenderObject(BuildContext context) =>
20 ClockRenderBox(size, offset, hour, minute);
21
22 @override
23 void updateRenderObject(
24 BuildContext context, covariant RenderObject renderObject) {
25 final clockRenderObject = renderObject as ClockRenderBox;
26 clockRenderObject
27 ..ownSize = size
28 ..offset = offset
29 ..hour = hour
30 ..minute = minute;
31 }
32 }
33
34 class ClockRenderBox extends RenderBox {
35 Size _size;
36 Offset _offset;
37 double _hour;
38 double _minute;
39
40 ClockRenderBox(
41 this._size,
42 this._offset,
43 this._hour,
44 this._minute,
45 );
46
47 @override
48 get sizedByParent => false;
49
50 @override
51 void performLayout() => size = _size;
52
53 set ownSize(Size newSize) {
54 if (newSize != _size) {
55 _size = newSize;
56 markNeedsPaint();
57 markNeedsLayout();
58 }
59 }
60
61 set offset(Offset offset) {
62 if (offset != _offset) {
63 _offset = offset;
64 markNeedsPaint();
65 }
66 }
67
68 set hour(double hour) {
69 if (hour != _hour) {
70 _hour = hour;
71 markNeedsPaint();
72 markNeedsSemanticsUpdate();
73 }
74 }
75
76 set minute(double minute) {
77 if (minute != _minute) {
78 _minute = minute;
79 markNeedsPaint();
80 markNeedsSemanticsUpdate();
81 }
82 }
83
84 @override
85 void paint(PaintingContext context, Offset offset) {
86 final center = size.center(offset + _offset);
87 final radius = size.shortestSide / 2;
88 final hourToRads = _hour / 12 * 2 * pi;
89 final minsToRads = _minute / 60 * 2 * pi;
90 final paintHours = Paint()
91 ..style = PaintingStyle.fill
92 ..strokeWidth = 5
93 ..color = Colors.white;
94 final paintMins = Paint()
95 ..style = PaintingStyle.fill
96 ..strokeWidth = 2
97 ..color = Colors.grey;
98 context.canvas.drawLine(
99 center,
100 center +
101 Offset(
102 radius / 2 * cos(pi / 2 - hourToRads),
103 -radius / 2 * sin(pi / 2 - hourToRads),
104 ),
105 paintHours,
106 );
107 context.canvas.drawLine(
108 center,
109 center +
110 Offset(
111 radius * cos(pi / 2 - minsToRads),
112 -radius * sin(pi / 2 - minsToRads),
113 ),
114 paintMins,
115 );
116 }
117 }
118
119 class ClockData {
120 Offset offset = Offset.zero;
121 Size size = const Size.square(128);
122 double hour = 0;
123 double minute = 0;
124 }
125
126 class MyClockApp extends StatefulWidget {
127 const MyClockApp({super.key});
128
129 @override
130 State<MyClockApp> createState() => _MyClockAppState();
131 }
132
133 class _MyClockAppState extends State<MyClockApp> {
134 final clockData = ClockData();
135
136 @override
137 Widget build(BuildContext context) {
138 return MaterialApp(
139 theme: ThemeData.dark(useMaterial3: false),
140 home: Scaffold(
141 body: SafeArea(
142 child:
143 Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
144 ElevatedButton(
145 onPressed: () =>
146 setState(() => clockData.offset += const Offset(1, 1)),
147 child: const Text('Shift'),
148 ),
149 ElevatedButton(
150 onPressed: () => setState(() => clockData.size *= 1.1),
151 child: const Text('Resize'),
152 ),
153 ElevatedButton(
154 onPressed: () => setState(() => clockData.hour++),
155 child: const Text('Increment hour'),
156 ),
157 ElevatedButton(
158 onPressed: () => setState(() => clockData.minute++),
159 child: const Text('Increment min'),
160 ),
161 Clock(
162 size: clockData.size,
163 offset: clockData.offset,
164 hour: clockData.hour,
165 minute: clockData.minute,
166 ),
167 ]),
168 ),
169 ),
170 );
171 }
172 }
173
174 void main() {
175 runApp(const MyClockApp());
176 }
Теперь сделаем так, чтобы размер наших часов был не фиксированным, а зависел от размеров родителя.
Измерение, позиционирование и 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. В нашей реализации для учёта ограничений от родителей необходимо выполнить следующие изменения:
1 @override
2 Size computeDryLayout(BoxConstraints constraints) => constraints.constrain(_size);
3
4 @override
5 void performLayout() => size = constraints.constrain(_size);
6
В коде добавим LimitedBox для установки ограничений от родительского объекта и увидим, что масштабирование часов будет ограничено указанным размером.
Код
1 import 'package:flutter/material.dart';
2 import 'dart:math';
3
4 class Clock extends LeafRenderObjectWidget {
5 final Size size;
6 final Offset offset;
7 final double hour;
8 final double minute;
9
10 const Clock({
11 required this.size,
12 required this.offset,
13 required this.hour,
14 required this.minute,
15 super.key,
16 });
17
18 @override
19 RenderObject createRenderObject(BuildContext context) =>
20 ClockRenderBox(size, offset, hour, minute);
21
22 @override
23 void updateRenderObject(
24 BuildContext context, covariant RenderObject renderObject) {
25 final clockRenderObject = renderObject as ClockRenderBox;
26 clockRenderObject
27 ..ownSize = size
28 ..offset = offset
29 ..hour = hour
30 ..minute = minute;
31 }
32 }
33
34 class ClockRenderBox extends RenderBox {
35 Size _size;
36 Offset _offset;
37 double _hour;
38 double _minute;
39
40 ClockRenderBox(
41 this._size,
42 this._offset,
43 this._hour,
44 this._minute,
45 );
46
47 @override
48 get sizedByParent => false;
49
50 @override
51 Size computeDryLayout(BoxConstraints constraints) =>
52 constraints.constrain(_size);
53
54 @override
55 void performLayout() => size = constraints.constrain(_size);
56
57 set ownSize(Size newSize) {
58 if (newSize != _size) {
59 _size = newSize;
60 markNeedsPaint();
61 markNeedsLayout();
62 }
63 }
64
65 set offset(Offset offset) {
66 if (offset != _offset) {
67 _offset = offset;
68 markNeedsPaint();
69 }
70 }
71
72 set hour(double hour) {
73 if (hour != _hour) {
74 _hour = hour;
75 markNeedsPaint();
76 markNeedsSemanticsUpdate();
77 }
78 }
79
80 set minute(double minute) {
81 if (minute != _minute) {
82 _minute = minute;
83 markNeedsPaint();
84 markNeedsSemanticsUpdate();
85 }
86 }
87
88 @override
89 void paint(PaintingContext context, Offset offset) {
90 final center = size.center(offset + _offset);
91 final radius = size.shortestSide / 2;
92 final hourToRads = _hour / 12 * 2 * pi;
93 final minsToRads = _minute / 60 * 2 * pi;
94 final paintHours = Paint()
95 ..style = PaintingStyle.fill
96 ..strokeWidth = 5
97 ..color = Colors.white;
98 final paintMins = Paint()
99 ..style = PaintingStyle.fill
100 ..strokeWidth = 2
101 ..color = Colors.grey;
102 context.canvas.drawLine(
103 center,
104 center +
105 Offset(
106 radius / 2 * cos(pi / 2 - hourToRads),
107 -radius / 2 * sin(pi / 2 - hourToRads),
108 ),
109 paintHours,
110 );
111 context.canvas.drawLine(
112 center,
113 center +
114 Offset(
115 radius * cos(pi / 2 - minsToRads),
116 -radius * sin(pi / 2 - minsToRads),
117 ),
118 paintMins,
119 );
120 }
121 }
122
123 class ClockData {
124 Offset offset = Offset.zero;
125 Size size = const Size.square(128);
126 double hour = 0;
127 double minute = 0;
128 }
129
130 class MyClockApp extends StatefulWidget {
131 const MyClockApp({super.key});
132
133 @override
134 State<MyClockApp> createState() => _MyClockAppState();
135 }
136
137 class _MyClockAppState extends State<MyClockApp> {
138 final clockData = ClockData();
139
140 @override
141 Widget build(BuildContext context) {
142 return MaterialApp(
143 theme: ThemeData.dark(useMaterial3: false),
144 home: Scaffold(
145 body: SafeArea(
146 child:
147 Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
148 ElevatedButton(
149 onPressed: () =>
150 setState(() => clockData.offset += const Offset(1, 1)),
151 child: const Text('Shift'),
152 ),
153 ElevatedButton(
154 onPressed: () => setState(() => clockData.size *= 1.1),
155 child: const Text('Resize'),
156 ),
157 ElevatedButton(
158 onPressed: () => setState(() => clockData.hour++),
159 child: const Text('Increment hour'),
160 ),
161 ElevatedButton(
162 onPressed: () => setState(() => clockData.minute++),
163 child: const Text('Increment min'),
164 ),
165 //добавили constraints, ограничивающие изменение размера до квадрата со стороной 200
166 LimitedBox(
167 maxWidth: 200,
168 maxHeight: 200,
169 child: Clock(
170 size: clockData.size,
171 offset: clockData.offset,
172 hour: clockData.hour,
173 minute: clockData.minute,
174 ),
175 ),
176 ]),
177 ),
178 ),
179 );
180 }
181 }
182
183 void main() {
184 runApp(const MyClockApp());
185 }
Модель Constraints допускает создание альтернативной системы ограничений, которая отличается от размещения двумерных объектов в пространстве экрана, и ниже мы рассмотрим пример с SliverConstraints для позиционирования объектов в прокручиваемых списках.
А пока давайте расширим наш пример и добавим поддержку реакции на касания в области часов для управления минутной стрелкой. В этом нам поможет обратная связь и механизм реакции RenderObject на внешние события.
Реакция на действия пользователя
При возникновении событий дерево RenderObject получает сообщение через вызов метода hitTest. Событием может быть прикосновение к экрану на мобильных устройствах или перемещение курсора на десктопных. Далее мы будем говорить только о прикосновениях к экрану.
Итак, метод hitTest принимает позицию касания в относительных координатах основного слоя RenderObject. В результате выполнения hitTest может быть возвращено true, если нужно остановить обработку события прикосновения, или false, чтобы передать это сообщение другим RenderObject, расположенным в той же области экрана.
Обработка начинается с вершины дерева, в котором расположен RenderView, занимающий доступное пространство экрана для приложения, при этом каждый RenderObject передаёт сообщение своим дочерним объектам, логика которых реализуется в методе hitTestChildren.
Если RenderObject размещён в каком-либо контейнере, например в Column, сообщение будет отправлено последовательно всем дочерним объектам независимо от координаты точки касания. Обработка сообщения завершится на первом объекте, который вернёт true, поэтому важно делать дополнительную проверку принадлежности координат точки касания прямоугольнику, включающему наш объект. С точки зрения проверки координаты точка касания должна находиться в интервале от (0, 0) до size.
Поскольку RenderObject не хранит информацию о связанном виджете, для отправки уведомлений о произошедшем событии нужно использовать коллбек-функции, которые передаются в виджет и дальше сохраняются в RenderObject.
Код
1class ClockRenderBox extends RenderBox {
2 Size _size;
3 Offset _offset;
4 double _hour;
5 double _minute;
6 ValueSetter<double> onUpdateMinutes;
7
8 ClockRenderBox(
9 this._size,
10 this._offset,
11 this._hour,
12 this._minute,
13 this.onUpdateMinutes,
14 );
15
16 @override
17 bool hitTest(BoxHitTestResult result, {required Offset position}) {
18 // проверка, что точка касания находится внутри прямоугольника RenderObject
19 if (!(Offset.zero & size).contains(position)) return false;
20 //регистрация события касания (будет передано в handleEvent)
21 result.add(BoxHitTestEntry(this, position));
22 return true;
23 }
24
25 @override
26 void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
27 // entry.localPosition здесь получит значение position из hitTest
28 final center = size / 2;
29 final position = entry.localPosition;
30 double angle =
31 atan2(position.dx - center.width, position.dy - center.height) + pi;
32 if (angle > 2 * pi) {
33 angle = angle - 2 * pi;
34 }
35 final minutes = (2 * pi - angle) / (2 * pi) * 60;
36 onUpdateMinutes(minutes);
37 }
38
39...
40}
41
42class Clock extends LeafRenderObjectWidget {
43 final Size size;
44 final Offset offset;
45 final double hour;
46 final double minute;
47 final ValueSetter<double> onUpdateMinutes;
48
49 const Clock({
50 required this.size,
51 required this.offset,
52 required this.hour,
53 required this.minute,
54 required this.onUpdateMinutes,
55 super.key,
56 });
57//...
58}
59
При вызове виджета также передаём коллбек:
Код
1 Clock(
2 size: clockData.size,
3 offset: clockData.offset,
4 hour: clockData.hour,
5 minute: clockData.minute,
6 onUpdateMinutes: (minutes) {
7 setState(() => clockData.minute = minutes);
8 },
9 ),
10
Информация об обработке события может быть сохранена в объект HitTestResult. Он собирает информацию о событиях взаимодействия с RenderObject, а также о применённых трансформациях для трансляции экранной системы координат в координаты внутри виджета.
Полученные HitTestResult в дальнейшем передаются в метод-обработчик handleEvents, который также получает более подробную информацию о событии в PointerEvent и значение относительных координат точки касания, полученное через BoxHitTestEntry.
Вот как будет выглядеть наш код
1 import 'package:flutter/material.dart';
2 import 'dart:math';
3
4 import 'package:flutter/rendering.dart';
5
6 class Clock extends LeafRenderObjectWidget {
7 final Size size; //размер области отрисовки
8 final Offset offset; //дополнительное смещение
9 final double hour; //часы
10 final double minute; //минуты
11 final ValueSetter<double> onUpdateMinutes; //действие при изменении минут
12
13 const Clock({
14 required this.size,
15 required this.offset,
16 required this.hour,
17 required this.minute,
18 required this.onUpdateMinutes,
19 super.key,
20 });
21
22 @override
23 RenderObject createRenderObject(BuildContext context) =>
24 ClockRenderBox(size, offset, hour, minute, onUpdateMinutes);
25
26 @override
27 void updateRenderObject(
28 BuildContext context, covariant RenderObject renderObject) {
29 final clockRenderObject = renderObject as ClockRenderBox;
30 clockRenderObject
31 ..ownSize = size
32 ..offset = offset
33 ..hour = hour
34 ..minute = minute;
35 }
36 }
37
38 class ClockRenderBox extends RenderBox {
39 Size _size;
40 Offset _offset;
41 double _hour;
42 double _minute;
43 ValueSetter<double> onUpdateMinutes;
44
45 ClockRenderBox(
46 this._size,
47 this._offset,
48 this._hour,
49 this._minute,
50 this.onUpdateMinutes,
51 );
52
53 @override
54 bool hitTest(BoxHitTestResult result, {required Offset position}) {
55 //проверка, что касание экрана произошло в прямоугольнике часов
56 if (!(Offset.zero & size).contains(position)) return false;
57 //если да, добавляем событие
58 result.add(BoxHitTestEntry(this, position));
59 return true;
60 }
61
62 @override
63 void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
64 //entry.localPosition здесь получит значение position из hitTest
65 final center = size / 2;
66 //переводим координаты точки касания в соответствующее значение угла
67 final position = entry.localPosition;
68 double angle =
69 atan2(position.dx - center.width, position.dy - center.height) + pi;
70 if (angle > 2 * pi) {
71 angle = angle - 2 * pi;
72 }
73 final minutes = (2 * pi - angle) / (2 * pi) * 60;
74 onUpdateMinutes(minutes);
75 }
76
77 @override
78 get sizedByParent => false;
79
80 @override
81 Size computeDryLayout(BoxConstraints constraints) =>
82 constraints.constrain(_size);
83
84 @override
85 void performLayout() => size = constraints.constrain(_size);
86
87 set ownSize(Size newSize) {
88 if (newSize != _size) {
89 _size = newSize;
90 markNeedsPaint();
91 markNeedsLayout();
92 }
93 }
94
95 set offset(Offset offset) {
96 if (offset != _offset) {
97 _offset = offset;
98 markNeedsPaint();
99 }
100 }
101
102 set hour(double hour) {
103 if (hour != _hour) {
104 _hour = hour;
105 markNeedsPaint();
106 markNeedsSemanticsUpdate();
107 }
108 }
109
110 set minute(double minute) {
111 if (minute != _minute) {
112 _minute = minute;
113 markNeedsPaint();
114 markNeedsSemanticsUpdate();
115 }
116 }
117
118 @override
119 void paint(PaintingContext context, Offset offset) {
120 final center = size.center(offset + _offset);
121 final radius = size.shortestSide / 2;
122 final hourToRads = _hour / 12 * 2 * pi;
123 final minsToRads = _minute / 60 * 2 * pi;
124 final paintHours = Paint()
125 ..style = PaintingStyle.fill
126 ..strokeWidth = 5
127 ..color = Colors.white;
128 final paintMins = Paint()
129 ..style = PaintingStyle.fill
130 ..strokeWidth = 2
131 ..color = Colors.grey;
132 context.canvas.drawLine(
133 center,
134 center +
135 Offset(
136 radius / 2 * cos(pi / 2 - hourToRads),
137 -radius / 2 * sin(pi / 2 - hourToRads),
138 ),
139 paintHours,
140 );
141 context.canvas.drawLine(
142 center,
143 center +
144 Offset(
145 radius * cos(pi / 2 - minsToRads),
146 -radius * sin(pi / 2 - minsToRads),
147 ),
148 paintMins,
149 );
150 }
151 }
152
153 class ClockData {
154 Offset offset = Offset.zero;
155 Size size = const Size.square(128);
156 double hour = 0;
157 double minute = 0;
158 }
159
160 class MyClockApp extends StatefulWidget {
161 const MyClockApp({super.key});
162
163 @override
164 State<MyClockApp> createState() => _MyClockAppState();
165 }
166
167 class _MyClockAppState extends State<MyClockApp> {
168 final clockData = ClockData();
169
170 @override
171 Widget build(BuildContext context) {
172 return MaterialApp(
173 theme: ThemeData.dark(useMaterial3: false),
174 home: Scaffold(
175 body: SafeArea(
176 child:
177 Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
178 ElevatedButton(
179 onPressed: () =>
180 setState(() => clockData.offset += const Offset(1, 1)),
181 child: const Text('Shift'),
182 ),
183 ElevatedButton(
184 onPressed: () => setState(() => clockData.size *= 1.1),
185 child: const Text('Resize'),
186 ),
187 ElevatedButton(
188 onPressed: () => setState(() => clockData.hour++),
189 child: const Text('Increment hour'),
190 ),
191 ElevatedButton(
192 onPressed: () => setState(() => clockData.minute++),
193 child: const Text('Increment min'),
194 ),
195 //ограничитель размера области отрисовки
196 LimitedBox(
197 maxWidth: 200,
198 maxHeight: 200,
199 child: Clock(
200 size: clockData.size,
201 offset: clockData.offset,
202 hour: clockData.hour,
203 minute: clockData.minute,
204 onUpdateMinutes: (minutes) {
205 setState(() => clockData.minute = minutes);
206 },
207 ),
208 ),
209 ]),
210 ),
211 ),
212 );
213 }
214 }
215
216 void main() {
217 runApp(const MyClockApp());
218 }
При обработке касаний также могут быть полезны методы из 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 через новый контекст.
Код
1 context.pushOpacity(
2 center + offset,
3 64, // прозрачность (0—255, 0 — полностью прозрачный)
4 (context, offset) {
5 context.canvas.drawLine(
6 offset,
7 offset +
8 Offset(
9 radius / 2 * cos(pi / 2 - hourToRads),
10 -radius / 2 * sin(pi / 2 - hourToRads),
11 ),
12 paintHours,
13 );
14 context.canvas.drawLine(
15 offset,
16 offset +
17 Offset(
18 radius * cos(pi / 2 - minsToRads),
19 -radius * sin(pi / 2 - minsToRads),
20 ),
21 paintMins,
22 );
23 },
24 );
25
Теперь добавим анимацию прозрачности для нашего RenderObject. Для этого разберёмся с моделью жизненного цикла и возможными состояниями объекта.
Жизненный цикл RenderObject
RenderObject создаётся без привязки к дереву (поля owner и parent равны null) и затем подключается в дерево в позицию, которая соответствует родительскому элементу в процессе встраивания нового элемента в своё дерево. Для этого в методе mount для RenderObjectElement создается экземпляр RenderObject через вызов createRenderObject из виджета.
Ответственность за присоединение дочерних объектов лежит на родительском объекте, и для каждого из них в первый раз вызывается метод attach, который запускается однократно и может использоваться для инициализации связанных объектов, создания подписок и других возможных действий.
Аналогично при исключении RenderObject из дерева, если создавший его виджет был перемещён или удалён, у RenderObject вызывается метод detach, который также должен выполнить обращение к detach для всех дочерних объектов.
При этом RenderObject может быть возвращён в другое место дерева — например, при использовании Hero-анимации (более подробно про анимации можно почитать в этом параграфе) или перемещении виджетов с глобальными ключами. В случае если RenderObject более не будет использоваться, вызывается метод dispose.
В нашем примере мы можем использовать методы жизненного цикла для создания анимации прозрачности. Здесь мы получим Ticker непосредственно в нашем классе, но более правильным будет решение создавать его в State виджета и передавать в конструктор при создании.
Код
1 import 'package:flutter/material.dart';
2 import 'dart:math';
3
4 import 'package:flutter/rendering.dart';
5 import 'package:flutter/scheduler.dart';
6
7 class Clock extends LeafRenderObjectWidget {
8 final Size size;
9 final Offset offset;
10 final double hour;
11 final double minute;
12 final ValueSetter<double> onUpdateMinutes;
13 final ValueSetter<double> onUpdateHours;
14
15 const Clock({
16 required this.size,
17 required this.offset,
18 required this.hour,
19 required this.minute,
20 required this.onUpdateMinutes,
21 required this.onUpdateHours,
22 super.key,
23 });
24
25 @override
26 RenderObject createRenderObject(BuildContext context) => ClockRenderBox(
27 size,
28 offset,
29 hour,
30 minute,
31 onUpdateMinutes,
32 onUpdateHours,
33 );
34
35 @override
36 void updateRenderObject(
37 BuildContext context, covariant RenderObject renderObject) {
38 final clockRenderObject = renderObject as ClockRenderBox;
39 clockRenderObject
40 ..ownSize = size
41 ..offset = offset
42 ..hour = hour
43 ..minute = minute;
44 }
45 }
46
47 class ClockRenderBox extends RenderBox implements TickerProvider {
48 Size _size;
49 Offset _offset;
50 double _hour;
51 double _minute;
52 ValueSetter<double> onUpdateMinutes;
53 ValueSetter<double> onUpdateHours;
54 AnimationController? _animationController;
55
56 ClockRenderBox(
57 this._size,
58 this._offset,
59 this._hour,
60 this._minute,
61 this.onUpdateMinutes,
62 this.onUpdateHours,
63 );
64
65 @override
66 get sizedByParent => false;
67
68 @override
69 Size computeDryLayout(BoxConstraints constraints) =>
70 constraints.constrain(_size);
71
72 @override
73 void performLayout() => size = constraints.constrain(_size);
74
75 @override
76 void attach(PipelineOwner owner) {
77 super.attach(owner);
78 _animationController = AnimationController(
79 vsync: this,
80 lowerBound: 63,
81 upperBound: 255,
82 duration: const Duration(seconds: 1),
83 );
84 _animationController?.repeat();
85 _animationController?.addListener(markNeedsPaint);
86 }
87
88 @override
89 void detach() {
90 _animationController?.stop();
91 super.detach();
92 }
93
94 set ownSize(Size newSize) {
95 if (newSize != _size) {
96 _size = newSize;
97 markNeedsPaint();
98 markNeedsLayout();
99 }
100 }
101
102 set offset(Offset offset) {
103 if (offset != _offset) {
104 _offset = offset;
105 markNeedsPaint();
106 }
107 }
108
109 set hour(double hour) {
110 if (hour != _hour) {
111 _hour = hour;
112 markNeedsPaint();
113 markNeedsSemanticsUpdate();
114 }
115 }
116
117 set minute(double minute) {
118 if (minute != _minute) {
119 _minute = minute;
120 markNeedsPaint();
121 markNeedsSemanticsUpdate();
122 }
123 }
124
125 @override
126 void paint(PaintingContext context, Offset offset) {
127 final center = size.center(offset + _offset);
128 final radius = size.shortestSide / 2;
129 final hourToRads = _hour / 12 * 2 * pi;
130 final minsToRads = _minute / 60 * 2 * pi;
131 final paintHours = Paint()
132 ..style = PaintingStyle.fill
133 ..strokeWidth = 5
134 ..color = Colors.white;
135 final paintMins = Paint()
136 ..style = PaintingStyle.fill
137 ..strokeWidth = 2
138 ..color = Colors.grey;
139
140 context.pushOpacity(center, _animationController?.value.toInt() ?? 255,
141 (context, offset) {
142 context.canvas.drawLine(
143 offset,
144 offset +
145 Offset(
146 radius / 2 * cos(pi / 2 - hourToRads),
147 -radius / 2 * sin(pi / 2 - hourToRads),
148 ),
149 paintHours,
150 );
151 context.canvas.drawLine(
152 offset,
153 offset +
154 Offset(
155 radius * cos(pi / 2 - minsToRads),
156 -radius * sin(pi / 2 - minsToRads),
157 ),
158 paintMins,
159 );
160 });
161 }
162
163 @override
164 bool hitTest(BoxHitTestResult result, {required Offset position}) {
165 if (!(Offset.zero & size).contains(position)) return false;
166 result.add(BoxHitTestEntry(this, position));
167 return true;
168 }
169
170 @override
171 void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) {
172 final center = size / 2;
173 final position = entry.localPosition;
174 double angle =
175 atan2(position.dx - center.width, position.dy - center.height) + pi;
176 if (angle > 2 * pi) {
177 angle = angle - 2 * pi;
178 }
179 final minutes = (2 * pi - angle) / (2 * pi) * 60;
180 onUpdateMinutes(minutes);
181 }
182
183 Ticker? _ticker;
184
185 @override
186 Ticker createTicker(TickerCallback onTick) {
187 _ticker ??= Ticker(onTick);
188 return _ticker!;
189 }
190 }
191
192 class ClockData {
193 Offset offset = Offset.zero;
194 Size size = const Size.square(128);
195 double hour = 0;
196 double minute = 0;
197 }
198
199 class MyClockApp extends StatefulWidget {
200 const MyClockApp({super.key});
201
202 @override
203 State<MyClockApp> createState() => _MyClockAppState();
204 }
205
206 class _MyClockAppState extends State<MyClockApp> {
207 final clockData = ClockData();
208
209 @override
210 Widget build(BuildContext context) {
211 return MaterialApp(
212 theme: ThemeData.dark(useMaterial3: false),
213 home: Scaffold(
214 body: SafeArea(
215 child:
216 Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
217 ElevatedButton(
218 onPressed: () =>
219 setState(() => clockData.offset += const Offset(1, 1)),
220 child: const Text('Shift'),
221 ),
222 ElevatedButton(
223 onPressed: () => setState(() => clockData.size *= 1.1),
224 child: const Text('Resize'),
225 ),
226 ElevatedButton(
227 onPressed: () => setState(() => clockData.hour++),
228 child: const Text('Increment hour'),
229 ),
230 ElevatedButton(
231 onPressed: () => setState(() => clockData.minute++),
232 child: const Text('Increment min'),
233 ),
234 LimitedBox(
235 maxWidth: 200,
236 maxHeight: 200,
237 child: Clock(
238 size: clockData.size,
239 offset: clockData.offset,
240 hour: clockData.hour,
241 minute: clockData.minute,
242 onUpdateMinutes: (minutes) {
243 setState(() => clockData.minute = minutes);
244 },
245 onUpdateHours: (hours) {
246 setState(() => clockData.hour = hours);
247 },
248 ),
249 ),
250 ]),
251 ),
252 ),
253 );
254 }
255 }
256
257 void main() {
258 runApp(const MyClockApp());
259 }
Далее мы сделаем так, чтобы наши часы могли использовать люди с ограниченными возможностями здоровья. В этом нам поможет передача семантической информации.
Семантическая информация и обработка действий
Для мобильных и веб-приложений в операционной системе или браузере поддерживаются возможности 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'), и соответствующих функций-обработчиков |
Добавим для нашего примера поддержку семантической информации о значении времени, отображаемого на часах, а также дополнительные описания семантических действий для изменения текущего времени:
1 @override
2 Rect get semanticBounds => Offset.zero & size;
3
4 @override
5 void describeSemanticsConfiguration(SemanticsConfiguration config) {
6 // текущее время, которое показывают часы
7 config.value = '$_hour hours and $_minute minutes';
8 //значение минутной стрелки после действий increment-decrement
9 config.decreasedValue = _minute.toInt().toString();
10 config.increasedValue = _minute.toInt().toString();
11
12 config.onDecrease = () {
13 // изменение времени (перемещение минутной стрелки назад)
14 _minute--;
15 if (_minute < 0) {
16 _minute = 60 + _minute;
17 _hour--;
18 if (_hour < 0) _hour = 24 + _hour;
19 }
20 onUpdateMinutes(_minute);
21 onUpdateHours(_hour);
22 markNeedsSemanticsUpdate();
23 };
24 config.onIncrease = () {
25 // изменение времени (перемещение минутной стрелки вперёд)
26 _minute++;
27 if (_minute >= 60) {
28 // также отслеживаем часовую стрелку
29 _minute = _minute - 60;
30 _hour = (_hour + 1) % 24;
31 }
32 onUpdateMinutes(_minute);
33 onUpdateHours(_hour);
34 markNeedsSemanticsUpdate();
35 };
36 config.onTap = () {
37 // семантическое действие «Нажать» переводит часовую стрелку
38 _hour = (_hour + 1) % 24;
39 onUpdateHours(_hour);
40 markNeedsSemanticsUpdate();
41 };
42 // голосовая подсказка для действия при нажатии на RenderObject часов
43 config.hint = 'Tap me to increment hours';
44 }
Более подробно использование виджетов семантической разметки и управления семантическим деревом будет рассмотрено в одном из следующих параграфов.
Теперь давайте сделаем так, чтобы мы могли легче находить возможные ошибки.
Отладка 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, полученных ограничениях и измеренном размере объекта, но метод может быть переопределён для добавления собственных значений, важных для описания состояния объекта, например:
1 @override
2 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
3 super.debugFillProperties(properties);
4 properties.add(DiagnosticsNode.message('This is a clock renderobject'));
5 properties.add(DiagnosticsProperty('hour', _hour));
6 properties.add(DiagnosticsProperty('minute', _minute));
7 properties.add(DiagnosticsProperty('offset', _offset));
8 }
9
Полученные значения в 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, но уже на других примерах.
