LoginSignup
10
5

More than 3 years have passed since last update.

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

Posted at

まえおき

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

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

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

スクリーンキャプチャ 概念図
alwaysdrink.gif Slice.png

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

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

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

それを、 Marker Clustering Utility を使うとかなりいい感じに委譲してActivityをスッキリさせることができた。

そのときのメモ。

結論だけ先に

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

前提として、Placeみたいなモデルに ClusterItemっていうインターフェースを実装する必要はある。

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リストをマーカーに表示するだけなら、

  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をカスタマイズする必要がある。

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を作り、

  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()
  ...
}

のようにするか、

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を積極的に使うといいと思う。

10
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5