Flutter で SVG 画像の表示

Flutterで、SVGの地図を描画してみます.
ただ描画してもおもしろくないので、Flutter 側で図形要素を加工して、ちょっとだけインタラクティブな日本地図を実装してみます. インフォグラフィック等に応用できるように考えています.

前準備

Flutterでは、SVG 画像をそのまま表示できません。(この記事を書いている時点では)

SVG を JPEG や PNG のような単純な画像と扱えばよいなら、flutter_svgパッケージで可能で、使い方も簡単です。

今回は、描画時に個別の要素をプログラムから変更したかったのと、少しインタラクティブにしたかったので、path_drawingパッケージを使います。

SVGはベクターデータです。 中身は、XMLで書かれていて、エディターで見たらわかりますが、ただのテキストファイルです。 図形を描画するための決められたタグと、その属性として座標値などが書かれています。

一方、Flutterで2D描画をする時には、CustomPainterなどを使って、Canvasに描画します。 過去の記事では、MLKitの認識結果をカメラ画像に重ねて描画したりするので使っています。

CustomPainterでの描画には、Pathを使った座標指定が必要です。 Pathは直線や曲線といった描画要素を組み合わせたものです。 塗り絵の枠線みたいな感じに、点を結んで行って図形を構成します。

今回使用するpath_drawingパッケージは、SVGのpathを、 dart のPathに変換してくれるものです。

pubspec.yamlにパッケージを追加します。 SVGファイルを直接XMLとして読み込んで要素の取り出しをするので、xmlパッケージも追加しています。

dependencies:
  ...
  path_drawing: ^0.5.1

  # 5.2.0 may break flutter_test
  # https://github.com/renggli/dart-xml/issues/108
  xml: 

xmlの最新は5.2.0ですが、そちらを読み込むと、flutter_testが動かないよと警告がでます。 そのために、今回はバージョン指定をしません。 こうすることで依存するパッケージが必要としているものの中で最新のパッケージが取り込まれます。 (バージョンの無指定はあまり良いやり方ではありませんね)

SVGファイルの構造

ソースコードは以下にありますので、参照して下さい。

Github - flutter_svg_map

SVG ファイルは中身の構造が厳密に定められているわけではないので、それぞれの SVG 毎に違います。

実際の構造については、SVG ファイル (images/Japan_template_large.svg) を直接見て下さい。

今回のものは、大まかには、

<svg width="1400" height="1600">
  ...
  <g stroke="black" stroke-width="3px">
    ...
    <g id="Tokyo" fill="white">
      <path d="M ... Z">
    </g>
  </g>
</svg>

<svg>下に、<g>要素があり、その下に <g id>で都道府県データが並びます。 それぞれの都道府県は、複数の<path>要素を持っていて、それぞれM ... Zでパスの座標値が指定されています。 パスは、最初に M(oveTo) で位置移動、そこから L(ine To)で線を相対値で描画し、Z でパスが閉じる様になっています。

また、全体のサイズは、幅1400で高さ1600です。

表示 Widget

では、実際のコードを見てみます。 Flutter の記事も多くなってきましたので、あまり詳細には触れません。

svg_map.dartSVGMapが実際にSVGを描画する Widget になります。 StetefulWidgetなので、処理は、Stateで実装します。

class _SVGMapState extends State<SVGMap> {
  List<MapShape>? _shapes;

  late final ValueNotifier<Offset> notifier;

_shapesは画面に描画する図形です。この後の初期化時に SVG を変換した結果が入ります。

  @override
  initState() {
    super.initState();

    notifier = ValueNotifier(Offset.zero);
    notifier.addListener(() {
      debugPrint("notified: ${notifier.value}");
    });

notifierValueNotifierで、後で見るCustomPainterを最描画させるために使います。 また、今回は実装していませんが、addListenerでclosureを追加していくと、値が変更された時に処理を実行する様にもできます。

続いて、日本地図の SVG ファイルを読み込みます。

    rootBundle.load('images/Japan_template_large.svg').then((ByteData data) {
      debugPrint('load: ${data.lengthInBytes}');

      final document = new XmlDocument.parse(utf8.decode(data.buffer.asUint8List()));
      final svgRoot = document.findAllElements('svg').first;
      final strokeRoot = svgRoot.findElements('g').first;
      final prefectures = strokeRoot.children;

まず、rootBundle.load('images/Japan_template_large.svg')で、プログラムのアセットからファイルを読み込みます。

読み込んだ結果をasUint8Listでバイナリデータとして扱いutf8.decodeで文字列に変換します。 XmlDocument.parseで、その文字列を XML 要素に変換します。

その中から、<svg>をさがして、その下の<g>strokeRootにセットします。 strokeRoot.childrenが都道府県ごとのパスデータとなります。

      List<MapShape> shapes = [];
      prefectures.forEach((node) {
        final id = node.getAttribute('id');
        if (id != null) {
          // debugPrint("xnode: ${node.getAttribute('id')}");
          final paths = node.findAllElements('path');
          paths.forEach((element) {
            final data = element.getAttribute('d');
            // debugPrint('data: $data');
            final printName = _prefecture_name[_prefecture_id[id]];
            shapes.add(MapShape(data, printName!,
                (_emergency_state.contains(_prefecture_id[id])) ? Colors.orange : Colors.white));
          });
        }
      });

      setState(() => {_shapes = shapes});

prefecturesforEachでループを回します。 その下のpath要素を取り出して、それをベースにMapShapeを作ります。

XML中では、英字になっている都道府県名 (<g id=で指定されている) を漢字にしています。 また、_emergency_stateにidが含まれていたら背景をオレンジに、それ以外は白となるようにしています。

変換用のデータは、japan_map_helper.dartに定義してあります。そちらはデータだけで partを使って読み込みます。

続いて、UIの方を準備します。

  @override
  Widget build(BuildContext context) {
    return Listener(
      onPointerDown: (e) => notifier.value = e.localPosition,
      onPointerMove: (e) => notifier.value = e.localPosition,
      child: CustomPaint(
        painter: SVGMapPainter(notifier, _shapes),
        child: SizedBox.expand(),
      ),
    );
  }

全体にListenerをつけて、ユーザがなにか操作をした時に、イベントとして受け付けられる様にします。

onPointerDownもしくはonPointerMoveでタップした場所が通知されます。 座標は、notifier.valueにセットします。

描画は、CustomPaintで、painter: SVGMapPainter()にセットしているので、そちらのpaintで行われます。

パスの描画

CustomPaintCustomPainterを組み合わせると、Widget を Canvas として直接描画ができるようになります。

class SVGMapPainter extends CustomPainter {
  SVGMapPainter(this._notifier, this._shapes) : super(repaint: _notifier);
  final List<MapShape>? _shapes;

  final ValueNotifier<Offset> _notifier;
  final Paint _paint = Paint();
  Size _size = Size.zero;

super(repaint:) に ValueNotifier を指定しています。 Widget のListenerで、_notifierの value を変えるので、その時に最描画するようなCustomPainterになります。

では、実際の描画ルーチンです。

まず、SVGの大きさと実際の表示領域の差を求めます。

  @override
  void paint(Canvas canvas, Size size) {
    if (size != _size) {
      _size = size;
      final fs = applyBoxFit(BoxFit.contain, Size(1400, 1600), size);
      final r = Alignment.center.inscribe(fs.destination, Offset.zero & size);
      final matrix = Matrix4.translationValues(r.left, r.top, 0)
        ..scale(fs.destination.width / fs.source.width);
      if (_shapes != null) {
        for (var shape in _shapes!) {
          shape.transform(matrix);
        }
      }
      print('new size: $_size');
    }

paint(Canvas canvas, Size size)には、描画キャンバスと、そのサイズが渡されます。 sizeが保存してある_sizeと異なる場合、SVG 画像のサイズと Widget のサイズから変換マトリックスを計算します。

計算できたら描画図形_shapesを変換マトリックスを使って変換します。(画面の座標系に変換されたパスになります)

図形の準備ができたら、実際の描画で、

    canvas
      ..clipRect(Offset.zero & size)
      ..drawColor(Colors.blueGrey, BlendMode.src);

..(ドット2つ)は、カスケード演算子で、同じオブジェクトに対して複数の操作をする場合に使います。

clipRect()の引数は、Rect です。 (Offset.zero & size)はちょっと不思議な書き方ですが、Offset&演算子は、Offset.zeroを起点として、sizeの大きさのRectを返す様に定義されています。

続いて、都道府県のパス描画です。

    var selectedMapShape;
    if (_shapes != null) {
      for (var shape in _shapes!) {
        final path = shape._transformedPath;
        final selected = path!.contains(_notifier.value);
        _paint
          ..color = selected ? Colors.teal : shape._color
          ..style = PaintingStyle.fill;
        canvas.drawPath(path, _paint);
        selectedMapShape ??= selected ? shape : null;

        _paint
          ..color = Colors.black
          ..strokeWidth = 3
          ..style = PaintingStyle.stroke;
        canvas.drawPath(path, _paint);
      }
    }

上で計算している変換マトリックスを適用した結果は、それぞれの_transformedPathにセットされています。 そのパスに、タップされたポイント_notifier.valueが含まれている場合、selectedtrueになります。

それによって、選択されたパスの塗りつぶしの色が変更されます。drawPathがパスの描画で、_paintに属性をセットして使います。

コードの残りの部分は、タップされた都道府県の場所に、名前を描画するものになります。

    if (selectedMapShape != null) {
      _paint
        ..color = Colors.black
        ..maskFilter = MaskFilter.blur(BlurStyle.outer, 12)
        ..style = PaintingStyle.fill;
      canvas.drawPath(selectedMapShape._transformedPath, _paint);
      _paint.maskFilter = null;

      final builder = ui.ParagraphBuilder(ui.ParagraphStyle(
        fontSize: 20,
        fontFamily: 'Roboto',
      ))
        ..pushStyle(ui.TextStyle(
          color: Colors.yellow,
        ))
        ..addText(selectedMapShape._label);
      final paragraph = builder.build()..layout(ui.ParagraphConstraints(width: size.width));
      canvas.drawParagraph(paragraph, _notifier.value.translate(0, -32));
    }

こんな感じで、SVGをXMLとして読み込んで、そこに含まれるpathを、flutterで使える形に加工できたら、 CustomPainterで描画したり、インタラクティブもどきの動作をさせることも可能です。

今回はタップしたら、描画色を変えるだけです。 そこで、CustomPainterで全部処理をするようにしていますが、コールバックで他の処理をさせる様な場合には、initStateでやったように、Listenable登録で処理できるかと思います。