FlutterでインラインMapViewを作ってみた(サンプルコード付き)


FlutterでMapViewのプロトタイプ

Flutterでマップを表示する為にプラグインを探してみても、インラインで表示することが出来ないという問題があります。GithubのIssuesを見ても3番目にが多いのがGoogle Mapsのサポートです。コメント数で見てみるとダントツの一位ですね。

Google Mapsが使えない理由はFlutterの動作する仕組みの為で、別アクティブティを立ち上げて表示する分には使えますが、Flutterネイティブのインラインで好きなレイアウトに表示しようとすると難しいです。FlutterはSurfaceViewの上に全てのウィジェットを独自に描画しているというのがその大きな原因です。

Google Mapsをそのまま描画できなくても、描画に必要な地図データさえあれば、描画自体はそこまで難しくないのでは?という単純な甘い考えからオープンストリートマップの地図データを使ってやってみようと思い立ったのでその内容をまとめた記事がこちらです(前置きが長い)

オープンストリートマップ(OpenStreetMap)
https://www.openstreetmap.org/

地図データとは?

地図データには画像やフォントと同様に大きく2種類のデータがあります

  • ラスターデータ(jpg, png, bmp, gif...)
  • ベクトルデータ(ai, svg...)


(via マルチメディアスコーラ Chapter 2 - 東京情報大学)

地図サイトやアプリを見ていて、四角くタイル状に地図画像を描画されるサイトがあると思いますが、それらの多くが画像ファイルとして事前に描画して保存してあるデータをダウンロードして描画しています。

この場合、画像ファイルはサイズが大きくなることが多く、ダウンロードにも時間がかかり、挙動としてももっさりしていて使いにくいことが多いです。メリットとしてはクライアント側の性能が悪くても描画できるということです。昔のガラケーなどではこちらの方式で表示されていました。

スマホ全盛の現在では、画面スワイプでスクロールして動かすためにタイル形式だとダウンロードも間に合わず、転送量も多くなってしまい。デメリットが大きいです。逆にスマホ端末はCPU等の性能が高い為、描画処理自体は問題になりませんので、現在のスマホ向けではベクトル形式のデータをダウンロードしてクライアントサイドで描画することが多いです。

今回も同様にベクトルデータを取得してクライアントサイドで描画する地図を作ろうと思います。

シェープファイル

シェープファイル (Shapefile) は、 他の地理情報システム(GIS)間でのデータの相互運用におけるオープン標準として用いられるファイル形式である。[1] 例えば、井戸、川、湖などの空間要素がベクタ画像である点 (数学)、線分、多角形で示され、各要素に固有名称や温度などの任意の属性を付与できる
Wikipedia: シェープファイル

シェープファイルのダウンロード

http://download.geofabrik.de/
OpenStreetMapのデータを各地域ごとにダウンロード出来るようにしているサイトです

こちらからAsia -> Japan -> Kantoと辿って関東のデータ(kanto-latest-free.shp.zip)をダウンロードします
http://download.geofabrik.de/asia/japan/kanto-latest-free.shp.zip
READMEによると毎日同じURLから最新版のデータが更新されるそうです。

シェープファイルの読み込み

Flutterで使うためにファイルを読み込む必要がありますがPluginを探してもシェープファイルを読み込むためのプラグインは見つかりませんでした(探し方が悪いだけかも)。

今回の目的はシェープファイルを読み込むことではないのでまずは地図描画部分を作るためにjsonファイルにコンバートしたデータを読み込むことにします。

GeoJSON

GeoJSON[1]はJavaScript Object Notation (JSON) を用いて空間データをエンコードし非空間属性を関連付けるファイルフォーマットである。 属性にはポイント(住所や座標)、ライン(各種道路や境界線)、 ポリゴン(国や地域)などが含まれる

Wikipedia: GeoJSON

シェープファイルがバイナリなのに比べてGeoJSONはライブラリも豊富なテキスト形式(Json)です。今回はこちらのGeoJSONフォーマットを使うことにします

シェープファイルのコンバート

QGISを使ってシェープファイルをGeoJSONとしてエクスポートします

QGIS
https://qgis.org/ja/site/
QGISはオープンソースの地理情報を扱うためのGUIツールです。地図データを扱う人はみんな使っているツールですが、地図のデータを扱わない人にとってはおそらく初めて聞くツールですね。

↑はお台場付近の道路情報のみをQGISに読み込ませて描画したもの

gis_osm_roads_free_1.cpg
gis_osm_roads_free_1.dbf
gis_osm_roads_free_1.prj
gis_osm_roads_free_1.shp
gis_osm_roads_free_1.shx

のファイルをQGISにドラッグするとあとはQGISが描画してくれます。

シェープファイルを読み込んだあと、QGISのメニューからLayer -> Save Asで以下のダイアログが出てくるのでフォーマットにGeoJSONを選択して保存場所を指定する

で吐き出されたgeojsonの中身は以下のような感じになります

{
"type": "FeatureCollection",
"name": "gis_osm_roads_free_1",
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } },
"features": [
{ "type": "Feature", "properties": { "osm_id": "4847506", "code": 5111, "fclass": "motorway", "name": "首都高速11号台場線", "ref": "11", "oneway": "F", "maxspeed": 0, "layer": 3, "bridge": "T", "tunnel": "F" }, "geometry": { "type": "MultiLineString", "coordinates": [ [ [ 139.7578, 35.6437952 ], [ 139.7578196, 35.6417602 ], [ 139.7578327, 35.6406704 ], [ 139.757831, 35.639852 ], [ 139.7578397, 35.6395924 ], [ 139.7578542, 35.639448 ], [ 139.7578741, 35.6393004 ], [ 139.7579045, 35.6391582 ], [ 139.7579335, 35.6390562 ], [ 139.757966, 35.6389645 ], [ 139.7580079, 35.6388687 ], [ 139.7580599, 35.6387672 ], [ 139.7581094, 35.6386844 ], [ 139.7581707, 35.6385969 ], [ 139.7582349, 35.6385161 ], [ 139.7583132, 35.6384262 ], [ 139.7583976, 35.6383438 ], [ 139.75849, 35.6382657 ], [ 139.758599800000013, 35.6381829 ], [ 139.7587212, 35.638103 ], [ 139.7588227, 35.6380454 ], [ 139.7589356, 35.637996 ] ] ] } },

... 略 ...

全部そのままエクスポートしたのでサイズが700MBを超過していましたので、まずは描画周りのテストを行うため、はじめの5000行のみを使うことにしました(30MB弱)

間引いたGeoJSONをQGISに読み込ませてみたのが以下

地理的な位置をGeoJSONの配列における位置は何の関連性もないようで、良く分からない感じになっていますが、まずはこれでFlutter側の実装を進めていくことにします。

場所によってはちゃんと道路を判別可能

CustomPaintでCanvasに描画

事前に_roadsPointsにはGeoJSONから読み込んだLatLngを入れておく

Widget

List<Offset> _roadsPoints = <Offset>[];

GestureDetector(
    child: CustomPaint(
        painter: CustomMap(points: _roadsPoints),
        size: Size.infinite
    ) // CustomPaint
) // GestureDetector

CustomMap

class CustomMap extends CustomPainter {
  List<Offset> points;

  CustomMap({this.points});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
      ..color = Colors.black
      ..strokeCap = StrokeCap.round
      ..strokeWidth = 3.0;

    for (int i = 0; i < points.length - 1; i++) {
      if (points[i] != null && points[i + 1] != null) {
        canvas.drawLine(points[i], points[i + 1], paint);
      }
    }
  }

  @override
  bool shouldRepaint(CustomMap oldDelegate) => oldDelegate.points != points;
}

とりあえず、GeoJSONから読み込んだ地図データをFlutterのWidgetに描画すると、こんな状態になりました。

背景色、ボーター色、道路の色を指定した状態がこちら↓ 少し地図らしくなってきましたね。

道路情報以外のデータも読み込んで描画

まずOffsetの点群(_tmpPoints)をPathに変換

Path path = Path()..addPolygon(_tmpPoints, true);

drawPathにpathを渡してビル群を描画

canvas.drawPath(path, paint);

道路と合わせて建物も描画したのがこちら。単純に描画するだけなら色を決めて並べるだけなので簡単ですね。

ズーム出来るようにする

GestureDetectorを使ってZOOMレベルを取得

GestureDetector(
    onScaleUpdate: (ScaleUpdateDetails details) {
        setState(() {
            _scale = details.scale;
        });
    },

以下のようにcanvasのscaleメソッドを使って描画しているものを全て同時に拡縮出来ます

canvas.scale(scale, scale);

以下が実行結果

スクロール出来るようにする

GestureDetectorを使って移動距離を取得して保存

Offset _delta = Offset.zero;
... 略 ...
GestureDetector(
    onPanUpdate: (DragUpdateDetails details) {
        setState(() {
            _delta = _delta + details.delta;

translateでcanvasを移動

canvas.translate(delta.dx, delta.dy);

以下が実行結果

まとめ

以上のように地図の元データをOpenStreetMapから取得することで、Flutterネイティブで地図を描画し、拡縮、移動などMapViewの基礎的な機能を実装することが出来ました。プラットフォームのAPIは使用していないのでiOS,Android両OSで動作します(シミュレーターで確認済み)

今回特定のエリア(渋谷駅近辺)のデータだけを使って試していますので、変換済みのGeoJSONをアプリ内に埋め込んで実行しています。広いエリアに対応するには描画に必要なエリアの地図データをサーバー経由で取得するようにするなど、地図としてまともに動作するまでにはやるべきことが山積みですが、第一歩としては悪くないと思っています。

ソースコード