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

Реализация раскраски
Требования следующие:
Векторная картинка загружается по url
Можно масштабировать и перемещать картинку
При нажатии на область она должна закрашиваться в нужный цвет
Про векторные изображения
Прежде чем приступить к основной реализации, вспомним, что из себя представляет векторная картинка.
SVG (Scalable Vector Graphics) — это формат векторной графики, использующийся для описания изображений с помощью XML-подобных тегов. Векторные изображения в формате SVG хорошо масштабируются на любой размер без потери качества, в отличие от растровых изображений, таких как JPEG или PNG, качество которых ухудшается при увеличении.

Если заглянуть в код файла SVG, изображающего картинку выше, то можно увидеть примерно следующее:
<?xml version="1.0" encoding="UTF-8"?> <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg"> <!-- Красный прямоугольник с закругленными углами --> <rect x="10" y="10" width="50" height="50" rx="10" ry="10" fill="red" stroke="black" stroke-width="2"/> <!-- Синий круг --> <circle cx="100" cy="50" r="20" fill="blue" stroke="green" stroke-width="3"/> <!-- Желтый эллипс --> <ellipse cx="160" cy="50" rx="20" ry="10" fill="yellow" stroke="pink" stroke-width="2"/> <!-- Черная линия --> <line x1="10" y1="100" x2="50" y2="150" stroke="black" stroke-width="4"/> <!-- Полилиния (ломаная линия) --> <polyline points="60 100, 90 120, 120 100, 150 120" stroke="orange" stroke-width="2" fill="none"/> <!-- Многоугольник (закрытая фигура) --> <polygon points="160 100, 190 120, 160 140, 130 120" stroke="purple" fill="lime" stroke-width="1"/> <!-- Путь (комплексная фигура) --> <path d="M10 160 Q 50 200, 90 160 T 170 160" fill="none" stroke="brown" stroke-width="2"/> </svg>
XML-объявление: Опционально, указывает, что файл является XML-файлом. Например:
<?xml version="1.0" encoding="UTF-8"?>.SVG тег: Корневой элемент, определяющий пространство, в котором будет отображаться графика. Например:
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">. Иногда это пространство может быть задано такviewBox="0 0 200 200".Элементы графики: Внутри SVG тега размещаются элементы, которые описывают изображение. Это могут быть:
Примитивы (линии, круги, эллипсы, прямоугольники и т.д.): Например,
<rect x="10" y="10" width="50" height="50" rx="10" ry="10"/>для прямоугольника.Пути (
<path>): Самый мощный элемент SVG, описывающий сложные формы и кривые.Текст (
<text>): Для добавления текста.Группы (
<g>): Для группировки элементов SVG.
Атрибуты: Определяют свойства элементов, такие как координаты, размеры, стили заливки, обводки, трансформации. Например,
fill="red"задает красный цвет заливки.И многие другие. На них не будем заострять внимание.
Можно заметить, что набор элементов очень напоминает апи Canvas во flutter, который используется в CustomPaint.
Реализация
Для упрощения в этом кейсе рассмотрим картинки, у которых в качестве основных элементов только замкнутые path.
Шаги:
Во-первых, создал два класса:
VectorImage, который будет хранить все элементы картинки и её размер,PathSvgItem, который будет хранитьPath pathи цвет заливкиColor? fill
// models.dart import 'dart:ui'; class VectorImage { const VectorImage({ required this.items, this.size, }); final List<PathSvgItem> items; final Size? size; } class PathSvgItem { const PathSvgItem({ required this.path, this.fill, }); final Path path; final Color? fill; }
Во-вторых, для отрисовки реализуем
CustomPainter
// svg_painter.dart import 'package:flutter/material.dart'; import 'models.dart'; class SvgPainter extends CustomPainter { const SvgPainter(this.pathSvgItem); final PathSvgItem pathSvgItem; @override void paint(Canvas canvas, Size size) { Path path = pathSvgItem.path; final paint = Paint(); paint.color = pathSvgItem.fill ?? Colors.white; paint.style = PaintingStyle.fill; canvas.drawPath(path, paint); } @override bool shouldRepaint(SvgPainter oldDelegate) => false; }
Получение SVG файла по урл в виде строки:
// utils.dart import 'package:http/http.dart' as http; Future<String> getSvgData(String url) async { final http.Response data = await http.get(Uri.parse(url)); return data.body; }
Парсинг SVG файла. Зная приблизительную структуру SVG файла и используя библиотеку для чтения XML файлов, можем достать элементы и атрибуты. В том же файле можно добавить следующее:
// utils.dart import 'package:flutter/material.dart'; import 'package:path_drawing/path_drawing.dart'; import 'package:xml/xml.dart'; import 'models.dart'; VectorImage getVectorImageFromStringXml(String svgData) { List<PathSvgItem> items = []; // step 1: parse the xml XmlDocument document = XmlDocument.parse(svgData); // step 2: get the size of the svg Size? size; String? width = document.findAllElements('svg').first.getAttribute('width'); String? height = document.findAllElements('svg').first.getAttribute('height'); String? viewBox = document.findAllElements('svg').first.getAttribute('viewBox'); if (width != null && height != null) { width = width.replaceAll(RegExp(r'[^0-9.]'), ''); height = height.replaceAll(RegExp(r'[^0-9.]'), ''); size = Size(double.parse(width), double.parse(height)); } else if (viewBox != null) { List<String> viewBoxList = viewBox.split(' '); size = Size(double.parse(viewBoxList[2]), double.parse(viewBoxList[3])); } // step 3: get the paths final List<XmlElement> paths = document.findAllElements('path').toList(); for (int i = 0; i < paths.length; i++) { final XmlElement element = paths[i]; // get the path String? pathString = element.getAttribute('d'); if (pathString == null) { continue; } Path path = parseSvgPathData(pathString); // get the fill color String? fill = element.getAttribute('fill'); String? style = element.getAttribute('style'); if (style != null) { fill = _getFillColor(style); } // get the transformations String? transformAttribute = element.getAttribute('transform'); double scaleX = 1.0; double scaleY = 1.0; double? translateX; double? translateY; if (transformAttribute != null) { ({double x, double y})? scale = _getScale(transformAttribute); if (scale != null) { scaleX = scale.x; scaleY = scale.y; } ({double x, double y})? translate = _getTranslate(transformAttribute); if (translate != null) { translateX = translate.x; translateY = translate.y; } } final Matrix4 matrix4 = Matrix4.identity(); if (translateX != null && translateY != null) { matrix4.translate(translateX, translateY); } matrix4.scale(scaleX, scaleY); path = path.transform(matrix4.storage); items.add(PathSvgItem( fill: _getColorFromString(fill), path: path, )); } return VectorImage(items: items, size: size); } ({double x, double y})? _getScale(String data) { RegExp regExp = RegExp(r'scale\(([^,]+),([^)]+)\)'); var match = regExp.firstMatch(data); if (match != null) { double scaleX = double.parse(match.group(1)!); double scaleY = double.parse(match.group(2)!); return (x: scaleX, y: scaleY); } else { return null; } } ({double x, double y})? _getTranslate(String data) { RegExp regExp = RegExp(r'translate\(([^,]+),([^)]+)\)'); var match = regExp.firstMatch(data); if (match != null) { double translateX = double.parse(match.group(1)!); double translateY = double.parse(match.group(2)!); return (x: translateX, y: translateY); } else { return null; } } String? _getFillColor(String data) { RegExp regExp = RegExp(r'fill:\s*(#[a-fA-F0-9]{6})'); RegExpMatch? match = regExp.firstMatch(data); return match?.group(1); } Color _hexToColor(String hex) { final buffer = StringBuffer(); if (hex.length == 6 || hex.length == 7) buffer.write('ff'); buffer.write(hex.replaceFirst('#', '')); return Color(int.parse(buffer.toString(), radix: 16)); } Color? _getColorFromString(String? colorString) { if (colorString == null) return null; if (colorString.startsWith('#')) { return _hexToColor(colorString); } else { switch (colorString) { case 'red': return Colors.red; case 'green': return Colors.green; case 'blue': return Colors.blue; case 'yellow': return Colors.yellow; case 'white': return Colors.white; case 'black': return Colors.black; default: return Colors.transparent; } } }
Из-за наличия огромного многообразия атрибутов приходится городить такую портянку из своих хэлперов. Здесь покрываются далеко не все кейсы, и я уверен, что для всего этого какой-нибудь умный человек уже давно сделал библиотеку. Но я такой не нашел и просто воспользовался ChatGPT для бойлерплейта.
Что здесь происходит пошагово, императивно, хардкорно:
Парсинг строки в XML объект, из которого можно доставать нужные нам данные
Определяем размеры изображения либо через заданные
widthиheight, либо черезviewBoxТакже делаем предположение, что все нужные нам элементы представлены в виде path, если это не так, то пропускаем. Для преобразования строки в объект
Pathвоспользуемся методом библиотеки path_drawing:Path parseSvgPathData(String svg);Для каждого элемента достаем необходимые атрибуты:
- цвет, который может быть в виде строки "red", "blue" или в виде hex
- трансформации, без указания которых линии будут рисоваться от координаты (0;0) и в неправильном масштабеВ конце концов результат сводим в кучу и получаем на выходе один объект
VectorImage
При желании можно добавить обработку таких атрибутов, как strokeWidth, strokeJoin и др
Результат, который можно тянуть в другие места:
Future<VectorImage> getVectorImage(String url) async { final String svgData = await getSvgData(url); final VectorImage vectorImage = getVectorImageFromStringXml(svgData); return vectorImage; }
Наконец, реализуем сам экран для отображения полученной картинки:
import 'package:flutter/material.dart'; import 'package:painter/features/coloring_svg/svg_painter.dart'; import 'models.dart'; import 'utils.dart'; class ColoringSvgScreen extends StatefulWidget { const ColoringSvgScreen({super.key}); @override State<ColoringSvgScreen> createState() => _ColoringSvgScreenState(); } class _ColoringSvgScreenState extends State<ColoringSvgScreen> { @override void initState() { _init(); super.initState(); } Size? _size; List<PathSvgItem>? _items; static const urlDogWithSmile = 'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX'; Future<void> _init() async { final value = await getVectorImage(urlDogWithSmile); setState(() { _items = value.items; _size = value.size; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Coloring SVG')), body: _items == null || _size == null ? const Center(child: CircularProgressIndicator()) : InteractiveViewer( child: Center( child: FittedBox( // RepaintBoundary should be used to prevent rebuilds // during transformations with InteractiveViewer child: RepaintBoundary( child: SizedBox( width: _size!.width, height: _size!.height, child: Stack( children: [ for (int index = 0; index < _items!.length; index++) SvgPainterImage( item: _items![index], ) ], ), ), ), ), ), ), ); } } class SvgPainterImage extends StatelessWidget { const SvgPainterImage({ super.key, required this.item, }); final PathSvgItem item; @override Widget build(BuildContext context) { return CustomPaint( painter: SvgPainter(item), ); } }

Таким образом, выполнены требования 1 и 2.
Как отловить, на какую из фигур тапнули?
В CustomPainter есть метод bool? hitTest(Offset position), который можно использовать для этих целей.
class SvgPainter extends CustomPainter { const SvgPainter(this.pathSvgItem, this.onTap); final PathSvgItem pathSvgItem; final VoidCallback onTap; @override void paint(Canvas canvas, Size size) { Path path = pathSvgItem.path; final paint = Paint(); paint.color = pathSvgItem.fill ?? Colors.white; paint.style = PaintingStyle.fill; canvas.drawPath(path, paint); } @override bool? hitTest(Offset position) { Path path = pathSvgItem.path; if (path.contains(position)) { onTap(); return true; } return super.hitTest(position); } @override bool shouldRepaint(SvgPainter oldDelegate) { return pathSvgItem != oldDelegate.pathSvgItem; } }
Здесь добавлен коллбэк
onTap(), который обрабатывает нажатие, еслиOffset positionнаходится внутри контураPath path. Следует также вернутьtrue, чтобы на виджетах, находящихся ниже в стеке, не отрабатывал методhitTest(). Это сделано на случай, если окажется, чтоpath.contains(position) == trueдля несколькихPathSvgItem.В методе
shouldRepaintтакже добавлено условие, когда нужно перерисовать виджет.
Обновленный код виджета ColoringSvgScreen:
class ColoringSvgScreen extends StatefulWidget { const ColoringSvgScreen({super.key}); @override State<ColoringSvgScreen> createState() => _ColoringSvgScreenState(); } class _ColoringSvgScreenState extends State<ColoringSvgScreen> { @override void initState() { _init(); super.initState(); } Size? _size; List<PathSvgItem>? _items; static const urlDogWithSmile = 'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX'; Future<void> _init() async { final value = await getVectorImage(urlDogWithSmile); setState(() { _items = value.items; _size = value.size; }); } void _onTap(int index) { setState(() { _items![index] = _items![index].copyWith( fill: Colors.red, ); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Coloring SVG')), body: _items == null || _size == null ? const Center(child: CircularProgressIndicator()) : InteractiveViewer( child: Center( child: FittedBox( // RepaintBoundary should be used to prevent rebuilds // during transformations with InteractiveViewer child: RepaintBoundary( child: SizedBox( width: _size!.width, height: _size!.height, child: Stack( children: [ for (int index = 0; index < _items!.length; index++) SvgPainterImage( item: _items![index], size: _size!, onTap: () => _onTap(index), ) ], ), ), ), ), ), ), ); } } class SvgPainterImage extends StatelessWidget { const SvgPainterImage({ super.key, required this.item, required this.size, required this.onTap, }); final PathSvgItem item; final Size size; final VoidCallback onTap; @override Widget build(BuildContext context) { return CustomPaint( size: size, foregroundPainter: SvgPainter(item, onTap), ); } }
Добавлен метод
void _onTap(int index), в котором вся логика изменения цвета в конкретномPathSvgItem item. В виджетеSvgPainterImageв реализацииCustomPaintнужно передавать размерыSize sizesvg картинки, чтобы отрабатывалhitTest(). Иначе он отрабатывать не будет, потому что по умолчанию считает размеры равнымиSize.zero, и из-за этого по виджету фактически невозможно попастьЗаменен
painterнаforegroundPainter. Это сделано потому что при изначальном варианте некорректно происходила обработка нажатия.

Мы реализовали раскраску векторного изображения. В примере была относительно простая картинка. Но что если попробовать на более сложных и детализированных изображениях?

При таком простом тесте я обнаружил, что чем больше на экране виджетов, тем сильнее лагает.

Многое перепробовал, но сильная разница в перформансе была обнаружена, когда сменил графический движок. По умолчанию с определенной версии fluter на ios используется Impeller, на андроид этот движок пока как дополнительная опция. Я поставил для обеих платформ по умолчанию Skia.

Это решило вопрос с лагами при перемещении картинки из стороны в сторону. Но при масштабировании вопрос остался открытым:


Тогда я решил сделать по-хитрому, как описывал в этой статье
Во-первых, сделал пейнтер, который рисует картинку
ui.Image:
class ImagePainter extends CustomPainter { final ui.Image image; const ImagePainter(this.image); @override void paint(Canvas canvas, Size size) { final paint = Paint(); canvas.drawImage(image, Offset.zero, paint); } @override bool shouldRepaint(ImagePainter oldDelegate) => false; }
Во-вторых, реализовал логику по получению
ui.Imageпо глобальному ключу
final GlobalKey _key = GlobalKey(); bool _isInteraction = false; ui.Image? _image; void _onInteractionStart() { if (_isInteraction) return; _image = (_key.currentContext!.findRenderObject()! as RenderRepaintBoundary).toImageSync(); setState(() { _isInteraction = true; }); } void _onInteractionEnd() { if (!_isInteraction) return; setState(() { _isInteraction = false; }); _image?.dispose(); _image = null; }
В-третьих, поправил реализацию в методе
buildосновного виджета
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Coloring SVG')), body: _items == null || _size == null ? const Center(child: CircularProgressIndicator()) : InteractiveViewer( onInteractionStart: (_) => _onInteractionStart(), onInteractionEnd: (_) => _onInteractionEnd(), child: Center( child: FittedBox( child: _isInteraction ? CustomPaint( size: _size!, painter: ImagePainter(_image!), ) // RepaintBoundary should be used to prevent rebuilds // during transformations with InteractiveViewer : RepaintBoundary( key: _key, child: SizedBox( width: _size!.width, height: _size!.height, child: Stack( children: [ for (int index = 0; index < _items!.length; index++) SvgPainterImage( item: _items![index], size: _size!, onTap: () => _onTap(index), ) ], ), ), ), ), ), ), ); }
Таким образом, уже вращаем и перемещаем не тяжеловесный объект рендеринга, а растровую картинку, что для любых движков должно быть проще:


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