Раскраска SVG картинки во flutter, и почему я решил отключить Impeller

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

3e2c5f1e0a95c27d9af305c4b30b7c9d.gif

Реализация раскраски

Требования следующие:

  1. Векторная картинка загружается по url

  2. Можно масштабировать и перемещать картинку

  3. При нажатии на область она должна закрашиваться в нужный цвет

Про векторные изображения

Прежде чем приступить к основной реализации, вспомним, что из себя представляет векторная картинка.

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

Пример svg

Пример svg

Если заглянуть в код файла SVG, изображающего картинку выше, то можно увидеть примерно следующее:



  
  

  
  

  
  

  
  

  
  

  
  

  
  

  1. XML-объявление: Опционально, указывает, что файл является XML-файлом. Например: .

  2. SVG тег: Корневой элемент, определяющий пространство, в котором будет отображаться графика. Например: . Иногда это пространство может быть задано такviewBox="0 0 200 200".

  3. Элементы графики: Внутри SVG тега размещаются элементы, которые описывают изображение. Это могут быть:

    • Примитивы (линии, круги, эллипсы, прямоугольники и т.д.): Например, для прямоугольника.

    • Пути (): Самый мощный элемент SVG, описывающий сложные формы и кривые.

    • Текст (): Для добавления текста.

    • Группы (): Для группировки элементов SVG.

  4. Атрибуты: Определяют свойства элементов, такие как координаты, размеры, стили заливки, обводки, трансформации. Например, 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 для бойлерплейта.

Что здесь происходит пошагово, императивно, хардкорно:

  1. Парсинг строки в XML объект, из которого можно доставать нужные нам данные

  2. Определяем размеры изображения либо через заданные width и height, либо через viewBox

  3. Также делаем предположение, что все нужные нам элементы представлены в виде path, если это не так, то пропускаем. Для преобразования строки в объект Path воспользуемся методом библиотеки path_drawing:
    Path parseSvgPathData(String svg);

  4. Для каждого элемента достаем необходимые атрибуты:
    — цвет, который может быть в виде строки «red», «blue» или в виде hex
    — трансформации, без указания которых линии будут рисоваться от координаты (0;0) и в неправильном масштабе

  5. В конце концов результат сводим в кучу и получаем на выходе один объект 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. Это сделано потому что при изначальном варианте некорректно происходила обработка нажатия.

70165413d3c8466150b5ebdbd2211494.gif

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

ea5d6b7505a9d9288891538b9b5b5509.gif

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

Impeller

Impeller

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

Skia

Skia

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

949bc72d24222422bbc6f2f834a7dc59.gifSkia

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

Skia

Impeller

Impeller

Итоги

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

Я надеюсь, что материал данной статьи поможет вам оптимизировать UI в ваших проектах.

© Habrahabr.ru