まえおき
趣味でアプリを作っていて、
- ショップリストをAPIからとってきてGoogle Map上のマーカーとカードリスト(ViewPager2)で表示する
- マーカーをクリックしたときにはViewPager2側でその場所のカードに移動する
- カードを左右にスワイプしたときには、マーカーの選択状態が変わる
みたいなのを実装していたときの話。
スクリーンキャプチャ | 概念図 |
---|---|
マーカー管理をもともとは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を積極的に使うといいと思う。