Marker Clustering 官方翻譯為標記叢集,聽起來好像是什麼酷酷的新東西,但其實就是地圖上的 Marker 聚合的功能。
假設今天在一個地圖範圍內,要一次呈現上千個以上的 Marker,雖然可以就這樣全部畫上去,但這對使用者來說其實不易查看,甚至有些手機型號可能會出現卡頓的情況。
不過,如果我們改用 Clustering 來呈現這些點位,就能夠讓位置相近的 Marker 隨著地圖縮放自行聚集或展開。如此一來,除了增加地圖的易讀性,也能減少畫面繪製造成的卡頓問題。
開始實作前,先來了解一下基本概念:
我們需要實作 ClusterItem
介面,並將建立好的物件提供給 ClusterManager
,由它來管理。
ClusterManager
中有使用兩個類別分別是 Algorithm
與 ClusterRenderer
。前者負責運算地圖上的 Marker 轉換成 Cluster 的邏輯,後者負責繪製的細節。(這兩個類別都可以抽換客製化的版本)
在大部分的開發情境下,我們主要會調整的是 ClusterRenderer
,因為要改變 Marker 與 Cluster 的外觀。
以下就讓我們用昨天使用的台中市超商資料,來實作一次基本款的 Clustering。
ClusterItem
介面的類別data class StoreClusterItem(private val latLng: LatLng,
private val storeName: String,
private val address: String): ClusterItem {
override fun getPosition(): LatLng = latLng
override fun getTitle(): String = storeName
override fun getSnippet(): String = address
override fun getZIndex(): Float = 0f
}
getPosition()
:回傳點位座標getTitle
():Marker 點擊後出現的 InfoWindow 的標題getSnippet()
:InfoWindow 的副標題getZIndex()
:在地圖上的垂直層級,數字越大越高(疊在上面)。ClusterManager<StoreClusterItem>
需要傳入 GoogleMap
與 Context
,並將 onCameraIdleListener
設定為 ClusterManager
。
private var clusterManager: ClusterManager<StoreClusterItem>? = null
private fun initClusterManager(map: GoogleMap) {
clusterManager = ClusterManager(this, map)
// 選用
map.setOnCameraIdleListener(clusterManager)
}
但這裡可能會遇到一個問題是,如果你的專案也需要 onCameraIdle()
事件的回呼,就不能直接將 GoogleMap
的 OnCameraIdleListener
設為 ClusterManager
。
看了一下 ClusterManager
的 Source Code,你可以在自己實作的 OnCameraIdleListener.onCameraIdle()
中,直接呼叫 ClusterManager.onCameraIdle()
也是可以的。
override fun onCameraIdle() {
super.onCameraIdle()
clusterManager?.onCameraIdle()
}
StoreClusterItem
List 傳給 ClusterManager
private fun addItemsToClusterManager() {
val clusterItemList = readStoreDataFromAssets()
clusterManager?.addItems(clusterItemList)
}
到這一步,我們已經將點位資料成功的以 Clustering 的方式,在地圖上呈現。
試著點擊地圖上的 Marker,會發現有出現如以下畫面的 InfoWindow。
這是因為在實作 ClusterItem
的時候,我們有實作 getSnippet()
與 getTitle()
這兩個方法。
如果要關閉 InfoWindow 的顯示,必須要實作 ClusterRenderer
才能關閉。
由 ClusterManager 所建立的 Marker ,點擊事件不會透過 GoogleMap.setOnMarkerClickListener()
回呼,而是要另外設定 ClusterManager.setOnClusterItemClickListener
。
clusterManager?.setOnClusterItemClickListener {
// Marker 點擊事件
Toast.makeText(this, it.title, Toast.LENGTH_SHORT).show()
// 回傳 false 表示事件會繼續往下傳遞給其他 Listener
// 反之,則由這個回呼處理,不再向下傳遞
return@setOnClusterItemClickListener false
}
接下來要來看稍微複雜一些的自訂外觀版本。
前面資料的部分都跟一般版相同,我們直接來看差異的部分。
DefaultClusterRenderer
在這個自定義的類別裡,我們需要實作以下方法,來換掉原本預設的 Marker 們。
onBeforeClusterItemRendered()
: 在這個方法中繪製未聚合的 Marker (會拿到半成品的 MarkerOptions
)onClusterItemUpdated()
: 更新未聚合的 Marker (會拿到已經加到圖面上的 Marker
)onBeforeClusterRendered()
: 繪製聚合的 Marker (會拿到半成品的 MarkerOptions
)onClusterUpdated
: 更新聚合的 Marker (會拿到已經加到圖面上的 Marker
)繪製新 Marker 自訂外觀的部分,就跟 Day 13: Google Maps SDK for Android–自訂 Marker 外觀一樣,是透過自訂 View 來建立 Bitmap,最後透過 BitmapDescriptor
設定到 Marker Icon 上。
class MyClusterRenderer(
private val context: Context?,
map: GoogleMap?,
clusterManager: ClusterManager<StoreClusterItem>?
) : DefaultClusterRenderer<StoreClusterItem>(context, map, clusterManager) {
private var clusterIconGenerator: IconGenerator
private var clusterItemIconGenerator: IconGenerator
// 自定義的非聚合 Marker View
private val itemBinding = ViewCustomClusterItemBinding.inflate(LayoutInflater.from(context))
init {
clusterIconGenerator = initClusterIcon()
clusterItemIconGenerator = initClusterItemIconGenerator()
}
private fun initClusterItemIconGenerator(): IconGenerator {
val iconGenerator = IconGenerator(context)
iconGenerator.setBackground(ColorDrawable(Color.TRANSPARENT))
return iconGenerator
}
/**
* 建立 Cluster Icon
*
*/
private fun initClusterIcon(): IconGenerator {
// 自定義的 View
val view = View.inflate(context, R.layout.view_custom_cluster, null)
val iconGenerator = IconGenerator(context)
iconGenerator.setContentView(view)
iconGenerator.setBackground(ColorDrawable(Color.TRANSPARENT))
return iconGenerator
}
/**
* 客製化未聚合前的Marker
*
* @param item
* @param markerOptions
*/
override fun onBeforeClusterItemRendered(item: StoreClusterItem, markerOptions: MarkerOptions) {
val bitmapDescriptor = getClusterItemIcon(item)
// 如果沒有要顯示 InfoWindow 就不用設定 title & snippet
markerOptions
.icon(bitmapDescriptor)
.snippet(item.snippet)
.title(item.title)
}
override fun onClusterItemUpdated(item: StoreClusterItem, marker: Marker) {
// 更新 Icon
marker.setIcon(getClusterItemIcon(item))
// 如果沒有要顯示 InfoWindow 就不用設定 title & snippet
marker.title = item.title
marker.snippet = item.snippet
}
/**
* 客製化聚合的Marker
*
* @param cluster
* @param markerOptions
*/
override fun onBeforeClusterRendered(
cluster: Cluster<StoreClusterItem>,
markerOptions: MarkerOptions
) {
// 更換客製化的 Icon
markerOptions.icon(getClusterIcon(cluster))
}
/**
* 更新已繪製的 Cluster
*
* @param cluster
* @param marker
*/
override fun onClusterUpdated(cluster: Cluster<StoreClusterItem>, marker: Marker) {
// 更換客製化的 Icon
marker.setIcon(getClusterIcon(cluster))
}
override fun shouldRenderAsCluster(cluster: Cluster<StoreClusterItem>): Boolean {
// 兩個以上就聚合
return cluster.size >= 2
}
/**
* 建立自定義的 Cluster Icon BitmapDescriptor
*
* @param cluster
* @return
*/
private fun getClusterIcon(cluster: Cluster<StoreClusterItem>): BitmapDescriptor? {
// 將聚合的數量文字傳入
// (自定義的 Layout TextView id 一定要設成 amu_text 才會被 IconGenerator.makeIcon() 設定 text)
val bitmap: Bitmap = clusterIconGenerator.makeIcon(cluster.size.toString())
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
/**
* 建立自定的 Cluster Item Icon
*
* @param item
* @return
*/
private fun getClusterItemIcon(item: StoreClusterItem): BitmapDescriptor {
// 更新 View 上的文字
itemBinding.tvStoreName.text = item.title
// 將更新的 View 設定到 IconGenerator
clusterItemIconGenerator.setContentView(itemBinding.root)
// 建立畫面 Bitmap
val iconBitmap: Bitmap = clusterItemIconGenerator.makeIcon()
return BitmapDescriptorFactory.fromBitmap(iconBitmap)
}
}
MyClusterRenderer
到 ClusterManager
clusterRenderer = MyClusterRenderer(context = this, map, clusterManager)
clusterManager.renderer = clusterRenderer
以上就是今天 Marker Clustering 的介紹,自定義外觀的部分稍嫌複雜了一點,但原理基本上很簡單,如果有我沒寫清楚的地方,歡迎留言討論喔~
明天見囉!!