[Maps SDK for Android] Marker Clustering Utilityを使って、マーカー管理をActivityから引き剥がす

## まえおき

趣味でアプリを作っていて、

* ショップリストをAPIからとってきてGoogle Map上のマーカーとカードリスト（ViewPager2）で表示する
* マーカーをクリックしたときにはViewPager2側でその場所のカードに移動する
* カードを左右にスワイプしたときには、マーカーの選択状態が変わる

みたいなのを実装していたときの話。

| スクリーンキャプチャ | 概念図 |
|:--:|:--:|
|![alwaysdrink.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/c9483d82-08f4-5d63-0e22-79b984bda514.gif)|![Slice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/74571/bd1f909b-b458-3371-38eb-dcaf8700aee4.png)|



マーカー管理をもともとはActivityでやっていたのだけど、

* "どのマーカーがどのPlaceに紐づくものか"を管理するHashMapをメンバー変数にもつ
* 選択状態が変わったときに、HashMapを総なめして、もともと選択状態だったマーカーのアイコンをデフォルトにもどして、選択されたものをshowInfoWindowする＋アイコンを変更する

などなど結構コード量が多くなり、Activityが肥大化してつらかった。

それを、 [Marker Clustering Utility](https://developers.google.com/maps/documentation/android-sdk/utility/marker-clustering) を使うとかなりいい感じに委譲してActivityをスッキリさせることができた。

そのときのメモ。

## 結論だけ先に

https://github.com/YusukeIwaki/alwaysDRINK_MyLauncher/commit/b70f5c78ed1803aaa0477cd2c4c7e159c1276801

## 複雑なことはClusterManager/ClusterRendererにおまかせしちゃおう

前提として、Placeみたいなモデルに [ClusterItem](https://googlemaps.github.io/android-maps-utils/javadoc/com/google/maps/android/clustering/ClusterItem.html)っていうインターフェースを実装する必要はある。

```kotlin
data class Place(
  val name: String,
  val address: String,
  val picture: Uri,
  val location: LatLng,
   :
   :
) : ClusterItem {

  override fun getTitle() = name
  override fun getPosition() = location
  override fun getSnippet() = null
}
```

こんなかんじで、適当に実装すればいい。

### ClusterManagerを使って、 `placeList.forEach{ ... }` ループとおさらばしよう

APIからとってきたPlaceリストをマーカーに表示するだけなら、

```kotlin
  override fun onMapReady(googleMap: GoogleMap) {
    val placeListClusterManager = ClusterManager<Place>(this, googleMap)
    googleMap.setOnCameraIdleListener(placeListClusterManager)
    googleMap.setOnMarkerClickListener(placeListClusterManager)
    googleMap.setOnInfoWindowClickListener(placeListClusterManager)

    // 前提として、placeRepository.listPlaces() でAPIからPlaceのリストが取得できるものとする。
    placeRepository.listPlaces().observe { placeList ->
      placeListClusterManager.apply {
        clearItems()
        addItems(placeList)
        cluster()
      }
    }
```

なんとこれだけでいい。

### ClusterRendererをカスタマイズして、Placeとマーカーの対応づけの管理から開放されよう

今回のように、選択状態の有無でマーカーのアイコンを変更したい場合には、ClusterRendererをカスタマイズする必要がある。

```kotlin
class PlaceListClusterRenderer(
    private val context: Context,
    private val googleMap: GoogleMap,
    private val clusterManager: ClusterManager<Place>
) : DefaultClusterRenderer<Place>(context, googleMap, clusterManager) {

  // マーカーアイコン
  private val defaultIconBitmap = .......
  private val selectedIconBitmap = .......

  // 選択中のPlaceをメモっておくメンバ変数
  private var selectedPlace: Place? = null

  // 選択中のPlaceを変更したいときに外から呼ぶメソッド
  fun updateSelectedPlace(place: Place?) {
    if (selectedPlace != place) {
      // もともと選択されていたPlaceに対応するマーカーをデフォルト表示に戻す
      selectedPlace?.let { oldPlace ->
        getMarker(oldPlace)?.apply {
          setIcon(defaultIconBitmap)
          hideInfoWindow()
        }
      }

      // 新たに選択した方のPlaceに対応するマーカーを選択表示にする
      place?.let { newPlace ->
        getMarker(newPlace)?.apply {
          setIcon(selectedIconBitmap)
          showInfoWindow()
        }
      }
      selectedPlace = place
    }
  }

  override fun shouldRenderAsCluster(cluster: Cluster<Place>?): Boolean {
    // とりあえず、常にアイコン表示
    return false
  }

  override fun onBeforeClusterItemRendered(item: Place?, markerOptions: MarkerOptions?) {
    // マーカーアイコンを変更する
    item?.let { place ->
      markerOptions?.apply {
        if (selectedPlace == place) {
          icon(selectedIconBitmap)
        } else {
          icon(defaultIconBitmap)
        }
        title(place.name)
      }
    }
  }

  override fun onClusterItemRendered(clusterItem: Place?, marker: Marker?) {
    // マーカー描画後に、選択中のものをshowWindow、未選択のものをhideWindowする
    clusterItem?.let { place ->
      if (selectedPlace == place) {
        marker?.showInfoWindow()
      } else {
        marker?.hideInfoWindow()
      }
    }
  }
}
```

こんなかんじのClusterRendererを作り、

```kotlin
  override fun onMapReady(googleMap: GoogleMap) {
    val placeListClusterManager = ClusterManager<Place>(this, googleMap)
    // ↓↓ 追加 ↓↓
    val placeListClusterRenderer = PlaceListClusterRenderer(this, googleMap, placeListClusterManager)
    val placeListClusterManager.renderer = placeListClusterRenderer
    // ↑↑ 追加 ↑↑
    googleMap.setOnCameraIdleListener(placeListClusterManager)
    googleMap.setOnMarkerClickListener(placeListClusterManager)
    googleMap.setOnInfoWindowClickListener(placeListClusterManager)
```

こんなふうにClusterRendererに指定する。

コード自体は長いけど、ほとんどActivityになにも書いてないでいけるっしょ？めっちゃ便利じゃない？

DefaultClusterRendererは内部で、どのモデルがどのマーカーと対応づけられているかを管理してくれていて、 `getMarker(place)` を使えばPlaceに対応付けられたマーカーが簡単に取得できる。これがホントありがたい。

## マーカーをクリックしたときの処理の書き方

```
googleMap.setOnMarkerClickListener(placeListClusterManager)
```

ここでクリックリスナーをClusterManagerに取られてしまっているので、自前のクリック処理は

```
placeListClusterManager.setOnClusterItemClickListener { place ->
  ...
}
```

のようにClusterManagerにセットする形で実装する。

GoogleMapのsetOnMarkerClickListenerはMarkerオブジェクトがコールバックされるのに対して、ClusterManagerのsetOnClusterItemClickListenerはPlace（モデル）がコールバックされるので、地味にかなり便利。

## Camera Idleのときに発火する処理の書き方

```
googleMap.setOnCameraIdleListener(placeListClusterManager)
```

これも、ここでリスナーを取られてしまっているので、自前のCameraIdle処理は

```
googleMap.setOnCameraIdleListener {
  placeListClusterManager.onCameraIdle()
  ...
}
```

のようにするか、

```kotlin
class PlaceListClusterRenderer(
    private val context: Context,
    private val googleMap: GoogleMap,
    private val clusterManager: ClusterManager<Place>
) : DefaultClusterRenderer<Place>(context, googleMap, clusterManager), GoogleMap.OnCameraIdleListener {

  override fun onCameraIdle() {
    ...
  }

```

のようにClusterRendererがOnCameraIdleListenerを実装していたらそれが呼ばれるらしい。
https://github.com/googlemaps/android-maps-utils/blob/9138f87e71cb8038933f12a42a5b3c6c9f04faa7/library/src/main/java/com/google/maps/android/clustering/ClusterManager.java#L208-L210

## まとめ

「なんらかのリストをマップ表示する」っていう場面では、クラスタ表示しない場合でも Marker Clustering Utilityを積極的に使うといいと思う。