Android
GoogleMapsAPI

GoogleMapでmap-utilを使用したマーカーのInfoWindowをカスタムする方法

はじめに

developerサイトのInfo WindowガイドページによればGoogleMap上に設置したMarkerをタップした時に以下イメージのような表示ができるとあります。
01_GoogleDevelopSample.png
引用元: Maps SDK for Android -> GUIDES -> Info Windowsより
(https://developers.google.com/maps/documentation/android-sdk/infowindows)

このInfoWindowのレイアウトをカスタマイズする方法が分からず色々調査しました。
Markerは設置数が多くなると地図表示が重くなったりメモリを食ってしまいます。それを回避するためmap-utilライブラリを使おうと思ったらさらに手間がかかったため今回調べたことをまとめて記事に残すことにしました。
本記事の目的はmap-utilライブラリを使用して上記画像イメージのようなInfoWindowを表示することです。
(なお、GoogleMapを使用するためGoogle Maps Android APIが必要です。)

検証環境

環境やライブラリは以下の通りです。ライブラリはdatabindingやConstraintLayoutも使ってますがそういうのは除外して今回の主旨に関係するものを記載しています。

  • 環境
    • API Level: 19〜27
  • ライブラリ
    • play-services-maps: 16.0.0
    • android-maps-utils: 0.5(Markerを自動でまとめるClusterが使えるライブラリ)
    • Glide: 4.6.1 (Picassoでも同じことができると思います)
    • OkHttp: 3.11.0

コードや参考サイトなど

GitHubにコードを載せておきますのでどうすればいいかだけ手っ取り早く知りたい方はこちらでコード読んでください。
https://github.com/hotdrop/mapmarkertest

また、Maps SDK for AndroidのInfo WindowsガイドのCode samplesに挙げられているApiDemos repositoryもコードの参考になると思います。
ここからは補足やハマった点などを記載します。

1. Markerをまとめるには

android-maps-utilsライブラリのClusterManagerを使用することで以下のように一定のズームアウトでMarkerを自動でまとめてくれます。
02_map-utilSample.png

Maps SDK for AndroidのGoogle Maps Android Marker Clustering Utilityもご参照ください。
簡単にClusterManagerが何をやっているのか調べました。

1-1. 通常のMarkerとClusterManagerのClusterItemを使ったMarkerの扱いの違い

通常、地図上にMarkerを付ける場合はcom.google.android.gms.maps.modelのMarkerクラスを使用しGoogleMapオブジェクトに対して以下のようなコードを書くと思います。

googleMap.addMarker(
  MarkerOptions().position(LatLng(35.681167, 139.767052))
                 .title("東京駅"))

これに対し、ClusterManagerを使って地図上にMarkerを付ける場合はcom.google.maps.android.clusteringのClusterItemクラスを使用します。緯度経度情報やMarkerをタップした時のタイトルもClusterItemが持ちます。ClusterItemはインタフェースなので自身で実装を書きます。コード例です。

clusterManager.addItem(
  MyClusterItem(location = LatLng(35.681167, 139.767052), title = "東京駅")
)

data class MyClusterItem constructor(
        private val location: LatLng,
        private val title: String,
        private val snippet: String? = null
        // 他に地図タップ時にInfoWindow上に表示したい情報もModelクラスなどでここに持てる
): ClusterItem {
    override fun getPosition(): LatLng = location
    override fun getTitle(): String = title
    override fun getSnippet(): String? = snippet
}

MarkerはMarkerクラスを単位として扱います。例えばsetOnMarkerClickListenerで渡ってくる引数の型はMarkerクラスになるのでMarkerしか扱わない場合はとても簡単です。これがClusterManagerを使った場合にちょっと厄介な話になります。(何が厄介なのかは後述します。)
ClusterManagerが何やってるのかいまいち不明だったので簡単に中身を覗いてみました。詳しくコードを追っていないのでより詳しく知りたい方はぜひコードを読んでみてください。

  1. Algorithmクラス
    ClusterManagerクラスのaddItemメソッドやremoveItemメソッドなどを読むとAlgorithm型のオブジェクトにClusterItemの持ち方や処理を委ねていることがわかります。
    このAlgorithm型の実装クラスがズームインやズームアウトで地図のMarkerをどうまとめるのかなど具体的な処理を担当しているようです。
    デフォルトで使用されているのはNonHierarchicalDistanceBasedAlgorithmというクラスでヘッダーコメントにMarkerをどうクラスタするのか簡単に書いてありました。どうもMarker間の距離計算をしてクラスタを生成したり分解しているようです。
    さらにClusterManagerクラスではこのAlgorithmクラスをPreCachingAlgorithmDecoratorクラスでラップしていました。こいつは単にズームレベルに応じてよしなにフェッチして必要に応じてキャッシュするもののようです。
    ClusterManagerクラスにはsetAlgorithmメソッドがあるのでクラスタの計算を自作したい場合も対応可能です。
  2. ClusterRendererクラス
    名前の通りGoogleMapオブジェクトとの中継をするクラスで、クラスタやMarkerをタップした時のListenerやカメラ操作、地図上へのMarker設置などViewとのやり取りをこのクラスが担当しているようです。中身はほとんど読んでいません。。
  3. clusterメソッド
    このメソッドを実行すると地図上にMarkerが配置されます。実行する際はAsyncTaskを継承したClusterTaskの中でdoInBackgroundでAlgorithmクラスに自身が持つClusterItem群の処理を依頼しonPostExecuteでClusterRendererクラスに描画依頼をしています。
  4. 補足
    通常のMarkerを使い慣れた方だとaddMarkerを実装するだけで地図上に表示されるので、ClusterManagerを使う場合はaddItemに加えclusterメソッドの呼び出しが必要な点に注意です。
    あと、通常のMarkerはMapにセットすると同時にMarkerの参照を自分で保持してonDestoryなどで参照を明示的に破棄しないとリークの原因になっていたと思います。
    でもClusterManagerを使うのであれば、Markerに相当するClusterItemの参照はAlgorithmが持っているのでClusterManagerのclearメソッドを呼んであげれば良いことになると思います。

2. 地図上のMarkerをタップした時の表示ウィンドウをカスタマイズするには

AndroidおなじみのAdapterクラスを使用します。GoogleMap.InfoWindowAdapterを継承したAdapterクラスを作成することになりますが、セット方法は通常の場合とmap-utilを使った場合とで若干異なります。

  • 通常のMarkerを使用している場合: GoogleMapクラスのsetInfoWindowAdapterで設定します。
  • ClusterManagerを使用している場合: ClusterManagerクラスのmarkerCollection.setOnInfoWindowAdapterで設定します。

2-1. InfoWindowAdapterについて

InfoWindowAdapterはgetInfoContentsメソッドとgetInfoWindowメソッドをもつインタフェースクラスです。
レイアウトとなるXMLを用意し、用途に応じてどちらかのoverrideメソッドを実装します。

getInfoContentsメソッド

InfoWindowの中身をカスタマイズしたい場合はこっちを実装し、getInfoWindowはnullを返すようにします。
カスタマイズしたイメージです。
03_getInfoContents.png

コーナーを丸くするようなレイアウトXMLを指定しても内側のレイアウトまでしか適用してくれません。
右はandroid:background="@drawable/shape_border"を指定したレイアウトリソースをgetInfoContentsで実装した場合のイメージです。
左は背景色を変えた場合です。このようにgetInfoContentsメソッドでは一番外側の枠には手を出せません。
04_getInfoContents_more.png

getInfoWindowメソッド

InfoWindow丸ごとカスタマイズしたい場合はこっちを実装し、getInfoContentsはnullを返します。
今時のようにコーナーを丸くしたい場合、getInfoWindowメソッドを使います。
getInfoContentsで説明したものと全く同じレイアウトXMLを指定した場合のイメージです。
05_getInfoWindow_image.png

コーナーを丸くしてMarkerを指す矢印も欲しい

getInfoWindowメソッドではlayoutのXMLに完全準拠するのでMarkerを指す矢印の部分も欲しい場合、自分で頑張る必要があります。
この矢印をXMLだけで定義するのはかなり難しいと思います。
(ひょっとしたらMaterial Themingを使えばレイアウト定義のみで簡単にできるかもしれません。)
Maps SDK for AndroidのInfo WindowsガイドApiDemos repositoryにて、9-patch画像を使って実現していました。
以下、イメージです。
06_getInfoWIndow_9patch.png

3. 詰まった点

ここまでの説明でClusterManagerとInfoWindowAdapterを組み合わせて実装しようとすると詰まります。
以下、実際に困った点です。

3-1. InfoWindowAdapterの中でタップしたMarkerのClusterItemをどうやって取得するか?

InfoWindowAdapterのoverrideメソッドは引数がMarker型のため地図をタップするとMarkerが渡ってきます。
しかし、ClusterManagerではClusterItemをメインで扱っているのでMarkerが渡ってきてもInfoWindowに表示したい情報が取得できません。
解消方法はいくつかあると思いますが、私は仕方ないのでMarkerタップ時にそのClusterItemをadapterに渡すようにしました。これがベストかは定かではありません。。

clusterManager.setOnClusterItemClickListener { myClusterItem ->
  // clusterManagerのListenerはClusterItem型(実際にはClusterItemを継承した自作ClusterItem型)が渡ってくるのでこれを保持
  adapter.selectedItem = myClusterItem
  false
}

// Adapterクラス
class CustomInfoWindowAdapter {
  //〜〜省略〜〜
  var selectedItem: MyClusterItem? = null

  override fun getInfoWindow(marker: Marker?): View? {
    // この中ではmarkerではなくselectedItemを使う。
  }
}

3-2. Glideで画像を非同期に取得しようとしたら出来なかった

InfoWindowに表示する画像は、大体がクラウドストレージ上にあってURLを参照すると思います。
この問題はアプリ内のresディレクトリから静的に参照する場合発生しませんがそんなサンプルで記事書いても意味無さすぎるのでOkHttpClientを使用してGlideを使用しFirebaseStorageに適当な画像を置いて表示しようとしました。
そしたら最初の1回目タップ時は必ずNoImageになり、2回目タップ時は画像が表示される現象になりました。
Maps SDK for AndroidのInfo WindowsガイドCustom info windowsの章にあるNoteにこれに関する重要なことが書いてあります。
以下、最初の数行を引用します。

The info window that is drawn is not a live view. The view is rendered as an image (using View.draw(Canvas)) at the time it is returned. This means that any subsequent changes to the view will not be reflected by the info window on the map. To update the info window later (for example, after an image has loaded), call showInfoWindow()

つまり、地図上に表示されたInfoWindowは1度レンダリング処理が走ってしまうと以降は更新されなくなります。Glideで非同期に取得した画像はViewに反映されないというわけです。2回目タップではGlideがキャッシュとして画像を持っているので表示されるのだと思います。
これを回避するには表示に必要な情報が全て取得出来た後にshowInfoWindow()を呼ぶ必要があるとのことです。
これは結構みんな困る話じゃないのかなと思って調べたら予想通りGlideリポジトリのissue290で解決策が示されていました。
ちょっと強引ですが、InfoWindowAdapterのところで書いたViewのreturnにnullを指定するとレンダリングされないことを使ってGlide経由で画像が取得できるまでレンダリングしないようにします。
そしてGlideでリソースの取得イベントをハンドリングし、画像が取得できたらshowInfoWindow()を呼ぶようにします。
ちょっと嫌なのが、取得した画像をMapでメモリに保持しておくところで読み込む画像が多くなると危ない気がしました。Glideのキャッシュとかうまく使えないかな。。

4. 終わりに

InfoWindowに表示する画像の扱いがもうちょっとなんとかならないかなと思って調査したのですが断念しました。時間があれば継続調査したいところです。
また、検証していませんがPicassoだともうちょっとうまい方法があるっぽいことをStack overflowでチラ見しました。