В предыдущих двух параграфах мы познакомились с CustomPainter
и Canvas
. В этом поговорим, как с их помощью накладывать на наши графические объект визуальные эффекты:
- обрезать холст
- повернуть холст
- масштабировать холст
- наложить фильтры на изображения.
В этом нам помогут методы класса Canvas
и специальный класс BlendMode
. А чтобы оптимизировать отрисовку и управлять сложными эффектами, мы воспользуемся слоями
(англ. layers).
Обрезка холста
У объекта Canvas
есть методы для обрезки контента: clipRect
, clipPath
, clipRRect
.
Например, это необходимо, чтобы ограничить зону отрисовки в пределах переданного размера. Допустим, нам нужно покрасить фон холста в определённый цвет. Для этого воспользуемся методом drawColor
— и вот какой результат получится.
Код
1@override
2void paint(Canvas canvas, Size size) {
3 canvas.drawColor(Colors.red, BlendMode.src);
4}
CustomPaint
растянут на весь экран и обёрнут в SafeArea
, но можно заметить что system bar (панель в верху экрана) тоже окрасился в красный. Обрежем зону отрисовки по размеру холста с помощью метода clipRect
.
1@override
2void paint(Canvas canvas, Size size) {
3 // Координата левого верхнего угла холста
4 const leftTopCorner = Offset(0, 0);
5 // Координата правого нижнего угла холста
6 final rightBottomCorner = Offset(size.width, size.height);
7
8 // Прямоугольник за пределами которого контент холста будет обрезан
9 final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
10
11 canvas.clipRect(rect);
12 canvas.drawColor(Colors.red, BlendMode.src);
13}
Как видите, зона отрисовки обрезалась по размеру холста.
Давайте сейчас чуть остановимся и посмотрим, что происходит «под капотом»:
- Сначала вызывается команда
canvas.clipRect
— что означает, что каждая следующая после него операция отрисовки будет обрезаться по переданному ограничению. - Затем вызывается команда
canvas.drawColor
, которая окрашивает весь холст в один цвет.
Такой алгоритм нельзя назвать хорошо оптимизированным — он обрезает каждую операцию отдельно. В нашем примере это допустимо, но представьте что нам нужно обрезать не одну операцию, а пять-шесть, и всё это в цикле.
Вот ещё пример. Мы используем операцию clipRRect
, для обрезки накладываемых цветов в форме прямоугольника с закруглёнными углами.
1 @override
2 void paint(Canvas canvas, Size size) {
3 // Координата левого верхнего угла холста
4 const leftTopCorner = Offset(0, 0);
5 // Координата правого нижнего угла холста
6 final rightBottomCorner = Offset(size.width, size.height);
7
8 // Прямоугольник за пределами которого контент холста будет обрезан
9 final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
10 final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(150));
11
12 // Обрезает хост в форме прямоугольника с закруглёнными углами
13 canvas.clipRRect(rrect);
14
15 // На холсте рисуется синий, затем обрезается операцией [clipRRect]
16 canvas.drawColor(Colors.blue, BlendMode.srcATop);
17 // На холсте рисуется зелёный, затем обрезается операцией [clipRRect]
18 canvas.drawColor(Colors.green, BlendMode.srcATop);
19 // На холсте рисуется красный, затем обрезается операцией [clipRRect]
20 canvas.drawColor(Colors.red, BlendMode.srcATop);
21 }
Чтобы оптимизировать процесс отрисовки, воспользуемся слоями.
Слои
Если вы работали в фоторедакторе наподобие Photoshop, то имеете представление о слоях. Если нет, то вот простое объяснение:
Слой — это группа операций поверх холста.
Соответственно, слои можно накладывать друг на друга или смешивать друг с другом.
Вот как можно оптимизировать обрезку холста с помощью слоёв — основная идея заключается в том, что мы откладываем применение обрезания и сначала мержим все операции рисования между собой в отдельном слое.
1@override
2void paint(Canvas canvas, Size size) {
3 // Координата левого верхнего угла холста
4 const leftTopCorner = Offset(0, 0);
5 // Координата правого нижнего угла холста
6 final rightBottomCorner = Offset(size.width, size.height);
7
8 // Прямоугольник за пределами которого контент холста будет обрезан
9 final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
10 final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(150));
11 // Сохраняем изначальное состояния холста до применения обрезания
12 canvas.save();
13 canvas.clipRRect(rrect);
14 // Сохраняем операцию обрезки
15 canvas.saveLayer(null, Paint());
16 // * Здесь операции будут обрабатываться только между собой
17
18 canvas.drawColor(Colors.blue, BlendMode.srcATop);
19 canvas.drawColor(Colors.green, BlendMode.srcATop);
20 canvas.drawColor(Colors.red, BlendMode.srcATop);
21
22 // Применяет обрезание
23 canvas.restore();
24 // * Здесь каждая операция будет обрезаться отдельно
25 // Востанавливает состояние холста накладывая слой с обрезанием сверху
26 canvas.restore();
27 // * Здесь не будет работать обрезание
28}
В приведённом коде видно, что помимо вызова clipRRect
есть вызовы методов save
, saveLayer
и restore
. Разберёмся с каждым из них подробнее.
save
Метод сохраняет состояние холста на момент вызова. Чтобы проще осознать это, можно представить, что вызов save
создаёт скоуп, в котором можно применять различные модификации холста — обрезание, разворот и так далее. После вызова метода restore
скоуп закрывается, и далее все трансформации не будут применяться к последующим операциям.
saveLayer
Метод сохраняет состояние холста на момент вызова в отдельный слой. Аналогично вызову save
можно представить, что создаётся скоуп. Основное отличие в том, что данный скоуп — это новый чистый слой, к которому не будут применяться ранее использованные трансформации холста.
restore
Вызов метода restore
объединяет новый слой из скоупа с ранее сохранённым состоянием холста — а значит, что также применяет все ранее использованные трансформации к новому слою.
С методами разобрались. Теперь, когда нам известно как работать со слоями, давайте рассмотрим ещё один механизм который на них основан — это трансформации холста.
Трансформации
Иногда может потребоваться изменить весть холст с уже нарисованными содержимым — например, повернуть готовую фигуру или немного увеличить её. У Canvas
есть следующие методы для трансформации холста:
-
translate
— сдвигает координатную сетку на переданные значения по осям X и Y. -
rotate
— поворачивает холст на переданный угол в радианах. -
scale
— умножает координаты холста на переданное значение.Например, прямоугольник размером 100х200, верхний левый угол которого находится в координатах (100; 150), после вызова scale(2) примет размеры 200х400 и будет находиться в координатах (200; 300).
-
skew
— трансформирует холст, наклоняя его в 2D-пространстве. -
transform
— применяет матрицу трансформации к холсту.
Подробнее останавливаться на них не будем — в конце этой главки есть пример. Вместо этого мы сфокусируемся на нюансах, связанных с трансформациями.
Относительное смещение
Сделав какую-либо трансформацию, вы вероятно столкнётесь с ситуацией, что результат будет отличаться от того, что вы ожидали: фигура, которую вы хотели изменить, может переместиться совсем в другое место или вовсе исчезнуть с экрана.
Почему так происходит отлично иллюстрирует изображение ниже: все трансформации происходят левого верхнего края холста.
Чтобы трансформация объекта выполнилась корректно, необходимо выполнять трансформации относительно его центра. Для этого необходимо с помощью метода translate
сместить холст на координаты центра объекта и после использования методов трансформации вернуть холст в исходное положение, вызвав метод translate
с отрицательными координатами.
1// Сдвигаем координатную плоскость к центру
2canvas.translate(center.dx, center.dy);
3// Поворачиваем холст
4canvas.rotate(math.pi / 2);
5// Сдвигаем координатную плоскость в исходное состояние
6canvas.translate(-center.dx, -center.dy);
Использование слоёв
Трансформации холста рекомендуется совершать в отдельном слое. Так трансформация не повлияет на последующие операции
Код
1// Сохраняем слой
2canvas.save();
3
4// Трансформация холста
5canvas.translate(center.dx, center.dy);
6canvas.rotate(math.pi / 2);
7canvas.translate(-center.dx, -center.dy);
8
9final trianglePath = Path()
10 ..moveTo(center.dx, center.dy - radius)
11 ..lineTo(center.dx + radius, center.dy)
12 ..lineTo(center.dx - radius, center.dy)
13 ..close();
14
15// Рисуем синий треугольник
16canvas.drawPath(
17 trianglePath,
18 painter..color = Colors.blue.withOpacity(0.5),
19);
20
21// Востанавливаем слой
22canvas.restore();
23
24// Рисуем красный треугольник
25canvas.drawPath(
26 trianglePath,
27 painter..color = Colors.red.withOpacity(0.5),
28);
29
Как можно заметить, мы рисуем оба треугольника в одних и тех же координатах, но в случае синего мы изменяем саму систему координат.
Трансформация на практике
Для наглядной иллюстрации использования трансформации приведём следующий пример — компас, полностью нарисованный с помощью CustomPainter
. У нас есть два основных компонента: статичная подложка и изменяющая своё направление стрелка.
Ещё один аспект напрямую связанный со слоями — BlendMode. Это инструмент, который поможет контролировать, как разные слои накладываются друг на друга, и какие цвета получаются в итоге.
BlendMode
Строго говоря, BlendMode — это алгоритмы, для смешивания пикселей. Выбор конкретного алгоритма зависит от выбранного типа класса BlendMode
. Это мы рассмотрим чуть позже, пока поговорим о том, как именно алгоритмы преобразуют цвета.
Каждый алгоритм имеет два входных параметра:
- source — рисуемое изображение;
- destination — исходное изображение, на которое компонуется source.
destination
часто рассматривается как фон. Как источник, так и пункт назначения имеют четыре цветовых канала: красный, зеленый, синий и альфа-каналы.
Обычно они представлены в виде чисел в диапазоне от 0,0 до 1,0. Выходные данные алгоритма также имеют те же четыре канала со значениями, вычисленными из источника и пункта назначения.
Цель различных алгоритмов BlendMode
— получить на вход 4 канала destination
и 4 канала source
и преобразовать их в 4 канала пикселя.
Для простоты представим задачу смешения пикселей абстрактным методом:
1Color? resolveColor(Color? source, Color? destination);
Тогда стратегию BlendMode.srcOver
, — дефолтное значение объекта Paint
, обычное наложение картинки на фон, — можно представить так:
1Color? resolveColor(Color? source, Color? destination) {
2 return destination ?? source;
3}
То есть если пиксель накладываемого изображения не пустой — возвращает его, иначе — возвращает пиксель фона.
Все стратегии BlendMode
можно посмотреть на странице официальной документации, давайте перейдём к примеру.
BlendMode на практике
Рассмотрим пример использования BlendMode
. Тут нам снова помогут слои.
Код
1class BlendModePainter extends CustomPainter {
2 const BlendModePainter();
3
4 @override
5 void paint(Canvas canvas, Size size) {
6 // Координата Y середины холста
7 final verticalCenter = size.height / 2;
8 // Координата X середины холста
9 final horizontalCenter = size.width / 2;
10 // Середина холста
11 final center = Offset(horizontalCenter, verticalCenter);
12 // Радиус окружности
13 final radius = size.shortestSide / 6;
14 // Стратегия наложения слоёв
15 const blendMode = BlendMode.plus;
16
17 // Сохраняем исходный фон / Выделяем отрисовку кругов в отдельный слой
18 canvas.saveLayer(null, Paint());
19
20 // Рисуем красный круг
21 canvas.drawCircle(
22 center.translate(0, -radius),
23 radius,
24 // Т.к. слой новый, первый круг необязательно рисовать
25 // c BlendMode.exclusion
26 Paint()..color = const Color(0xFFFF0000),
27 );
28
29 // Рисуем зелёный круг
30 canvas.drawCircle(
31 center.translate(-radius / 2, 0),
32 radius,
33 Paint()
34 ..color = const Color(0xFF00FF00)
35 ..blendMode = blendMode,
36 );
37
38 // Выделяем слой для отрисовки синего круга
39 canvas.saveLayer(null, Paint()..blendMode = blendMode);
40 // Рисуем синий круг
41 canvas.drawCircle(
42 center.translate(radius / 2, 0),
43 radius,
44 Paint()..color = const Color(0xFF0000FF),
45 );
46 // Объединяем слой синего круга с остальными
47 canvas.restore();
48
49 // Объединяем круги с исходным фоном
50 canvas.restore();
51 }
52
53 @override
54 bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
55}
В этом примере используется стратегия смешивания BlendMode.plus
. Как можно понять из названия — этот алгоритм суммирует значения каналов, поэтому при наложении друг на друга кругов они образуют такую картину.
Также мы хотели бы акцентировать ваше внимание на то, как рисуется каждый из кругов в данном примере.
- Красный круг рисуется без изменения
BlendMode
, так как это первое изображение на новом слое, и ему просто не с чем смешиваться. - Зелёный круг рисуется с использованием объекта
Paint
, у которого параметрblendMode
равенBlendMode.plus
. - Синий круг, рисуется в отдельном слое. Здесь стоит обратить внимание, что при вызове метода
saveLayer
объектуPaint
также задаётся значениеBlendMode.plus
. То есть помимо синего круга в этом слое можно нарисовать что-нибудь ещё, и при вызове метода restore весь слой будет объединяться с предыдущим с использованием стратегииBlendMode.plus
.
Вот и всё! В этом параграфе мы рассмотрели, как накладывать графические эффекты с помощью методов Canvas
и оптимизировать операции с помощью слоёв
. Теперь у вас достаточно знаний, чтобы рисовать сложную графику, что сделает ваши приложения ярче и интерактивнее.
В следующем параграфе мы завершим разговор о CustomPainter
и графике во Flutter. Поговорим о шейдерах — мощном инструменте для создания визуальных эффектов, которых нет «из коробки».
Например, с помощью шейдеров можно создавать эффекты пульсации, свечения, мерцания, переходы и анимации между изображениями, шума, зернистости, заставить изображение реагировать на касание, звуки, ввод данных и многое многое другое.