В предыдущих нескольких параграфах мы рассмотрели анимации. В этом затронем смежную тему: работу с графикой.
Поговорим про классы CustomPainter
и Canvas
— вместе они позволяют создавать разнообразные геометрические фигуры и линии на холсте. С их помощью мы немного порисуем и анимируем наш рисунок.
CustomPaint и CustomPainter
Начнём вот с чего: не путайте CustomPaint
и CustomPainter
.
CustomPainter
— это абстрактный класс. В его наследнике мы переопределяем методы, с помощью которых можно раскрашивать и создавать элементы пользовательского интерфейса. Он позволяет полностью контролировать внешний вид, начиная от простых форм и линий и заканчивая сложной графикой.
А CustomPaint
— виджет, который встраивает наследника CustomPainter
в дерево виджетов и предоставляет холст для рисования. О нём мы подробно поговорим ниже, а пока рассмотрим наследника CustomPainter
.
1class Painter extends CustomPainter {
2 @override
3 void paint(Canvas canvas, Size size) {
4 // TODO: implement paint
5 }
6
7 @override
8 bool shouldRepaint(covariant Painter oldDelegate) {
9 // TODO: implement shouldRepaint
10 throw UnimplementedError();
11 }
12}
Методы CustomPainter
Как видите, для рисования нам нужно реализовать два метода: paint
и shouldRepaint
.
paint
Метод вызывается каждый раз, когда нужно отрисовать CustomPainter
, — в теле этого метода будут описываться все операции рисования.
Он принимает два аргумента:
- объект
Canvas
(подробнее о нём расскажем далее); - объект
Size
, который предоставляет max constraints, переданные родительским виджетом.
Одна важная особенность CustomPainter
заключается в том, что можно нарисовать что-нибудь и за пределами переданных размеров, но нет гарантии, что отрисованный объект не будет обрезан или не перекроет соседние виджеты, поэтому стоит избегать рисования за пределами переданных размеров.
shouldRepaint
Возвращает булево значение, на основе которого принимается решение, надо ли вызывать метод paint
для перерисовки или нет. Вызывается каждый раз, когда новый объект CustomPainter
устанавливается в RenderObject
-дереве объекту RenderCustomPaint
. На деле чаще всего это означает изменение параметров, переданных в конструкторе CustomPainter
.
Теперь давайте подробнее поговорим CustomPaint
и самой важной части этого виджета — Canvas
.
CustomPaint
Виджет, через который CustomPainter
встраивается в дерево. Создаёт Canvas
(холст) для рисования переданного CustomPainter
.
Основные параметры:
painter
— объект типаCustomPainter
, который рисуется перед переданным виджетомchild
.foregroundPainter
— объект типаCustomPainter
, который рисуется после переданного виджетаchild
.child
— виджет, который рисуется послеpainter
и доforegroundPainter
.Canvas
(холст) принимает размеры переданного виджета. Этот параметр опциональный и может быть не заполнен, в таком случае размерыCanvas
(холста) берутся из параметраsize
.size
— объект типаSize
. Задаёт размерыCanvas
(холста) в том случае, если не был передан параметрchild
. Не обязателен для заполнения в случае передачиchild
.isComplex
— флаг, указывающий на необходимость кэшированияCustomPainter
. Установка флага может быть уместна, если в методеpaint
выполняется много операций вычисления, происходит работа со слоями, трансформация холста.willChange
— флаг, указывающий чтоCustomPainter
в скором времени будет перерисован и нет необходимости кэшировать его. Установка флага может быть уместна, еслиCustomPainter
находится в процессе анимации и в следующем кадре изменит своё состояние.
Canvas
Canvas
(англ. холст) — это фундаментальный инструмент, который лежит в основе механизма отрисовки чего либо на экране. Canvas
подобно Декартовой системе координат имеет две оси — X (горизонтальная) и Y (вертикальная). Но есть одно важное отличие: вектор оси Y направлен не вверх, а вниз. Таким образом начало координат находится в левом верхнем углу.
Теперь мы готовы нарисовать наш первый рисунок во Flutter!
Рисуем с помощью CustomPainter и Canvas
Рисование на Сanvas
можно сравнить с рисованием в программе Paint — мы выбираем цвет, выбираем фигуру и просто переносим нашу фигуру на холст.
Класс Canvas
содержит большое количество методов для рисования различных геометрических фигур, линий и кривых. Нарисуем с его помощью домик, а для этого рассмотрим подробнее некоторые методы.
Важно: для удобства чтения мы спрячем блоки кода под катом, оставим только иллюстрации. Полный код примера будет в дартпаде в конце.
В первую очередь создадим наследника класса CustomPainter
.
1class HousePainter extends CustomPainter {
2 const HousePainter();
3
4 @override
5 void paint(Canvas canvas, Size size) { }
6
7 @override
8 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
9}
Пока что это просто белый холст, но с каждым новым изученным методом мы будем дорисовывать нужные элементы, чтобы в конечном итоге получилось изображение дома.
drawPaint
Для начала перекрасим фон в цвет неба. Для этого воспользуемся методом drawPaint
, который принимает объект Paint
. В Paint
задаём цвет, который хотим нанести на холст.:
Код
1...
2 @override
3 void paint(Canvas canvas, Size size) {
4 _drawSky(canvas);
5 }
6
7 /// Рисуем небо
8 void _drawSky(Canvas canvas) {
9 canvas.drawPaint(Paint()..color = Colors.blue.shade300);
10 }
11...
Объект Paint
— это декларация того, как будет отрисован объект на холсте. Вот его основные параметры:
-
сolor
— позволяет задать цвет, которым будет отрисован объект (по умолчанию — прозрачный); -
style
— режим отрисовки фигуры;PaintingStyle.stroke
— рисовать только контур переданным цветом (сolor
) и шириной (strokeWidth
).PaintingStyle.fill
— рисовать переданным цветом (сolor
) тело фигуры и контур шириной (strokeWidth
).
-
strokeWidth
— ширина линии контура фигуры; -
Нюанс
strokeWidth
Ширина линии добавляется по половине с каждой стороны от контура по которому проходит линия. Стоит это учитывать, особенно если необходимо нарисовать фигуру по размеру холста.
В примере нарисованы несколько кругов которые отличаются только параметром
strokeWidth
. Радиус равен половине ширины экрана и хорошо видно что круг не влезает полностью. Об этом необходимо помнить и учитыватьstrokeWidth/2
в размерах фигур.
strokeCap
— вид концов линий.StrokeCap.butt
— линия заканчивается в координатах которые были указаны в параметрах.StrokeCap.round
— на концы линии добавляется окружность с радиусом в половину ширину контура (strokeWidth/2
). То есть итоговый размер линии увеличивается на ширину контура (strokeWidth
).StrokeCap.square
— на концы линии добавляется прямоугольник с шириной в половину ширину контура (strokeWidth/2
). То есть итоговая ширина линии увеличивается на ширину контура (strokeWidth
).
Важно: мы перечислили только самые популярные параметра класса
Paint
, которые нужны в большинстве случаев. Остальные параметры рассмотрим на практике далее по тексту.
drawLine
Теперь нарисуем траву, на которой будет стоять дом. Тут нам поможет метод drawLine
— он рисует прямую линию из одной координаты в другую.
1...
2 static const _groundHeight = 100.0;
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 _drawSky(canvas);
7 _drawGround(canvas, size);
8 }
9
10 /// Рисуем траву
11 void _drawGround(Canvas canvas, Size size) {
12 final painter = Paint()
13 // Цвет травы
14 ..color = Colors.green.shade300
15 // Ширина линии в пикселях
16 ..strokeWidth = _groundHeight;
17
18 // Y-координата линии
19 final dY = size.height - painter.strokeWidth / 2;
20
21 final startPoint = Offset(0, dY);
22 final endPoint = Offset(size.width, dY);
23
24 canvas.drawLine(
25 startPoint,
26 endPoint,
27 painter,
28 );
29 }
30...
Координата Y рассчитывается так:
1...
2final dY = size.height - painter.strokeWidth / 2;
3...
size.height
— самая нижняя координата выделенного под холст пространства, в данном случае самый низ экрана до системных кнопок.painter.strokeWidth / 2
— середина линии. Важно всегда учитывать параметрstrokeWidth
при позиционировании фигур на холсте.
drawRect
Нарисуем наконец стены домика. Наш дом будет прямоугольной формы, и для изображения фигуры воспользуемся методом drawRect
.
Принимает параметры:
rect
— объектRect
, представляющий прямоугольник с координатами;paint
— объектPaint
для настройки визуализации фигуры.
Код
1...
2 static const _houseHeight = 275.0;
3 static const _houseWidth = 250.0;
4 static const _houseOnGroundOffset = 20.0;
5
6 @override
7 void paint(Canvas canvas, Size size) {
8 _drawSky(canvas);
9 _drawGround(canvas, size);
10 _drawWalls(canvas, size);
11 }
12
13 /// Рисуем стены
14 void _drawWalls(Canvas canvas, Size size) {
15 final painter = Paint()
16 // Цвет стены
17 ..color = Colors.pink.shade300;
18
19 // Координата X середины холста
20 final horizontalCenter = size.width / 2;
21 // Половина от ширины дома
22 const halfOfHouseWidth = _houseWidth / 2;
23
24 final housePositionOnGround = size.height - _groundHeight + _houseOnGroundOffset;
25
26 // Нижняя левая координата дома
27 final leftBottomCornerPoint = Offset(
28 horizontalCenter - halfOfHouseWidth,
29 housePositionOnGround,
30 );
31
32 // Верхняя правая координата дома
33 final rightTopCornerPoint = Offset(
34 horizontalCenter + halfOfHouseWidth,
35 housePositionOnGround - _houseHeight,
36 );
37
38 final rect = Rect.fromPoints(
39 leftBottomCornerPoint,
40 rightTopCornerPoint,
41 );
42
43 canvas.drawRect(rect, painter);
44 }
45...
drawRRect
Пока что это не сильно похоже на домик. Давайте запилим ему дверь. Для этого воспользуемся методом drawRRect
. Метод очень похож на предыдущий, но позволяет также закруглить углы прямоугольника. Принимает параметры:
rrect
— объектRRect
, представляющий прямоугольник с закруглёнными углами с координатами. Для создания объекта можно использовать именованный конструкторRRect.fromRectAndRadius
. Он принимает объектRect
, который уже был упомянут ранее в разделе drawRect , и объектRadius
, который представляет конфигурацию закругления углов.paint
— объектPaint
для настройки визуализации фигуры.
Код
1...
2 static const _doorHeight = _houseHeight * 0.5;
3 static const _doorWidth = _houseWidth * 0.3;
4
5 @override
6 void paint(Canvas canvas, Size size) {
7 _drawSky(canvas);
8 _drawGround(canvas, size);
9 _drawWalls(canvas, size);
10 _drawDoor(canvas, size);
11 }
12
13 /// Рисуем дверь
14 void _drawDoor(Canvas canvas, Size size) {
15 final painter = Paint()
16 // Цвет двери
17 ..color = Colors.orange.shade700;
18
19 // Радиус закругления углов
20 const radius = Radius.circular(16);
21
22 // Координата X середины холста
23 final horizontalCenter = size.width / 2;
24 // Половина от ширины дома
25 const halfOfHouseWidth = _houseWidth / 2;
26
27 final housePositionOnGround =
28 size.height - _groundHeight + _houseOnGroundOffset;
29
30 final doorBottomY = housePositionOnGround - 10;
31
32 final doorLeftX = horizontalCenter - halfOfHouseWidth + 20;
33
34 // Нижняя левая координата двери
35 final leftBottomCornerPoint = Offset(
36 doorLeftX,
37 doorBottomY,
38 );
39
40 // Верхняя правая координата двери
41 final rightTopCornerPoint = Offset(
42 doorLeftX + _doorWidth,
43 doorBottomY - _doorHeight,
44 );
45
46 final rect = Rect.fromPoints(
47 leftBottomCornerPoint,
48 rightTopCornerPoint,
49 );
50
51 final rrect = RRect.fromRectAndRadius(
52 rect,
53 radius,
54 );
55
56 canvas.drawRRect(rrect, painter);
57 }
58...
drawOval
У двери не хватает ручки, это нужно исправить. Нарисуем её с помощью метода drawOval
.
Метод рисует эллипс, вписанный в переданный прямоугольник. Принимает параметры:
rect
— объектRect
, представляющий прямоугольник с координатами;paint
— объектPaint
для настройки визуализации фигуры.
1...
2 static const _doorHandleHeight = 5.0;
3 static const _doorHandleWidth = 15.0;
4
5 @override
6 void paint(Canvas canvas, Size size) {
7 _drawSky(canvas);
8 _drawGround(canvas, size);
9 _drawWalls(canvas, size);
10 _drawDoor(canvas, size);
11 _drawDoorHandle(canvas, size);
12 }
13
14 /// Рисуем дверную ручку
15 void _drawDoorHandle(Canvas canvas, Size size) {
16 final painter = Paint()
17 // Цвет ручки
18 ..color = Colors.black;
19
20 // Координата X середины холста
21 final horizontalCenter = size.width / 2;
22 // Половина от ширины дома
23 const halfOfHouseWidth = _houseWidth / 2;
24
25 final housePositionOnGround =
26 size.height - _groundHeight + _houseOnGroundOffset;
27
28 final doorBottomY = housePositionOnGround - 10;
29
30 final doorLeftX = horizontalCenter - halfOfHouseWidth + 20;
31
32 // Нижняя левая координата двери
33 final leftBottomCornerPoint = Offset(
34 doorLeftX,
35 doorBottomY,
36 );
37
38 // Верхняя правая координата двери
39 final rightTopCornerPoint = Offset(
40 doorLeftX + _doorWidth,
41 doorBottomY - _doorHeight,
42 );
43
44 final doorRect = Rect.fromPoints(
45 leftBottomCornerPoint,
46 rightTopCornerPoint,
47 );
48
49 // Позиция дверной ручки относительно правой стороны двери по центру
50 final doorHandleCenter = doorRect.centerRight.translate(-15, 0);
51 final doorHandleRect = Rect.fromCenter(
52 center: doorHandleCenter,
53 width: _doorHandleWidth,
54 height: _doorHandleHeight,
55 );
56
57 canvas.drawOval(doorHandleRect, painter);
58 }
59...
drawCircle
Прорубим в домике круглое окно. Для этого используем метод drawCircle
. Метод принимает 3 параметра:
center
— координата центра окружности;radius
— радиус окружности;paint
— объектPaint
для настройки визуализации фигуры.
Метод
drawOval
также можно использовать для отрисовки окружности/круга, так как окружность — это частный случай эллипса, вписанного в квадрат.
Код
1...
2 static const _windowRadius = 35.0;
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 _drawSky(canvas);
7 _drawGround(canvas, size);
8 _drawWalls(canvas, size);
9 _drawDoor(canvas, size);
10 _drawDoorHandle(canvas, size);
11 _drawWindow(canvas, size);
12 }
13
14 /// Рисуем окно
15 void _drawWindow(Canvas canvas, Size size) {
16 final painter = Paint()..color = Colors.blue.shade600;
17
18 // Координата X середины холста
19 final horizontalCenter = size.width / 2;
20 // Половина от ширины дома
21 const halfOfHouseWidth = _houseWidth / 2;
22
23 final housePositionOnGround =
24 size.height - _groundHeight + _houseOnGroundOffset;
25
26 final windowCenterY = housePositionOnGround - _doorHeight * 0.7;
27
28 final windowCenterX =
29 horizontalCenter + halfOfHouseWidth - _windowRadius - 40;
30
31 final windowCenter = Offset(windowCenterX, windowCenterY);
32
33 canvas.drawCircle(
34 windowCenter,
35 _windowRadius,
36 painter,
37 );
38 }
39...
drawArc
Над окошком не хватает навеса. А раз оно у нас круглое, то и навес должен быть в форме дуги. Тут мы воспользуемся методом drawArc
. Он принимает параметры:
rect
— объектRect
, представляющий прямоугольник с координатами.startAngle
— угол в радианах с которого начинается дуга. В системе координат холста вектор оси Y направлен вниз, поэтому угол π/2 будет внизу окружности.sweepAngle
— угол в радианах на который сдвигается. Чтобы было проще понять, можно представить, что образуется дуга от 0 доsweepAngle
и сдвигается наstartAngle
.
useCenter
— еслиtrue
, то соединяет дугу с центром окружности, образуя сегмент.paint
— объектPaint
для настройки визуализации фигуры.
Код
1...
2 static const _windowRoofThickness = 10.0;
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 _drawSky(canvas);
7 _drawGround(canvas, size);
8 _drawWalls(canvas, size);
9 _drawDoor(canvas, size);
10 _drawDoorHandle(canvas, size);
11 _drawWindow(canvas, size);
12 _drawWindowRoof(canvas, size);
13 }
14
15 /// Рисуем крышу окна
16 void _drawWindowRoof(Canvas canvas, Size size) {
17 final painter = Paint()
18 ..color = Colors.pink.shade400
19 ..strokeCap = StrokeCap.round
20 ..style = PaintingStyle.stroke
21 ..strokeWidth = _windowRoofThickness;
22
23 // Координата X середины холста
24 final horizontalCenter = size.width / 2;
25 // Половина от ширины дома
26 const halfOfHouseWidth = _houseWidth / 2;
27
28 final housePositionOnGround =
29 size.height - _groundHeight + _houseOnGroundOffset;
30
31 final windowCenterY = housePositionOnGround - _doorHeight * 0.7;
32 final windowCenterX =
33 horizontalCenter + halfOfHouseWidth - _windowRadius - 40;
34
35 final windowCenter = Offset(windowCenterX, windowCenterY);
36
37 // Размер окна + половина от ширины крыши
38 const rectSize = _windowRadius * 2 + _windowRoofThickness / 2;
39
40 final rect = Rect.fromCenter(
41 center: windowCenter,
42 width: rectSize,
43 height: rectSize,
44 );
45
46 canvas.drawArc(
47 rect,
48 // Угол с которого начинаем дугу
49 math.pi * 7 / 6,
50 // Продолжительность дуги
51 math.pi * 4 / 6,
52 false,
53 painter,
54 );
55 }
56...
drawPath
Итак, дом практически готов, остался финальный элемент — крыша. Пусть она будет классической треугольной формы.
Вы могли подумать, что по аналогии с предыдущими методами мы воспользуемся методом drawTriangle
. Однако такого метода у объекта Canvas
нет, и здесь на помощь нам придёт метод drawPath
. Он принимает объекты Path
и Painter
в качестве входных параметров.
Класс Path
Инструмент для отрисовки векторной графики. Позволяет задать любую фигуру набором методов, описывающих путь от начальной точки до конечной через любые промежуточные координаты. Содержит набор добавленных точек и текущую позицию.
Методы класса Path
Рассмотрим некоторые методы класс Path
для лучшего понимания концепции. Методы класса Path
оперируют:
- абсолютными координатами — принимают координаты [x, y], которые равны координатам на холсте.
- относительными координатами — принимают [x, y], которые определяют координату как смещение от текущей позиции
Path
на переданное значение.
Класс Path и параметры Paint
Данные параметры класса Paint
используются для настройки соединения сегментов Path
.
strokeJoin
— вид соединения сегментов линии.StrokeJoin.miter
— внешние стороны сегментов продлеваются, чтобы образовать острый угол. При определённом пороге настраиваемом с помощью параметраstrokeMiterLimit
принимает видStrokeJoin.bevel
.StrokeJoin.round
— добавляет закругление на месте соединения сегментов.StrokeJoin.bevel
— внешние стороны сегментов обрезаются, придавая скошенный вид.
strokeMiterLimit
— пороговое значение, при которомStrokeJoin.miter
заменяется наStrokeJoin.bevel
. По умолчанию 4.0.
Теперь у нас есть всё необходимое, чтобы нарисовать крышу.
Код
1...
2 static const _houseRoofHeight = 75.0;
3 static const _houseRoofHorizontalOffset = 25.0;
4 static const _houseRoofVerticalOffset = 10.0;
5
6 @override
7 void paint(Canvas canvas, Size size) {
8 _drawSky(canvas);
9 _drawGround(canvas, size);
10 _drawWalls(canvas, size);
11 _drawDoor(canvas, size);
12 _drawDoorHandle(canvas, size);
13 _drawWindow(canvas, size);
14 _drawWindowRoof(canvas, size);
15 _drawHouseRoof(canvas, size);
16 }
17
18 /// Рисуем крышу дома
19 void _drawHouseRoof(Canvas canvas, Size size) {
20 final painter = Paint()..color = Colors.pink.shade400;
21
22 // Координата X середины холста
23 final horizontalCenter = size.width / 2;
24 // Половина от ширины дома
25 const halfOfRoofWidth = _houseWidth / 2 + _houseRoofHorizontalOffset;
26
27 final housePositionOnGround =
28 size.height - _groundHeight + _houseOnGroundOffset;
29 final roofBottomY =
30 housePositionOnGround - _houseHeight + _houseRoofVerticalOffset;
31
32 // Левый угол крыши
33 final roofLeftX = horizontalCenter - halfOfRoofWidth;
34 // Правый угол крыши
35 final roofRightX = horizontalCenter + halfOfRoofWidth;
36
37 final path = Path()
38 // Сдвигаем курсор на левый угол крыши
39 ..moveTo(roofLeftX, roofBottomY)
40 // Рисуем линию с левого угла крыши до центра
41 ..lineTo(
42 horizontalCenter,
43 housePositionOnGround - _houseHeight - _houseRoofHeight,
44 )
45 // Рисуем линию центра до правого угла крыши
46 ..lineTo(roofRightX, roofBottomY)
47 // Соединяем начальную позицию с конечной
48 ..close();
49
50 canvas.drawPath(path, painter);
51 }
52...
Вот такой замечательный домик у нас получился. Раз мы уже умеем анимировать — давайте воспользуемся знаниями и настроим смену дня и ночи.
Бонус: анимируем графику
Конструктор CustomPainter
принимает параметр Listenable repaint
.
CustomPainter
перерисовывается каждый раз, когда repaint
уведомляет о своём изменении. Обратите внимание, что при отрисовке анимации в виджете не вызывается setState
и shouldRepaint
всегда возвращает false
— CustomPainter
сам определяет, когда перерисовать холст, прослушивая объект Listenable
.
Основной плюс в том, что мы перерисовываем только холст и нет необходимости вызывать setState
или использовать AnimationBuilder
для обновления UI под новое значение анимации.
Чтобы имитировать смену дня и ночи, будем перекрашивать фон, а также включать свет в окне.
Для начала объявим поле Animation<double> timeOfDayAnimation
— его мы передаём в суперконструктор, и по его значению мы будем определять, какое время суток отображать.
Код
1...
2 /// Value 0.0 - night
3 /// Value 1.0 - day
4 final Animation<double> timeOfDayAnimation;
5
6 const HousePainter({
7 required this.timeOfDayAnimation,
8 }) : super(repaint: timeOfDayAnimation);
Теперь поменяем метод _drawSky
: будем менять цвет неба в зависимости от значения timeOfDayAnimation
.
Код
1...
2static final ColorTween _skyTween = ColorTween(
3 end: Colors.blue.shade300,
4 begin: Colors.grey.shade700,
5 );
6
7...
8
9 /// Рисуем небо
10 void _drawSky(Canvas canvas) {
11 canvas.drawPaint(Paint()
12 ..color = _skyTween.evaluate(timeOfDayAnimation) ?? Colors.transparent);
13 }
14...
Так же сделаем эффект включения света в окне.
Код
1...
2static final _windowTween = ColorTween(
3 end: Colors.blue.shade600,
4 begin: Colors.yellow.shade600,
5 ).chain(CurveTween(curve: Curves.easeInOutQuint));
6
7...
8
9 /// Рисуем окно
10 void _drawWindow(Canvas canvas, Size size) {
11 final painter = Paint()
12 ..color = _windowTween.evaluate(timeOfDayAnimation) ?? Colors.transparent;
13 ...
14 }
15...
Вот и всё! Домик построен, мы молодцы. Результат:
На этом пример с домиком можно считать завершённым. Мы рассмотрели основные методы, с помощью которых можно рисовать различные геометрические фигуры на холсте.
А в следующем параграфе мы продолжим работать с графикой — научимся наносить на холст текст и изображения.