Implementation of Map Marker Clustering in a Mobile Application
A map with 300 markers without clustering is an unreadable mess of icons and lag on zoom. Clustering groups nearby points into a single object with a count that expands when zoomed in. Implementation depends on the chosen map SDK.
Google Maps: Maps SDK Clustering Utility
For Google Maps, use the maps-utils library:
// build.gradle
implementation("com.google.maps.android:android-maps-utils:3.8.2")
class ClusteringActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var clusterManager: ClusterManager<MyClusterItem>
override fun onMapReady(googleMap: GoogleMap) {
clusterManager = ClusterManager(this, googleMap)
googleMap.setOnCameraIdleListener(clusterManager)
googleMap.setOnMarkerClickListener(clusterManager)
// Custom renderer
clusterManager.renderer = CustomClusterRenderer(this, googleMap, clusterManager)
// Add points
val items = locations.map { MyClusterItem(it.lat, it.lng, it.title) }
clusterManager.addItems(items)
clusterManager.cluster()
}
}
data class MyClusterItem(
private val lat: Double,
private val lng: Double,
private val title: String
) : ClusterItem {
override fun getPosition() = LatLng(lat, lng)
override fun getTitle() = title
override fun getSnippet() = null
override fun getZIndex() = 0f
}
Custom Cluster Appearance
class CustomClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<MyClusterItem>
) : DefaultClusterRenderer<MyClusterItem>(context, map, clusterManager) {
override fun onBeforeClusterItemRendered(item: MyClusterItem, markerOptions: MarkerOptions) {
markerOptions.icon(BitmapDescriptorFactory.fromResource(R.drawable.custom_pin))
}
override fun onBeforeClusterRendered(
cluster: Cluster<MyClusterItem>,
markerOptions: MarkerOptions
) {
val count = cluster.size
val bitmap = createClusterBitmap(count)
markerOptions.icon(BitmapDescriptorFactory.fromBitmap(bitmap))
}
private fun createClusterBitmap(count: Int): Bitmap {
val size = 80
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#3498DB") }
canvas.drawCircle(size / 2f, size / 2f, size / 2f - 2f, bgPaint)
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 28f
textAlign = Paint.Align.CENTER
}
canvas.drawText(count.toString(), size / 2f, size / 2f + 10f, textPaint)
return bitmap
}
}
MapKit (Yandex): ClusterizedPlacemarkCollection
val clusterizedCollection = map.mapObjects.addClusterizedPlacemarkCollection(
object : ClusterListener {
override fun onClustersAdded(clusters: MutableCollection<Cluster>) {
clusters.forEach { cluster ->
cluster.appearance.setIcon(
TextImageProvider(buildClusterImage(cluster.size))
)
}
}
}
)
// Add points
val placemarks = locations.map { Point(it.lat, it.lng) }
val options = PlacemarkCreationContext()
clusterizedCollection.addPlacemarks(placemarks, ImageProvider.fromResource(context, R.drawable.pin), options)
clusterizedCollection.clusterPlacemarks(clusterRadius = 60.0, minZoom = 15)
clusterRadius — radius in pixels for grouping. minZoom — starting from which zoom level points are shown separately.
Mapbox: Built-in Clustering via GeoJSON
Mapbox supports clustering at the data source level — everything happens on the renderer side without additional libraries:
style.addSource(
GeoJsonSource.Builder("locations")
.featureCollection(featureCollection)
.cluster(true)
.clusterMaxZoom(14)
.clusterRadius(50)
.build()
)
// Cluster layer
style.addLayer(
CircleLayer("clusters", "locations").apply {
filter(has("point_count"))
circleColor(
step(get("point_count"),
color(Color.parseColor("#51bbd6")),
stop { literal(100); color(Color.parseColor("#f1f075")) },
stop { literal(750); color(Color.parseColor("#f28cb1")) }
)
)
circleRadius(
step(get("point_count"),
literal(20),
stop { literal(100); literal(30) },
stop { literal(750); literal(40) }
)
)
}
)
// Layer with cluster count
style.addLayer(
SymbolLayer("cluster-count", "locations").apply {
filter(has("point_count"))
textField(get("point_count_abbreviated"))
textSize(12.0)
textColor(Color.WHITE)
}
)
// Layer for individual points
style.addLayer(
SymbolLayer("unclustered-point", "locations").apply {
filter(not(has("point_count")))
iconImage("custom-pin")
}
)
The GeoJSON source approach scales to tens of thousands of points without noticeable FPS drops — rendering is entirely on GPU.
Tap on Cluster: Zoom to Bounds
When clicking a cluster, the map should zoom so all cluster points fit on screen:
mapView.mapboxMap.addOnMapClickListener { point ->
val features = mapView.mapboxMap.queryRenderedFeatures(
ScreenCoordinate(point.x, point.y),
RenderedQueryOptions(listOf("clusters"), null)
)
features.value?.firstOrNull()?.let { feature ->
val clusterId = feature.id()?.toLongOrNull() ?: return@addOnMapClickListener true
(style.getSource("locations") as? GeoJsonSource)?.getClusterExpansionZoom(
Feature.fromGeometry(feature.geometry()!!)
) { result ->
result.value?.let { zoom ->
mapView.mapboxMap.setCamera(
CameraOptions.Builder()
.center(feature.geometry() as? Point)
.zoom(zoom.toDouble() + 0.5)
.build()
)
}
}
}
true
}
Timeline
1–3 days. Standard clustering — 1 day. Custom cluster appearance + expansion animation + tap with zoom — 2–3 days. Cost is calculated individually.







