3.10. CustomPainter: визуальные эффекты
Автор: Дмитрий Маркин
В предыдущих двух статьях мы познакомились с CustomPainter и Canvas. В этом поговорим, как с их помощью накладывать на наши графические объект визуальные эффекты:
- обрезать холст
- повернуть холст
- масштабировать холст
- наложить фильтры на изображения.
В этом нам помогут методы класса Canvas и специальный класс BlendMode. А чтобы оптимизировать отрисовку и управлять сложными эффектами, мы воспользуемся слоями (англ. layers).
Обрезка холста
У объекта Canvas есть методы для обрезки контента: clipRect, clipPath, clipRRect.
Например, это необходимо, чтобы ограничить зону отрисовки в пределах переданного размера. Допустим, нам нужно покрасить фон холста в определённый цвет. Для этого воспользуемся методом drawColor — и вот какой результат получится.
Код
@override
void paint(Canvas canvas, Size size) {
canvas.drawColor(Colors.red, BlendMode.src);
}
CustomPaint растянут на весь экран и обёрнут в SafeArea, но можно заметить что system bar (панель в верху экрана) тоже окрасился в красный. Обрежем зону отрисовки по размеру холста с помощью метода clipRect.
Пример
@override
void paint(Canvas canvas, Size size) {
// Координата левого верхнего угла холста
const leftTopCorner = Offset(0, 0);
// Координата правого нижнего угла холста
final rightBottomCorner = Offset(size.width, size.height);
// Прямоугольник за пределами которого контент холста будет обрезан
final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
canvas.clipRect(rect);
canvas.drawColor(Colors.red, BlendMode.src);
}
Как видите, зона отрисовки обрезалась по размеру холста.
Давайте сейчас чуть остановимся и посмотрим, что происходит «под капотом»:
- Сначала вызывается команда
canvas.clipRect— что означает, что каждая следующая после него операция отрисовки будет обрезаться по переданному ограничению. - Затем вызывается команда
canvas.drawColor, которая окрашивает весь холст в один цвет.
Такой алгоритм нельзя назвать хорошо оптимизированным — он обрезает каждую операцию отдельно. В нашем примере это допустимо, но представьте что нам нужно обрезать не одну операцию, а пять-шесть, и всё это в цикле.
Вот ещё пример. Мы используем операцию clipRRect, для обрезки накладываемых цветов в форме прямоугольника с закруглёнными углами.
Пример
@override
void paint(Canvas canvas, Size size) {
// Координата левого верхнего угла холста
const leftTopCorner = Offset(0, 0);
// Координата правого нижнего угла холста
final rightBottomCorner = Offset(size.width, size.height);
// Прямоугольник за пределами которого контент холста будет обрезан
final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(150));
// Обрезает хост в форме прямоугольника с закруглёнными углами
canvas.clipRRect(rrect);
// На холсте рисуется синий, затем обрезается операцией [clipRRect]
canvas.drawColor(Colors.blue, BlendMode.srcATop);
// На холсте рисуется зелёный, затем обрезается операцией [clipRRect]
canvas.drawColor(Colors.green, BlendMode.srcATop);
// На холсте рисуется красный, затем обрезается операцией [clipRRect]
canvas.drawColor(Colors.red, BlendMode.srcATop);
}
Чтобы оптимизировать процесс отрисовки, воспользуемся слоями.
Слои
Если вы работали в фоторедакторе наподобие Photoshop, то имеете представление о слоях. Если нет, то вот простое объяснение:
Примечание
Слой — это группа операций поверх холста.
Соответственно, слои можно накладывать друг на друга или смешивать друг с другом.
Вот как можно оптимизировать обрезку холста с помощью слоёв — основная идея заключается в том, что мы откладываем применение обрезания и сначала мержим все операции рисования между собой в отдельном слое.
Пример
@override
void paint(Canvas canvas, Size size) {
// Координата левого верхнего угла холста
const leftTopCorner = Offset(0, 0);
// Координата правого нижнего угла холста
final rightBottomCorner = Offset(size.width, size.height);
// Прямоугольник за пределами которого контент холста будет обрезан
final rect = Rect.fromPoints(leftTopCorner, rightBottomCorner);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(150));
// Сохраняем изначальное состояния холста до применения обрезания
canvas.save();
canvas.clipRRect(rrect);
// Сохраняем операцию обрезки
canvas.saveLayer(null, Paint());
// * Здесь операции будут обрабатываться только между собой
canvas.drawColor(Colors.blue, BlendMode.srcATop);
canvas.drawColor(Colors.green, BlendMode.srcATop);
canvas.drawColor(Colors.red, BlendMode.srcATop);
// Применяет обрезание
canvas.restore();
// * Здесь каждая операция будет обрезаться отдельно
// Востанавливает состояние холста накладывая слой с обрезанием сверху
canvas.restore();
// * Здесь не будет работать обрезание
}
В приведённом коде видно, что помимо вызова 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 с отрицательными координатами.
// Сдвигаем координатную плоскость к центру
canvas.translate(center.dx, center.dy);
// Поворачиваем холст
canvas.rotate(math.pi / 2);
// Сдвигаем координатную плоскость в исходное состояние
canvas.translate(-center.dx, -center.dy);
Использование слоёв
Трансформации холста рекомендуется совершать в отдельном слое. Так трансформация не повлияет на последующие операции
Код
// Сохраняем слой
canvas.save();
// Трансформация холста
canvas.translate(center.dx, center.dy);
canvas.rotate(math.pi / 2);
canvas.translate(-center.dx, -center.dy);
final trianglePath = Path()
..moveTo(center.dx, center.dy - radius)
..lineTo(center.dx + radius, center.dy)
..lineTo(center.dx - radius, center.dy)
..close();
// Рисуем синий треугольник
canvas.drawPath(
trianglePath,
painter..color = Colors.blue.withOpacity(0.5),
);
// Востанавливаем слой
canvas.restore();
// Рисуем красный треугольник
canvas.drawPath(
trianglePath,
painter..color = Colors.red.withOpacity(0.5),
);
Как можно заметить, мы рисуем оба треугольника в одних и тех же координатах, но в случае синего мы изменяем саму систему координат.
Трансформация на практике
Для наглядной иллюстрации использования трансформации приведём следующий пример — компас, полностью нарисованный с помощью CustomPainter. У нас есть два основных компонента: статичная подложка и изменяющая своё направление стрелка.
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _compassAnimation;
late final Tween<double> _compassTween;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3000),
);
_compassTween = Tween(
begin: _nextAngle(),
end: _nextAngle(),
);
_compassAnimation = _compassTween
.chain(CurveTween(curve: Curves.easeInOutBack))
.animate(_controller);
_controller
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
setState(() {
_compassTween.begin = _compassAnimation.value;
_compassTween.end = _nextAngle();
});
_controller
..reset()
..forward();
}
})
..forward();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Center(
child: RepaintBoundary(
child: AnimatedBuilder(
animation: _compassAnimation,
builder: (context, child) => CustomPaint(
painter: CompasPainter(angle: _compassAnimation.value),
willChange: true,
child: Container(),
),
),
),
),
),
);
}
static double _nextAngle() => math.Random().nextDouble() * (math.pi * 2);
}
class CompasPainter extends CustomPainter {
final double angle;
const CompasPainter({this.angle = 0});
@override
void paint(Canvas canvas, Size size) {
final painter = Paint()..color = Colors.red;
// Координата Y середины холста
final verticalCenter = size.height / 2;
// Координата X середины холста
final horizontalCenter = size.width / 2;
// Середина холста
final center = Offset(horizontalCenter, verticalCenter);
// Радиус окружности
final radius = size.shortestSide / 6;
// Рисуем подложку компаса
canvas.drawCircle(
center,
radius,
Paint()..color = Colors.grey,
);
final circleTopPoint = center.translate(0, -radius);
final circleBottomPoint = center.translate(0, radius);
final circleLeftPoint = center.translate(-radius, 0);
final circleRightPoint = center.translate(radius, 0);
// Рисуем буквы-обозначения на компасе
const textStyle = TextStyle(
fontWeight: FontWeight.w500,
color: Colors.black,
);
final textPainter = TextPainter();
textPainter
..textDirection = TextDirection.ltr
..text = const TextSpan(
style: textStyle,
text: 'N',
)
..layout(maxWidth: size.width)
..paint(
canvas,
circleTopPoint.translate(-textPainter.width / 2, 0),
)
..text = const TextSpan(
style: textStyle,
text: 'S',
)
..layout(maxWidth: size.width)
..paint(
canvas,
circleBottomPoint.translate(
-textPainter.width / 2, -textPainter.height),
)
..text = const TextSpan(
style: textStyle,
text: 'W',
)
..layout(maxWidth: size.width)
..paint(
canvas,
circleLeftPoint.translate(0, -textPainter.height / 2),
)
..text = const TextSpan(
style: textStyle,
text: 'E',
)
..layout(maxWidth: size.width)
..paint(
canvas,
circleRightPoint.translate(-textPainter.width, -textPainter.height / 2),
);
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(angle.clamp(0, math.pi * 2));
canvas.translate(-center.dx, -center.dy);
const markerRadius = 10;
/// Рисуем красную часть стрелки
canvas.drawPath(
Path()
..moveTo(circleTopPoint.dx, circleTopPoint.dy)
..lineTo(center.dx + markerRadius, center.dy)
..lineTo(center.dx - markerRadius, center.dy)
..close(),
painter..color = Colors.red,
);
/// Рисуем синюю часть стрелки
canvas.drawPath(
Path()
..moveTo(circleBottomPoint.dx, circleBottomPoint.dy)
..lineTo(center.dx + markerRadius, center.dy)
..lineTo(center.dx - markerRadius, center.dy)
..close(),
painter..color = Colors.blue,
);
/// Рисуем "гвоздик" по центру стрелки
canvas.drawCircle(
center,
markerRadius + 2,
Paint()..color = Colors.black,
);
canvas.restore();
}
@override
bool shouldRepaint(CompasPainter oldDelegate) => angle != oldDelegate.angle;
}
Результат выполнения
Ещё один аспект напрямую связанный со слоями — BlendMode. Это инструмент, который поможет контролировать, как разные слои накладываются друг на друга, и какие цвета получаются в итоге.
BlendMode
Строго говоря, BlendMode — это алгоритмы, для смешивания пикселей. Выбор конкретного алгоритма зависит от выбранного типа класса BlendMode. Это мы рассмотрим чуть позже, пока поговорим о том, как именно алгоритмы преобразуют цвета.
Каждый алгоритм имеет два входных параметра:
- source — рисуемое изображение;
- destination — исходное изображение, на которое компонуется source.
destination часто рассматривается как фон. Как источник, так и пункт назначения имеют четыре цветовых канала: красный, зеленый, синий и альфа-каналы.
Обычно они представлены в виде чисел в диапазоне от 0,0 до 1,0. Выходные данные алгоритма также имеют те же четыре канала со значениями, вычисленными из источника и пункта назначения.
Цель различных алгоритмов BlendMode — получить на вход 4 канала destination и 4 канала source и преобразовать их в 4 канала пикселя.
Для простоты представим задачу смешения пикселей абстрактным методом:
Color? resolveColor(Color? source, Color? destination);
Тогда стратегию BlendMode.srcOver, — дефолтное значение объекта Paint, обычное наложение картинки на фон, — можно представить так:
Color? resolveColor(Color? source, Color? destination) {
return destination ?? source;
}
То есть если пиксель накладываемого изображения не пустой — возвращает его, иначе — возвращает пиксель фона.
Все стратегии BlendMode можно посмотреть на странице официальной документации, давайте перейдём к примеру.
BlendMode на практике
Рассмотрим пример использования BlendMode. Тут нам снова помогут слои.
Код
class BlendModePainter extends CustomPainter {
const BlendModePainter();
@override
void paint(Canvas canvas, Size size) {
// Координата Y середины холста
final verticalCenter = size.height / 2;
// Координата X середины холста
final horizontalCenter = size.width / 2;
// Середина холста
final center = Offset(horizontalCenter, verticalCenter);
// Радиус окружности
final radius = size.shortestSide / 6;
// Стратегия наложения слоёв
const blendMode = BlendMode.plus;
// Сохраняем исходный фон / Выделяем отрисовку кругов в отдельный слой
canvas.saveLayer(null, Paint());
// Рисуем красный круг
canvas.drawCircle(
center.translate(0, -radius),
radius,
// Т.к. слой новый, первый круг необязательно рисовать
// c BlendMode.exclusion
Paint()..color = const Color(0xFFFF0000),
);
// Рисуем зелёный круг
canvas.drawCircle(
center.translate(-radius / 2, 0),
radius,
Paint()
..color = const Color(0xFF00FF00)
..blendMode = blendMode,
);
// Выделяем слой для отрисовки синего круга
canvas.saveLayer(null, Paint()..blendMode = blendMode);
// Рисуем синий круг
canvas.drawCircle(
center.translate(radius / 2, 0),
radius,
Paint()..color = const Color(0xFF0000FF),
);
// Объединяем слой синего круга с остальными
canvas.restore();
// Объединяем круги с исходным фоном
canvas.restore();
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
В этом примере используется стратегия смешивания BlendMode.plus. Как можно понять из названия — этот алгоритм суммирует значения каналов, поэтому при наложении друг на друга кругов они образуют такую картину.
Также мы хотели бы акцентировать ваше внимание на то, как рисуется каждый из кругов в данном примере.
- Красный круг рисуется без изменения
BlendMode, так как это первое изображение на новом слое, и ему просто не с чем смешиваться. - Зелёный круг рисуется с использованием объекта
Paint, у которого параметрblendModeравенBlendMode.plus. - Синий круг, рисуется в отдельном слое. Здесь стоит обратить внимание, что при вызове метода
saveLayerобъектуPaintтакже задаётся значениеBlendMode.plus. То есть помимо синего круга в этом слое можно нарисовать что-нибудь ещё, и при вызове метода restore весь слой будет объединяться с предыдущим с использованием стратегииBlendMode.plus.
Вот и всё! В этой статье мы рассмотрели, как накладывать графические эффекты с помощью методов Canvas и оптимизировать операции с помощью слоёв. Теперь у вас достаточно знаний, чтобы рисовать сложную графику, что сделает ваши приложения ярче и интерактивнее.
В следующей статье мы завершим разговор о CustomPainter и графике во Flutter. Поговорим о шейдерах — мощном инструменте для создания визуальных эффектов, которых нет «из коробки».
Например, с помощью шейдеров можно создавать эффекты пульсации, свечения, мерцания, переходы и анимации между изображениями, шума, зернистости, заставить изображение реагировать на касание, звуки, ввод данных и многое многое другое.