Раскраска SVG картинки во flutter, и почему я решил отключить Impeller
Привет всем, в прошлой статье я рассказал, как можно реализовать раскраску для растровых изображений, и сегодня расскажу, как можно похожее реализовать и для векторных картинок, а также какое влияние может оказать выбор графического движка в этом кейсе.
Реализация раскраски
Требования следующие:
Векторная картинка загружается по url
Можно масштабировать и перемещать картинку
При нажатии на область она должна закрашиваться в нужный цвет
Про векторные изображения
Прежде чем приступить к основной реализации, вспомним, что из себя представляет векторная картинка.
SVG (Scalable Vector Graphics) — это формат векторной графики, использующийся для описания изображений с помощью XML-подобных тегов. Векторные изображения в формате SVG хорошо масштабируются на любой размер без потери качества, в отличие от растровых изображений, таких как JPEG или PNG, качество которых ухудшается при увеличении.
Пример svg
Если заглянуть в код файла SVG, изображающего картинку выше, то можно увидеть примерно следующее:
XML-объявление: Опционально, указывает, что файл является XML-файлом. Например:
.
SVG тег: Корневой элемент, определяющий пространство, в котором будет отображаться графика. Например:
. Иногда это пространство может быть задано так
viewBox="0 0 200 200"
.Элементы графики: Внутри SVG тега размещаются элементы, которые описывают изображение. Это могут быть:
Примитивы (линии, круги, эллипсы, прямоугольники и т.д.): Например,
для прямоугольника.Пути (
): Самый мощный элемент SVG, описывающий сложные формы и кривые.Текст (
): Для добавления текста.Группы (
): Для группировки элементов 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 items;
final Size? size;
}
class PathSvgItem {
const PathSvgItem({
required this.path,
this.fill,
});
final Path path;
final Color? fill;
}
// 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;
}
// utils.dart
import 'package:http/http.dart' as http;
Future 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 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 viewBoxList = viewBox.split(' ');
size = Size(double.parse(viewBoxList[2]), double.parse(viewBoxList[3]));
}
// step 3: get the paths
final List 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 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 createState() => _ColoringSvgScreenState();
}
class _ColoringSvgScreenState extends State {
@override
void initState() {
_init();
super.initState();
}
Size? _size;
List? _items;
static const urlDogWithSmile =
'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX';
Future _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 createState() => _ColoringSvgScreenState();
}
class _ColoringSvgScreenState extends State {
@override
void initState() {
_init();
super.initState();
}
Size? _size;
List? _items;
static const urlDogWithSmile =
'https://vk.com/doc223802256_674334116?hash=407AqZBhX6zQrqcI3cGxCZdJGaZDbv1ywq65EZ8eHqH&dl=5KapGZXnEYzXOUUA977vWJoTB0kvZSrUzp7drp4qPIX';
Future _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 size
svg картинки, чтобы отрабатывалhitTest()
. Иначе он отрабатывать не будет, потому что по умолчанию считает размеры равнымиSize.zero
, и из-за этого по виджету фактически невозможно попастьЗаменен
painter
наforegroundPainter
. Это сделано потому что при изначальном варианте некорректно происходила обработка нажатия.
Мы реализовали раскраску векторного изображения. В примере была относительно простая картинка. Но что если попробовать на более сложных и детализированных изображениях?
При таком простом тесте я обнаружил, что чем больше на экране виджетов, тем сильнее лагает.
Impeller
Многое перепробовал, но сильная разница в перформансе была обнаружена, когда сменил графический движок. По умолчанию с определенной версии fluter на ios используется Impeller, на андроид этот движок пока как дополнительная опция. Я поставил для обеих платформ по умолчанию Skia.
Skia
Это решило вопрос с лагами при перемещении картинки из стороны в сторону. Но при масштабировании вопрос остался открытым:
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;
}
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;
}
@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),
)
],
),
),
),
),
),
),
);
}
Таким образом, уже вращаем и перемещаем не тяжеловесный объект рендеринга, а растровую картинку, что для любых движков должно быть проще:
Skia
Impeller
Итоги
Мы реализовали раскраску векторных картинок и протестировали перформанс при различных ситуациях. Оказалось, что для новый движок Impeller уступает Skia в кейсах, когда надо отобразить большое количество виджетов на экране.
Иногда можно обойти этот барьер, как показал пример, с помощью манипуляций с растровым изображением, а не самим виджетом. Но, к сожалению, так не всегда получается, и в некоторых своих проектах я отказался от использования Impeller как основного графического движка.
Я надеюсь, что материал данной статьи поможет вам оптимизировать UI в ваших проектах.