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 ファイルは中身の構造が厳密に定められているわけではないので、それぞれの 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
です。
では、実際のコードを見てみます。 Flutter の記事も多くなってきましたので、あまり詳細には触れません。
svg_map.dart
のSVGMap
が実際に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}");
});
notifier
はValueNotifier
で、後で見る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});
prefectures
にforEach
でループを回します。
その下の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
で行われます。
CustomPaint
とCustomPainter
を組み合わせると、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
が含まれている場合、selected
がtrue
になります。
それによって、選択されたパスの塗りつぶしの色が変更されます。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登録で処理できるかと思います。