В этом параграфе мы обсудим внутреннюю организацию 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
, но уже на других примерах.