iT邦幫忙

2023 iThome 鐵人賽

DAY 23
0
Mobile Development

Google Maps SDK for Android 與 GIS App 開發筆記系列 第 23

Day 23: Maps SDK for Android Utility–Marker Clustering 標記叢集

  • 分享至 

  • xImage
  •  

簡介

Marker Clustering 官方翻譯為標記叢集,聽起來好像是什麼酷酷的新東西,但其實就是地圖上的 Marker 聚合的功能。

假設今天在一個地圖範圍內,要一次呈現上千個以上的 Marker,雖然可以就這樣全部畫上去,但這對使用者來說其實不易查看,甚至有些手機型號可能會出現卡頓的情況。

https://ithelp.ithome.com.tw/upload/images/20231006/20160271YJ9Y1692gp.png

不過,如果我們改用 Clustering 來呈現這些點位,就能夠讓位置相近的 Marker 隨著地圖縮放自行聚集或展開。如此一來,除了增加地圖的易讀性,也能減少畫面繪製造成的卡頓問題。

實作

開始實作前,先來了解一下基本概念:

我們需要實作 ClusterItem 介面,並將建立好的物件提供給 ClusterManager,由它來管理。

ClusterManager 中有使用兩個類別分別是 AlgorithmClusterRenderer。前者負責運算地圖上的 Marker 轉換成 Cluster 的邏輯,後者負責繪製的細節。(這兩個類別都可以抽換客製化的版本)

在大部分的開發情境下,我們主要會調整的是 ClusterRenderer ,因為要改變 Marker 與 Cluster 的外觀。

基本款

以下就讓我們用昨天使用的台中市超商資料,來實作一次基本款的 Clustering。

1. 建立一個實作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():在地圖上的垂直層級,數字越大越高(疊在上面)。

2. 初始化 ClusterManager<StoreClusterItem>

需要傳入 GoogleMapContext,並將 onCameraIdleListener 設定為 ClusterManager

private var clusterManager: ClusterManager<StoreClusterItem>? = null

private fun initClusterManager(map: GoogleMap) {
    clusterManager = ClusterManager(this, map)
    
    // 選用
    map.setOnCameraIdleListener(clusterManager)
}

但這裡可能會遇到一個問題是,如果你的專案也需要 onCameraIdle() 事件的回呼,就不能直接將 GoogleMapOnCameraIdleListener 設為 ClusterManager

https://ithelp.ithome.com.tw/upload/images/20231006/201602712yVLaALl06.png

看了一下 ClusterManager 的 Source Code,你可以在自己實作的 OnCameraIdleListener.onCameraIdle() 中,直接呼叫 ClusterManager.onCameraIdle() 也是可以的。

override fun onCameraIdle() {
    super.onCameraIdle()
    clusterManager?.onCameraIdle()
}

3. 將建立好的 StoreClusterItem List 傳給 ClusterManager

private fun addItemsToClusterManager() {
    val clusterItemList = readStoreDataFromAssets()
    clusterManager?.addItems(clusterItemList)
}

https://ithelp.ithome.com.tw/upload/images/20231006/201602714NhpZn2cZ6.png

到這一步,我們已經將點位資料成功的以 Clustering 的方式,在地圖上呈現。

補充:InfoWindow

試著點擊地圖上的 Marker,會發現有出現如以下畫面的 InfoWindow。

https://ithelp.ithome.com.tw/upload/images/20231006/20160271HQKNeapVz2.png

這是因為在實作 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
}

自訂外觀的 Cluster

接下來要來看稍微複雜一些的自訂外觀版本。
前面資料的部分都跟一般版相同,我們直接來看差異的部分。

1. 建立一個自訂的類別繼承 DefaultClusterRenderer

在這個自定義的類別裡,我們需要實作以下方法,來換掉原本預設的 Marker 們。

  1. onBeforeClusterItemRendered(): 在這個方法中繪製未聚合的 Marker (會拿到半成品的 MarkerOptions)
  2. onClusterItemUpdated(): 更新未聚合的 Marker (會拿到已經加到圖面上的 Marker)
  3. onBeforeClusterRendered(): 繪製聚合的 Marker (會拿到半成品的 MarkerOptions)
  4. 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)
    }
}

2. 設定 MyClusterRendererClusterManager

clusterRenderer = MyClusterRenderer(context = this, map, clusterManager)

clusterManager.renderer = clusterRenderer

自定外觀的成果

https://ithelp.ithome.com.tw/upload/images/20231006/20160271Elo4OyXAuJ.png

參考資料

小結

以上就是今天 Marker Clustering 的介紹,自定義外觀的部分稍嫌複雜了一點,但原理基本上很簡單,如果有我沒寫清楚的地方,歡迎留言討論喔~

明天見囉!!/images/emoticon/emoticon08.gif


上一篇
Day 22: Maps SDK for Android Utility–Heat map 熱視圖
下一篇
Day 24: Maps SDK for Android Utility–LayerManager
系列文
Google Maps SDK for Android 與 GIS App 開發筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言