3.10. CustomPainter: визуальные эффекты

В предыдущих двух параграфах мы познакомились с CustomPainter и Canvas. В этом поговорим, как с их помощью накладывать на наши графические объект визуальные эффекты:

  • обрезать холст
  • повернуть холст
  • масштабировать холст
  • наложить фильтры на изображения.

В этом нам помогут методы класса Canvas и специальный класс BlendMode. А чтобы оптимизировать отрисовку и управлять сложными эффектами, мы воспользуемся слоями (англ. layers).

Обрезка холста

У объекта Canvas есть методы для обрезки контента: clipRectclipPathclipRRect.

Например, это необходимо, чтобы ограничить зону отрисовки в пределах переданного размера. Допустим, нам нужно покрасить фон холста в определённый цвет. Для этого воспользуемся методом drawColor — и вот какой результат получится.

Код
1@override
2void paint(Canvas canvas, Size size) {
3 canvas.drawColor(Colors.red, BlendMode.src);
4}

Group

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}

Как видите, зона отрисовки обрезалась по размеру холста.

Group

Давайте сейчас чуть остановимся и посмотрим, что происходит «под капотом»:

  1. Сначала вызывается команда canvas.clipRect — что означает, что каждая следующая после него операция отрисовки будет обрезаться по переданному ограничению.
  2. Затем вызывается команда 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 — применяет матрицу трансформации к холсту.

Подробнее останавливаться на них не будем — в конце этой главки есть пример. Вместо этого мы сфокусируемся на нюансах, связанных с трансформациями.

Относительное смещение

Сделав какую-либо трансформацию, вы вероятно столкнётесь с ситуацией, что результат будет отличаться от того, что вы ожидали: фигура, которую вы хотели изменить, может переместиться совсем в другое место или вовсе исчезнуть с экрана.

Почему так происходит отлично иллюстрирует изображение ниже: все трансформации происходят левого верхнего края холста.

flt

Чтобы трансформация объекта выполнилась корректно, необходимо выполнять трансформации относительно его центра. Для этого необходимо с помощью метода 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

Group

Как можно заметить, мы рисуем оба треугольника в одних и тех же координатах, но в случае синего мы изменяем саму систему координат.

Трансформация на практике

Для наглядной иллюстрации использования трансформации приведём следующий пример — компас, полностью нарисованный с помощью 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. Как можно понять из названия — этот алгоритм суммирует значения каналов, поэтому при наложении друг на друга кругов они образуют такую картину.

Group

Также мы хотели бы акцентировать ваше внимание на то, как рисуется каждый из кругов в данном примере.

  • Красный круг рисуется без изменения BlendMode , так как это первое изображение на новом слое, и ему просто не с чем смешиваться.
  • Зелёный круг рисуется с использованием объекта Paint, у которого параметр blendMode равен BlendMode.plus.
  • Синий круг, рисуется в отдельном слое. Здесь стоит обратить внимание, что при вызове метода saveLayer объекту Paint также задаётся значение BlendMode.plus. То есть помимо синего круга в этом слое можно нарисовать что-нибудь ещё, и при вызове метода restore весь слой будет объединяться с предыдущим с использованием стратегии BlendMode.plus.

Вот и всё! В этом параграфе мы рассмотрели, как накладывать графические эффекты с помощью методов Canvas и оптимизировать операции с помощью слоёв. Теперь у вас достаточно знаний, чтобы рисовать сложную графику, что сделает ваши приложения ярче и интерактивнее.

В следующем параграфе мы завершим разговор о CustomPainter и графике во Flutter. Поговорим о шейдерах — мощном инструменте для создания визуальных эффектов, которых нет «из коробки».

Например, с помощью шейдеров можно создавать эффекты пульсации, свечения, мерцания, переходы и анимации между изображениями, шума, зернистости, заставить изображение реагировать на касание, звуки, ввод данных и многое многое другое.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф3.9. CustomPainter: работа с текстом и изображениями
Следующий параграф3.11. CustomPainter: шейдеры