フューチャー技術ブログ

FlutterでGoogleマップを表示してみる

TIGの伊藤真彦です

先日入門記事を書いたFlutterですが、実践的なアプリケーションの作り込みも試してみました。

FlutterでGoogleマップを表示したい

FlutterではGoogleマップを表示するためのウィジェットが存在するため、どのくらい簡単に実現できるか検証してみました。

実装の準備

Google Maps Platformを活用することで、任意のアプリケーションからGoogleマップの機能を利用することができます。
アプリケーションの実装の前に、GCPアカウントを用意し、Maps JavaScript APIなど必要な機能を有効化し、APIを実行するためのキーを払い出す必要があります。

google_maps_flutter

Flutterではgoogle_maps_flutterというパッケージが存在します。Flutterチームのオフィシャルプラグインで安心感が高いです。このプラグインの機能を試してみます。

今回もFlutter on the Webで検証します。

パッケージの導入

環境構築、Hello Worldアプリケーションの用意は環境構築の記事を参照してください。
アプリケーションの用意ができたらパッケージをインストールします。

flutter pub add google_maps_flutter

Flutter on the Webではgoogle_maps_flutter_webも導入します。

flutter pub add google_maps_flutter_web

導入するとGoogleMapウィジェットが使えるようになります。

main.dart
import 'package:google_maps_flutter/google_maps_flutter.dart';

Widget build(BuildContext context) {
return GoogleMap(
mapType: MapType.normal,
initialCameraPosition: _kGooglePlex,
markers: _markers,
polylines: _lines,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
);
}

Flutter on the Webではindex.htmlのヘッダー部分にGoogle MapsのJavascriptを追加するとウィジェットが正常に動作します。

index.html
<head>
<script src="https://maps.googleapis.com/maps/api/js?key=MyApiKey"></script>
</head>

ウィジェットを組み込む

公式exampleは中々壮大な例となっており、最小限の機能を抜粋するのが逆に難しいくらいです.
GitHubのREADMEには比較的ミニマムな実装が用意されています。

main.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Google Maps Demo',
home: MapSample(),
);
}
}

class MapSample extends StatefulWidget {
@override
State<MapSample> createState() => MapSampleState();
}

class MapSampleState extends State<MapSample> {
Completer<GoogleMapController> _controller = Completer();

static final CameraPosition _kGooglePlex = CameraPosition(
target: LatLng(37.42796133580664, -122.085749655962),
zoom: 14.4746,
);

static final CameraPosition _kLake = CameraPosition(
bearing: 192.8334901395799,
target: LatLng(37.43296265331129, -122.08832357078792),
tilt: 59.440717697143555,
zoom: 19.151926040649414);

@override
Widget build(BuildContext context) {
return new Scaffold(
body: GoogleMap(
mapType: MapType.hybrid,
initialCameraPosition: _kGooglePlex,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _goToTheLake,
label: Text('To the lake!'),
icon: Icon(Icons.directions_boat),
),
);
}

Future<void> _goToTheLake() async {
final GoogleMapController controller = await _controller.future;
controller.animateCamera(CameraUpdate.newCameraPosition(_kLake));
}
}

main.dartを上記コードに置き換えてアプリケーションを起動すると画像のような状態になります。

Google Mapサンプルアプリ

実際のアプリケーションではGoogleMapウィジェットを全画面に表示するだけのものを作ることにはならないと思います。
サンプルの様々な部分を参考に何らかの機能を追加したり、他のウィジェットと組み合わせたりと拡張していきましょう。

例えば他の情報を表示する余白を作るためにContainerウィジェットでGoogleMapウィジェットをラッピングするようなことができます。
ウィジェットを用いてラッピングしたマップ

README記載のサンプルで表示される地図の場所はGoogle本社オフィスのようです、近くにゴルフ場やコンピュータ歴史博物館があるんですね、楽しそうです。

地図のプロパティを変更する

GoogleMapウィジェットのプロパティを設定する事で地図の描画スタイルや機能の有無など様々な変更ができます。
ドキュメントを参考に変更してみましょう。

例えばmapTypeプロパティをnormalに変更することで、地図の描画が航空写真から地図らしい描画に切り替わります。

dart main.dart
GoogleMap(
mapType: MapType.normal,
// 以下省略
)
地図画像

地図にマーカーを表示する

独自のアプリケーションを作り込むからには、任意の地点を地図に表示したい要望が出てくるでしょう。GoogleMapウィジェットのmarkersプロパティSet<Marker>の適切なデータを追加することでマーカーが表示されます。

Markerクラスは位置情報、アイコンなどのプロパティの他、オンクリックイベントのコールバック関数などを設定することができます。マーカーの位置情報であるLatLngクラスは緯度経度の情報です。

infoWindowでマーカーの名称や情報を追加できます。

main.dart
Set<Marker> _markers = {
Marker(
markerId: MarkerId("marker1"),
position: LatLng(37.4224411,-122.0884808),
infoWindow: InfoWindow(title: "フューチャー株式会社"),
)
};

GoogleMap(
markers: _markers,
// 以下省略
)
地図上にマーカー

地図に線を引く

GoogleMapウィジェットはpolylinesプロパティで2点間のルート情報を表示することもできます。

main.dart
Set<Marker> _markers = {
Marker(
markerId: MarkerId("marker1"),
position: LatLng(37.42246006639176, -122.08409675340478),
infoWindow: InfoWindow(title: "Google本社"),
),
Marker(
markerId: MarkerId("marker2"),
position: LatLng(37.42747752203552, -122.08057852883495),
infoWindow: InfoWindow(title: "ショアライン・アンフィシアター"),
),
};
Set<Polyline> _lines = {
Polyline(
polylineId: PolylineId("line1"),
points: [
LatLng(37.42246006639176, -122.08409675340478),
LatLng(37.42747752203552, -122.08057852883495),
],
color: Colors.blue,
),
};

GoogleMap(
markers: _markers,
polylines: _lines,
// 以下省略
)

Set<Polyline>のデータを用意することで青い線を引くことができました。

2点間で線を引く

しかしこれではスタートとゴールの間に山があろうと谷があろうと乗り越え直進するという無茶なルートしか表示できません。

Directions APIを活用する

これまで紹介したGoogle MapのAPIは地図の描画に専念して用意されたAPIです。経路探索や距離の計算は別のAPIとして公開されています。

Directions APIを利用することで、任意の2点間の現実的で最適な経路を取得することができます。 Directions APIJavascript APIのようにGCPの管理画面で機能を有効化する必要があります。

Directions APIを実行するDartパッケージとしてflutter_polyline_pointsが存在しますが、執筆時点ではレスポンスのパース処理に不具合があったため、愚直にDirections APIを実行し、レスポンスのパースはgoogle_polyline_algorithmで行いました。

main.dart
import 'package:google_polyline_algorithm/google_polyline_algorithm.dart';
Future<List<LatLng>> getPolylineResult(
LatLng origin,
LatLng destination,
List<LatLng> wayPoints) async {
var params = {
"origin": "${origin.latitude},${origin.longitude}",
"destination": "${destination.latitude},${destination.longitude}",
"mode": "driving",
"avoidHighways": "false",
"avoidFerries": "true",
"avoidTolls": "false",
"key": "MyApiKey",
};
if (wayPoints.isNotEmpty) {
List wayPointsArray = [];
wayPoints.forEach((point) {
String encoded = encodePolyline([[point.latitude.toDouble(), point.longitude.toDouble()]]);
wayPointsArray.add("enc:${encoded}:");
});
String wayPointsString = wayPointsArray.join('|');
params.addAll({"waypoints": wayPointsString});
}
Uri uri = Uri.https("maps.googleapis.com", "maps/api/directions/json", params);
var response = await http.get(uri);
List<LatLng> polylineCoordinates = [];
if (response.statusCode != 200) {
return [];
}
var parsedJson = json.decode(response.body);
if (parsedJson["status"]?.toLowerCase() != "ok" || parsedJson["routes"] == null || parsedJson["routes"].isEmpty) {
return [];
}
List<List<num>> points = decodePolyline(parsedJson["routes"][0]["overview_polyline"]["points"]);
points.forEach((point) {
polylineCoordinates.add(LatLng(point[0].toDouble(), point[1].toDouble()));
});
return polylineCoordinates;
}

パッケージを導入すると使えるようになるdecodePolyline()はレスポンスにあるエンコードされた経路情報をデコードして緯度経度の配列にする関数です。

曲がり角の情報だけでなく、線を道に沿って表示するにはどこを繋ぐとよいのか、という詳細な緯度経度情報の配列が取得できます。エンコードされた緯度軽度の圧縮アルゴリズムはドキュメントとして公開されているため、仕様に則ってデコードするだけですが、中々難しいことをしています。

Drection APIの実行結果を地図に反映させると画像のようになります。完璧なルートが出力されました、当たり前のように使ってるGoogleマップって改めて考えると凄いなと感じますね。

image.png

なおFlutter on the Webとして動かす場合、maps.googleapis.comへのGETリクエストはCORS設定の都合で失敗してしまいます。ウェブサイトとして完成させるには同一ドメインでmaps.googleapis.comへのリクエストを代行してくれるプロキシ的なバックエンドを作る必要があります。単にローカルでは一旦ブラウザで動かしたい、という場合はchromiumをインストールして、オプション付きで起動すると動作します。

下記のようなシェルスクリプトを用意します。

google-chrome-unsafe.sh
#!/bin/sh
/usr/local/bin/chromium --disable-web-security --user-data-dir="A-TEMP-LOCATION" $*

環境変数CHROME_EXECUTABLEでFlutterが起動するブラウザの起動設定をカスタマイズできます。

CHROME_EXECUTABLE=`pwd`/google-chrome-unsafe.sh flutter run

Flutter on the Webは、対象プラットフォームがモバイルアプリだけだった頃に作られたライブラリがCORS対策の問題などで上手く動かない、という可能性があるかもしれません。

アプリケーションとして整備する

最低限やれることはわかってきました。ウィジェットとしてリファクタリングする、データの取得と画面描画をリファクタリングする、オンクリックイベントの実装などアプリケーションとして作り込んでいきます。サンプルアプリケーションを見るとGoogleMapウィジェットはExpandウィジェットなどと組み合わせて利用されています。今回はColumnウィジェットの一要素としてGoogleMapウィジェットを読み込んでみるスタイルにしてみます。Columnウィジェットに積み込む他のウィジェットは適当にflutterロゴを出してみます。

image.png

オンクリックイベントの実験と、infoWindowより自由度の高いUIを実現したい、という観点で、マーカーをタップするとサイドバーが表示されるような作り込みもしてみました。

image.png

サンプルを試していた時にはマーカーやルートの位置情報をソースコードに愚直に書きましたが、実際にアプリケーションとして作り込むにあたっては、ウィジェットのレイアウトとビジネスロジックはなるべく切り分けたいですね。

表示したい情報の取得は一般的にサーバーサイドへアクセスする処理を非同期な関数で実装する事になることを想定しています。律儀にデータの取得完了を待ってからFutureBuilderウィジェットを利用して描画する手法も試してみましたが、地図に描画するデータの更新ではマーカーやルートを取得して差し替えた際に地図そのものの再描画は発生せず綺麗に動くため、素直にsetStateを利用した方が快適に動きました。

上記の作り込みをまとめると下記のようになります。

今回は大掛かりなファイル分割は行わず、main.dartservice.dartの2ファイルに分けて、GoogleMap用のトークンを書き換えたら動くような形にしました。

main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import './service.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Google Maps Demo',
home: MapSample(),
);
}
}

class MapSample extends StatefulWidget {
@override
State<MapSample> createState() => MapSampleState();
}

class MapSampleState extends State<MapSample> {
final _scaffoldKey = GlobalKey<ScaffoldState>();
Completer<GoogleMapController> _controller = Completer();
static final CameraPosition _kGooglePlex = CameraPosition(
target: LatLng(37.42796133580684, -122.085749655962),
zoom: 14.4746,
);
Set<Polyline> _lines = {};
Set<Marker> _markers = {};
String _drawer_name = "";
String _drawer_info = "";
String _drawer_lat = "";
String _drawer_lng = "";

@override
void initState() {
super.initState();
createPolylines();
createMarkers();
}

@override
Widget build(BuildContext context) {
return Column(
children: [
FlutterLogo(),
MapContainer(),
FlutterLogo(),
],
);
}

MapContainer() {
return Expanded(
child: Container(
width: 1000,
height: 900,
child: Scaffold(
key: _scaffoldKey,
resizeToAvoidBottomInset: false,
drawer: MapDrawer(),
body: GoogleMap(
mapType: MapType.normal,
initialCameraPosition: _kGooglePlex,
markers: _markers,
polylines: _lines,
onMapCreated: (GoogleMapController controller) {
_controller.complete(controller);
},
),
),
)
);
}

MapDrawer() {
return Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
ListTile(
title: Text("name: ${_drawer_name}"),
),
ListTile(
title: Text("info: ${_drawer_info}"),
),
ListTile(
title: Text("latitude: ${_drawer_lat}"),
),
ListTile(
title: Text("longitude: ${_drawer_lng}"),
),
],
),
);
}

marker_tapped(Place place){
setState(() {
_drawer_name = place.name;
_drawer_info = place.info;
_drawer_lat= place.latlng.latitude.toString();
_drawer_lng= place.latlng.longitude.toString();
});
_scaffoldKey.currentState?.openDrawer();
}

void createMarkers() async {
Set<Marker> markers = await getMarkers(marker_tapped);
setState(() {
_markers = markers;
});
}

void createPolylines() async {
Set<Polyline> lines = await getLines();
setState(() {
_lines = lines;
});
}
}
service.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:google_polyline_algorithm/google_polyline_algorithm.dart';

const String apiKey = "MyApiKey";

Future<Set<Marker>> getMarkers(void Function(Place) callback) async {
Set<Place> places = await getPlaces();
Set<Marker> markers = {};
places.toList().asMap().forEach((k, v) {
markers.add(Marker(
markerId: MarkerId(k.toString()),
position: v.latlng,
onTap: () => callback(v),
));
});
return markers;
}

// DBから取得する想定
Future<Set<Place>> getPlaces() async {
LatLng latLng1 = LatLng(37.42747752203552, -122.08057852883495);
LatLng latLng2 = LatLng(37.42246006639176, -122.08409675340478);
LatLng latLng3 = LatLng(37.41746006639176, -122.08409675340478);
LatLng latLng4 = LatLng(37.42546006639176, -122.09809675340478);
Set<Place> places = {};
[latLng1, latLng2, latLng3, latLng4].asMap().forEach((int i, LatLng v) {
places.add(
Place(
name: "place${i}",
info: "it is place ${i}",
latlng: v,
)
);
});
return places;
}

Future<Set<Polyline>> getLines() async {
Set<MapRoute> routes = await getRoutes();
Set<Polyline> lines = {};
for(int i = 0; i < routes.length; i++) {
var route = routes.elementAt(i);
List<LatLng> polylineCoordinates = [];
polylineCoordinates.add(route.origin);
List<LatLng> result = await getPolylineResult(
route.origin,
route.destination,
route.wayPoints
);
polylineCoordinates.addAll(result);
polylineCoordinates.add(route.destination);
lines.add(
Polyline(
polylineId: PolylineId(i.toString()),
jointType: JointType.round,
points: polylineCoordinates,
color: Colors.blue,
)
);
};
return lines;
}

// DBから取得する想定
Future<Set<MapRoute>> getRoutes() async {
LatLng latLng1 = LatLng(37.42747752203552, -122.08057852883495);
LatLng latLng2 = LatLng(37.42246006639176, -122.08409675340478);
LatLng latLng3 = LatLng(37.41746006639176, -122.08409675340478);
LatLng latLng4 = LatLng(37.42546006639176, -122.09809675340478);
return {
MapRoute(
origin: latLng1,
destination: latLng2,
),
MapRoute(
origin: latLng1,
destination: latLng2,
wayPoints: [
latLng3,
latLng4,
],
),
};
}

Future<List<LatLng>> getPolylineResult(
LatLng origin,
LatLng destination,
List<LatLng> wayPoints) async {
var params = {
"origin": "${origin.latitude},${origin.longitude}",
"destination": "${destination.latitude},${destination.longitude}",
"mode": "driving",
"avoidHighways": "false",
"avoidFerries": "true",
"avoidTolls": "false",
"key": apiKey
};
if (wayPoints.isNotEmpty) {
List wayPointsArray = [];
wayPoints.forEach((point) {
String encoded = encodePolyline([[point.latitude.toDouble(), point.longitude.toDouble()]]);
wayPointsArray.add("enc:${encoded}:");
});
String wayPointsString = wayPointsArray.join('|');
params.addAll({"waypoints": wayPointsString});
}
Uri uri = Uri.https("maps.googleapis.com", "maps/api/directions/json", params);
var response = await http.get(uri);
List<LatLng> polylineCoordinates = [];
if (response.statusCode != 200) {
return [];
}
var parsedJson = json.decode(response.body);
if (parsedJson["status"]?.toLowerCase() != "ok" || parsedJson["routes"] == null || parsedJson["routes"].isEmpty) {
return [];
}
List<List<num>> points = decodePolyline(parsedJson["routes"][0]["overview_polyline"]["points"]);
points.forEach((point) {
polylineCoordinates.add(LatLng(point[0].toDouble(), point[1].toDouble()));
});
return polylineCoordinates;
}

class Place {
LatLng latlng;
String name;
String info;
Place({this.name = "", this.info = "", this.latlng = const LatLng(0, 0)});
}

class MapRoute {
LatLng origin;
LatLng destination;
List<LatLng> wayPoints;
MapRoute({this.origin = const LatLng(0, 0), this.destination = const LatLng(0,0), this.wayPoints = const []});
}

まとめ

  • Flutterにはチーム公式のGoogleMapライブラリが存在する
  • ルートの計算を行うにはDirections APIを活用する
  • Flutter on the WebはCORS対策の必要性がある
  • ウィジェットの組み合わせなどは公式サンプルが参考になる

特にウィジェットの組み合わせに頭を悩ませる時間が最初のうちはあると思います、OSSパッケージやサンプルコードなど様々な事例を参考にすると良いと思います。