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);
}

Group_22_e196e804ba.svg

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);
}

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

Group_12_8a9306c09f.svg

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

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

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

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

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

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

flt_2_7_ef8ac15c7e.svg

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

Group_13_60439c9b53.svg

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

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

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

Результат выполнения

Результат выполнения примера 1

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

Group_14_bf7da5cd45.svg

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

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

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

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

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