Android
iOS
GoogleMapsAPI
ionic
PWA

Android, iOS, PWAに対応するionicフレームワークで使えるGoogleマッププラグインの紹介

Ionic framework

Ionic framework(アイオニック・フレームワーク)は、AngularJSベースで動くフレームワークです。日本ではあまり知名度がないのですが、世界的にはVue.jsと並んで有名です。

JSフレームワーク単体として使うこともできますが、Apache Cordova(もしくはIonic Capacitor)と組み合わせて、Android, iOS,ブラウザに対応するクロスプラットフォーム・アプリとしても開発することができます。

今回はこのIonicフレームワークで動作するGoogleマップ・プラグインの紹介です。

Ionicフレームワークの現在のバージョンは4です。

Ionic 1, Ionic 2, Ionic 3, Ionic 4(beta)とそれぞれ違いがありますが、今回は Ionic 4(beta)をメインに取り上げたいと思います。

Ionic4の最大の特徴は、AngularJSをメインに利用していることです。
これにより、AngularJSの知識がある人は、そのまま移行しやすくなっています。

Ionic Nativeプラグインとは

Ionicフレームワークでは、Apache Cordovaと組み合わせて動作させるとき、ネイティブ機能(ブラウザの領域を超えた機能)を使うことができるIonic Nativeという仕組みを用意しています。
これによりブラウザだけでは実現できない機能を簡単に使うことができるようになっています。

Ionic Nativeの実態は、Apache Cordova向けの様々なプラグインをTypeScriptで使えるようにしたラッパープラグインです。

Ionic Nativeのプラグインを利用する場合は、それと一緒に別のプラグインもインストールします。

例えば私が担当している@ionic-native/google-mapsでは、一緒にcordova-plugin-googlemapsもインストールする必要があります。

Google Maps JavaScript APIが常に最適解ではない

もしかしたら「アプリ内でGoogleマップを表示するなら、Google Maps JavaScript API v3を使えばいいじゃないか」と思う方もいるかも知れません。

はい、その通りです。でもそれ最速ですか?

Googleは地図を表示するのに、以下のようなAPIを提供してくれています。

  • Google Maps JavaScript API v3
    • 主にブラウザで表示することを想定
    • 多くのブラウザをサポート
    • 機能も充実
    • 2Dマップのみ
    • オフラインのときは使えない
  • Google Maps SDK for Android
    • Androidアプリ内で動作
    • 3D地図を表示可能
    • Google Maps JavaScript API v3よりも早い
    • Google Maps JavaScript API v3に比べると、機能数は少ない
    • 地図を表示した箇所はある程度、内部的に地図がキャッシュされる
    • オフラインでも動作可能
  • Google Maps SDK for iOS
    • iOSアプリ内で動作
    • 特徴はGoogle Maps SDK for Androidと同じだが、全ての機能が共通するわけではない
  • Google Static Maps API
    • URLクエリ文字列で地図を指定する
    • <img>を使って表示することを想定したAPI
    • 静的な画像のみ
    • オフラインでは使用できない
  • Google Maps Embed API
    • iframeを使って表示することを想定
    • 1箇所だけの地図を表示するのには便利
    • あまりカスタマイズはできない
    • オフラインでは使用できない

このようにGoogleはそれぞれの目的に合わせて使えるAPIやSDKを提供してくれています。
例えばAndroidアプリを作るのであれば、Google Maps JavaScript API v3よりも、Google Maps SDK for Androidを使うほうが、制御もしやすく、アプリとの親和性も高いです。

でもGoogle Maps SDK for AndroidGoogle Maps SDK for iOSを使うには、それぞれJavaやSwiftなどの言語を使用して、OSごとにネイティブアプリを作る必要があります

それって大変じゃないですか?

そこで私が開発しているcordova-plugin-googlemapsの紹介です。

Android, iOS, ブラウザに対応するGoogleマップ・プラグイン

大まかな特徴

  • TypeScriptでコードを書くことができる
  • 1つのコードでAndroid, iOS, ブラウザに対応できる
  • Androidアプリ内では、Google Maps Android APIを使用
  • iOSアプリ内では、Google Maps SDK for iOSを使用
  • ブラウザで表示するときは、Google Maps JavaScript API v3を使用します
  • Android, iOSでは3Dマップやオフライン地図表示などが可能

デモ

下の画像はAndroid版のスクリーンキャプチャです。

同じコードがブラウザでも動きます。さすがにアニメーション・ズームインはできませんが、とりあえず同じような動作をします。

browser.gif

デモの解説

上記デモのコードを紹介します。この程度のコードで動きます。

import { Component, OnInit } from '@angular/core';
import { Platform } from '@ionic/angular';
import {
  GoogleMaps,
  GoogleMap,
  GoogleMapsEvent,
  Marker,
  GoogleMapsAnimation,
  MyLocation
} from '@ionic-native/google-maps';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})
export class HomePage implements OnInit {
  map: GoogleMap;

  // このHomePageクラスが作成されるときに実行される
  constructor(private platform: Platform) {}

  // ngOnInitは、AngularJSの準備が完了したら実行される
  async ngOnInit() {

    // Apache Cordovaから 'deviceready'イベントが発行されるのを待つ
    await this.platform.ready();

    // platform.ready()が完了したら、地図を作成する
    await this.loadMap();
  }

  async loadMap() {

    // Googleマップを作成
    this.map = GoogleMaps.create('map_canvas', {
      camera: {
        target: {
          lat: 43.0741704,
          lng: -89.3809802
        },
        zoom: 18,
        tilt: 30
      }
    });

  }

  // ボタンが押された時の処理
  onButtonClick() {

    // 現在位置を取得
    this.map.getMyLocation().then((location: MyLocation) => {
      // アニメーションで指定の位置にズームイン 
      this.map.animateCamera({
        target: location.latLng,
        zoom: 17,
        tilt: 30
      });

      // アニメーションが終了したらマーカーを追加
      let marker: Marker = this.map.addMarkerSync({
        title: '@ionic-native/google-maps plugin!',
        snippet: 'This plugin is awesome!',
        position: location.latLng,
        animation: GoogleMapsAnimation.BOUNCE
      });

      // 情報ウィンドウの表示
      marker.showInfoWindow();

      // もし情報ウィンドウがクリックされたら、アラートを表示
      marker.on(GoogleMapsEvent.MARKER_CLICK).subscribe(() => {
        alert('clicked!');
      })
    })
    .catch(err => {
      // getMyLocationでエラーが発生したら、メッセージを表示
      alert(err.error_message);
    });
  }
}

コード中のコメントを読んでいただければ、大体分かるとは思いますが、一つずつ見ていきましょう。

初期化のタイミング

Ionicフレームワークは、Apache Cordova上で動いています。
Apache Cordovaは、ブラウザサイドとネイティブサイドの2つがあり、ブラウザからネイティブの機能を呼び出すことができるのが便利なところです。
ただブラウザの初期化されるタイミングと、ネイティブサイドが初期化されるタイミングは異なります。

ブラウザサイドは、乱暴な言葉で言ってしまえば、単なるHTMLです。
もしフレームワークを何も利用しなければ、window.onloadの時点で初期化完了です。
Ionic 4の場合、AngularJSを利用しているので、AngularJSの準備が完了したことを知らせるngOnInit()を利用します。

ネイティブサイドはブラウザとは別の仕組みで動いているので、初期化に時間がかかります。
どのくらい時間がかかるのかは、インストールしているプラグインの数にもよりますが、大体1〜2秒程度かかります。
このネイティブサイドが準備完了になったことを知らせるのに、devicereadyというイベントが発行されますが、これを待つのが、platform.ready()です。

この両方が準備完了になったら、このloadMap()関数を実行することができます。

// このHomePageクラスが作成されるときに実行される
constructor(private platform: Platform) {}

// ngOnInitは、AngularJSの準備が完了したら実行される
async ngOnInit() {

  // Apache Cordovaから 'deviceready'イベントが発行されるのを待つ
  await this.platform.ready();

  // platform.ready()が完了したら、地図を作成する
  await this.loadMap();
}

地図の作成

地図の作成は、この部分で行っています。

GoogleMaps.create()の第1引数ではどこに地図を作成するかを指定し、第2引数は初期化オプションです。

map_canvasというのは、HTML側にある<div id="map_canvas"></div>のIDです。

// Googleマップを作成
this.map = GoogleMaps.create('map_canvas', {
  camera: {
    target: {
      lat: 43.0741704,
      lng: -89.3809802
    },
    zoom: 18,
    tilt: 30
 }
});

位置情報の取得

HTML側のファイルには、このように定義してあり、ボタンがクリックされるとonButtonClick()が実行されます。

<div id="map_canvas">
  <ion-button (click)="onButtonClick()">Demo</ion-button>
</div>

ボタンが押されたら、まずthis.map.getMyLocation()であなたの位置情報を取得します。

ブラウザ側の実装ではnavigation.getCurrentLocation()、Android/iOSではそれぞれの位置情報を取得するAPIを使って位置を取得します。位置情報を取得するにはパーミッションを取得したり、GPSの有無をチェックしたり、、、、など割と面倒ですが、そうゆうことは全部 getMyLocation()メソッドの内部に隠されています。あなたはgetMyLocation()とすれば、位置情報を取得することができるのです。

// ボタンが押された時の処理
onButtonClick() {

  // 現在位置を取得
  this.map.getMyLocation().then((location: MyLocation) => {
    ...
  })
  .catch(err => {
    // getMyLocationでエラーが発生したら、メッセージを表示
    this.showToast(err.error_message);
  });
}

カメラ位置の操作

カメラとは、地図を見るためのカメラです。
このカメラに映る位置・角度・ズームを調整して、そこに映る地図が表示されていると思ってください。 

次のコードは、targetで指定する位置にアニメーションをしながらカメラを移動します。
カメラの移動が、zoom=17, tilt=30になるように自動的に調整してくれます。
tiltは、カメラと地図の角度です。

// アニメーションで指定の位置にズームイン 
this.map.animateCamera({
  target: location.latLng,
  zoom: 17,
  tilt: 30
});

マーカーの追加と情報ウィンドウの表示

カメラをあなたの現在位置に移動させたら、マーカーを表示させましょう。
このコードは、カメラの移動が終了したら実行されます。

// アニメーションが終了したらマーカーを追加
let marker: Marker = this.map.addMarkerSync({
  title: '@ionic-native/google-maps plugin!',
  snippet: 'This plugin is awesome!',
  position: location.latLng,
  animation: GoogleMapsAnimation.BOUNCE
});

// 情報ウィンドウの表示
marker.showInfoWindow();

マーカーがクリックされたときの処理

最後にマーカーがクリックされたときの処理を書きます。

marker.on(GoogleMapsEvent.MARKER_CLICK).subscribe(() => {
  alert('clicked!');
});

マーカーがクリックされると、MARKER_CLICKイベントを発行します。
これをキャッチするにはone()on()の2つの方法があります。

one()は1回のみイベントをキャッチします。

marker.one(GoogleMapsEvent.MARKER_CLICK).then(() => {
  alert('1回だけ表示されます');
});

on()は複数回イベントをキャッチします。

marker.on(GoogleMapsEvent.MARKER_CLICK).subscribe(() => {
  alert('複数回表示されます');
});

目的に合わせて使い分けましょう。

map.animateCamera()のマジック

ここでJavaScriptに少し詳しい方なら疑問に思うかもしれません。

「あれっ?map.animateCamera()は非同期処理じゃないの?」

はい。非同期処理です。というか、Apache Cordovaでネイティブサイドのコードを実行するときは、全て非同期処理になります。

this.map.animateCamera({
  ...
}).then(() => {
  console.log('アニメーション完了!');
});
console.log('animateCamera()が実行された');

let a = 1;
console.log('a = 1が実行された');

例えば上記のコードを実行すれば、期待通りの答えになります。

animateCamera()が実行された
a = 1が実行された
アニメーション完了!

ではなぜ animateCamera()の次に来るaddMarkerSync()は、animateCamera()の実行が完了してから実行されるのでしょう。

それは、async, awaitのようなものを内部的に実装しているからです。

もう少し詳しく説明すると、この地図プラグインは自前のコマンドキューを持っていて、そこでどれを並列で実行して、どれを同期処理で実行するかを制御しています。

なので可能な限りパフォーマンスが最大になるように設計されています。


各機能の紹介

ここからはこのプラグインに実装されている様々な機能を紹介します。


HTMLInfoWindow

「情報ウィンドウの中にHTMLを表示したい」という場合は、HTMLInfoWindowというクラスを使用してください。

というのも、Google Maps SDK for AndroidとGoogle Maps SDK for iOSでは情報ウィンドウの中にHTMLを表示できないからです。

なのでHTMLを表示できるスペシャルな情報ウィンドウを作っちゃいました。それがHTMLInfoWindowです。

let htmlInfo: HTMLInfoWindow = new HtmlInfoWindow();
htmlInfo.setContent('<h3>Hello world!</h3>');
htmlInfo.open(marker);

Screen Shot 2018-09-12 at 11.30.58 AM.png

え、改行されるのが嫌ですか?

じゃあ、CSSで指定してください。

htmlInfoWindow.setContent('<h3>Hello world!</h3>', {
  'white-space': 'pre'
});

Screen Shot 2018-09-12 at 11.33.09 AM.png

え、なんですか? こんなのじゃ、つまらない? もっと面白いサンプルはないのかって?

じゃあ、こんなのはどうでしょう。

let htmlInfoWindow = new HtmlInfoWindow();

// flip-flop contents
// https://davidwalsh.name/css-flip
let frame: HTMLElement = document.createElement('div');
frame.innerHTML = `
<div class="flip-container" id="flip-container">
  <div class="flipper">
    <div class="front">
    <h3>Click this photo!</h3>
    <img src="assets/imgs/hearst_castle.jpg">
  </div>
  <div class="back">
    <!-- back content -->
    Hearst Castle above the clouds on top of The Enchanted Hill. William Randolph Hearst started to build a fabulous estate on his ranchland overlooking the village of San Simeon in 1919.
    </div>
  </div>
</div>`;

frame.addEventListener("click", (evt) => {
  let container = document.getElementById('flip-container');
  if (container.className.indexOf(' hover') > -1) {
    container.className = container.className.replace(" hover", "");
  } else {
    container.className += " hover";
  }
});
htmlInfoWindow.setContent(frame, {
  width: "170px"
});


let marker: Marker = this.map.addMarkerSync({
  position: {lat: 35.685208, lng: -121.168225},
  draggable: true,
  disableAutoPan: true
});

marker.on(GoogleMapsEvent.MARKER_CLICK).subscribe(() => {
    htmlInfoWindow.open(marker);
});
marker.trigger(GoogleMapsEvent.MARKER_CLICK);

browser.gif

HTMLInfoWindowはブラウザ上で表示しています。なのでブラウザ上で表示できるものであれば、なんでも表示することが可能です。


Polyline

地図上にポリラインを描画したい場合は、map.addPolylineSync()を使います。
pointsプロパティにラインを描画したい位置の配列を渡すだけでOKです。
clickable = trueにすれば、POLYLINE_CLICKイベントも取得できます。

let AIR_PORTS = [
  HND_AIR_PORT,
  HNL_AIR_PORT,
  SFO_AIR_PORT
];

// 地図にポリラインを追加
let polyline: Polyline = this.map.addPolylineSync({
  points: AIR_PORTS,
  color: '#AA00FF',
  width: 10,
  geodesic: true,
  clickable: true  // clickable = false in default
});

// ポリライン上がクリックされたら、その位置にマーカーを表示する
polyline.on(GoogleMapsEvent.POLYLINE_CLICK).subscribe((params: any) => {
  // params[0]にはクリックされた位置が入っている
  let position: LatLng = <LatLng>params[0];

  // クリックされたら位置にマーカーを追加
  let marker: Marker = this.map.addMarkerSync({
    position: position,
    title: position.toUrlValue(),
    disableAutoPan: true
  });

  // 情報ウィンドウの表示
  marker.showInfoWindow();
});


Polygon

一定の領域を塗りつぶすには、map.addPolygonSync()を使います。
領域の頂点の位置の配列を渡すだけです。

GORYOKAKU_POINTS: ILatLng[] = [
  {lat: 41.79883, lng: 140.75675},
  {lat: 41.799240000000005, lng: 140.75875000000002},
  {lat: 41.797650000000004, lng: 140.75905},
  {lat: 41.79637, lng: 140.76018000000002},
  {lat: 41.79567, lng: 140.75845},
  {lat: 41.794470000000004, lng: 140.75714000000002},
  {lat: 41.795010000000005, lng: 140.75611},
  {lat: 41.79477000000001, lng: 140.75484},
  {lat: 41.79576, lng: 140.75475},
  {lat: 41.796150000000004, lng: 140.75364000000002},
  {lat: 41.79744, lng: 140.75454000000002},
  {lat: 41.79909000000001, lng: 140.75465}
];

let polygon: Polygon = this.map.addPolygonSync({
  'points': this.GORYOKAKU_POINTS,
  'strokeColor' : '#AA00FF',
  'fillColor' : '#00FFAA',
  'strokeWidth': 10
});

Screen Shot 2018-09-12 at 11.54.56 AM.png


Circle

円を描画したい場合は、map.addCircleSync()を使います。
円の中心位置と、そこからの半径(m)を指定するだけです。

let center: ILatLng = {"lat": 32, "lng": -97};
let radius = 300;  // radius (meter)

let circle: Circle = this.map.addCircleSync({
  'center': center,
  'radius': radius,
  'strokeColor' : '#AA00FF',
  'strokeWidth': 5,
  'fillColor' : '#00880055'
});

Screen Shot 2018-09-12 at 12.23.19 PM.png


GroundOverlay

地図の上の特定の地域にだけ画像を貼り付けたい場合、map.addGroundOverlaySync()を使用します。

this.groundOverlay = this.map.addGroundOverlaySync({
  'url': 'assets/imgs/newark_nj_1922.jpg',
  'bounds': bounds,
  'opacity': 0.5
});

Screen Shot 2018-09-12 at 12.26.35 PM.png

無駄に便利なのが、bearingプロパティです。ここに回転角を指定すれば、画像を回転することが可能です。

this.groundOverlay = this.map.addGroundOverlaySync({
  'url': 'assets/imgs/newark_nj_1922.jpg',
  'bounds': bounds,
  'opacity': 0.5,
  'bearing': 45
});

Screen Shot 2018-09-12 at 12.28.17 PM.png

実はGoogle Maps JavaScript v3のGroundOverlayには、このbearingに相当する機能はありません。
実装されてないので、自前でGroundOverlayに相当する機能を作っています。


タイルオーバーレイ

地図そのものを変えたいときは、map.addTileOverlaySync()を使います。

this.map.addTileOverlaySync({
  getTile: (x: number, y: number, zoom: number) => {
    return "http://tile.stamen.com/watercolor/" +
            zoom + "/" + x + "/" + y + ".jpg";
  },

  // draw the debug information on tiles
  debug: false,

  opacity: 1.0
});


マーカークラスター

地図上に数百箇所もマーカーを表示するのは、ユーザーとして非常に使い勝手が悪いです。
そうゆうときはマーカークラスターをオススメします。
ズームインするときに表示される赤い枠は、boundsDraw = falseで消すことが可能です。

let markerCluster: MarkerCluster = this.map.addMarkerClusterSync({
  markers: data,
  boundsDraw: true, // 赤い枠を表示
  icons: [
    {
      min: 3,
      max: 9,
      url: "./assets/markercluster/small.png",
      label: {
        color: "white"
      }
    },
    {
      min: 10,
      url: "./assets/markercluster/large.png",
      label: {
        color: "white"
      }
    }
  ]
});

// マーカーがクリックされたときの処理
markerCluster.on(GoogleMapsEvent.MARKER_CLICK).subscribe((params) => {
  let marker: Marker = params[1];
  marker.setTitle(marker.get("name"));
  marker.setSnippet(marker.get("address"));
  marker.showInfoWindow();
});


ジオコーディング(1)

場所の名称や住所から緯度経度に変換することを『ジオコーディング』といいます。

// 住所 -> 緯度経度
this.geocoder.geocode({
  "address": this.search_address
})
.then((results: GeocoderResult[]) => {
  console.log(results);

  return this.map1.addMarker({
    'position': results[0].position,
    'title':  JSON.stringify(results[0].position)
  })
})
.then(...)


ジオコーディング(2)

このプラグインでは、バッチリクエスト(まとめて処理)もサポートしています。

this.geocoder.geocode({
  // US Capital cities
  "address": [
    "Montgomery, AL, USA", "Juneau, AK, USA", ...
    "Madison, WI, USA", "Cheyenne, Wyoming, USA"
  ]
})
.then((mvcArray: BaseArrayClass<GeocoderResult[]>) => {

});

アメリカの50箇所(50州の州都名)をジオコーディングしている例です。
50箇所のジオコーディングを1.9秒で完了させています。


KMLOverlay

データとコードを分離したい。そう思うかもしれません。
そんな場合は、データをKMLファイルに保存して、読み込みにmap.addKmlOverlay()を使用してください。

this.map.addKmlOverlay({
  url: "assets/kmloverlay/polygon.kml",
  icon: "green"
}).then((kmlOverlay: KMLOverlay) => {

  this.map.moveCamera(kmlOverlay.getDefaultViewport());

  // クリックされたときの処理。
  // どのオーバーレイがクリックされたのかが含まれる。
  kmlOverlay.on(GoogleMapsEvent.KML_CLICK).subscribe((params: any) => {
    let overlay: Polygon = params[0]; // depends on overlay
    let latLng: ILatLng = params[1];
    console.log(overlay, latLng);
  });

});

kml.gif

StreetView

Google StreetViewも表示できます。
地図の作成と同じようなやり方で、GoogleMaps.createPanorama()を使います。

this.panorama = GoogleMaps.createPanorama('pano_canvas', {
  camera: {
    target: initialPos
  }
});

Screen Shot 2018-09-12 at 12.58.17 PM.png

デモでは、StreetViewの上に小さな地図を重ね合わせて表示しています。
これはこうゆう感じでできます。

<div id="pano_canvas">
  <div id="map_canvas"></div>
</div>
#pano_canvas {
  height: 90%;
  position: relative !important;
}

#map_canvas {
  bottom: 5%;
  left: 5%;
  width: 150px;
  height: 150px;
  position: absolute !important;
  z-index: 2;
}
// パノラマの作成
this.panorama = GoogleMaps.createPanorama('pano_canvas', {
  camera: {
    target: initialPos
  }
});

// 地図を作成
this.map = GoogleMaps.create('map_canvas', {
  camera: {
    target: initialPos,
    zoom: 17
  }
});

// マーカーを作成
this.marker = this.map.addMarkerSync({
  flat: true,
  position: initialPos
});

// パノラマの位置が移動したら、マーカーの位置を移動させる
// panorama.positionプロパティを、marker.positionプロパティに同期させる
this.panorama.bindTo('position', this.marker);

// パノラマの位置が移動したら、地図のカメラ位置を変更する
this.panorama.on(GoogleMapsEvent.PANORAMA_LOCATION_CHANGE).subscribe((params:any[]) => {
  let location: StreetViewLocation = params[0];
  this.map.animateCamera({
    target: location.latLng,
    duration: 1000
  });
});

// パノラマのカメラの向きが変わったら、マーカーを回転させる
this.panorama.on(GoogleMapsEvent.PANORAMA_CAMERA_CHANGE).subscribe((params: any[]) => {
  let camera: StreetViewCameraPosition = params[0];
  this.marker.setRotation(camera.bearing - 180);
});

streetview.gif


最後に

どうでしょうか? なんとなく作れそうでしょ?

紹介したデモは、このページで見ることができます。
https://mapsplugin.github.io/ionic-googlemaps-quickdemo-v4/

最後にもう一度いいますが、同じコードでAndroid, iOS, ブラウザ上のどこでも同じように動作します。

えっ、地図以外の部分のUIはどうしたらいいのかって?

ここにIonic v4のAPIが定義されてます。サンプルを見ていれば分かると思います。
https://beta.ionicframework.com/docs/api

日本語で質問できる場所がほしいですか?
Ionicユーザー達がここで会話しています。Slackに登録すれば、日本語で質問できます。
https://ionic-jp.connpass.com/

(私にIonicのことはあまり聞かないでください)

ちなみに@ionic-native/google-mapsvue.js上でIonicなしでも動きます(そのはず)