Реализация кластеризации маркеров на карте мобильного приложения
Карта с 300 маркерами без кластеризации — это нечитаемое месиво иконок и лаг при зуме. Кластеризация группирует близлежащие точки в один объект с числом, которое раскрывается при приближении. Реализация зависит от выбранного картографического SDK.
Google Maps: Maps SDK Clustering Utility
Для Google Maps используется библиотека maps-utils:
// 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)
// Кастомный рендерер
clusterManager.renderer = CustomClusterRenderer(this, googleMap, clusterManager)
// Добавление точек
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
}
Кастомный вид кластера
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 (Яндекс): ClusterizedPlacemarkCollection
val clusterizedCollection = map.mapObjects.addClusterizedPlacemarkCollection(
object : ClusterListener {
override fun onClustersAdded(clusters: MutableCollection<Cluster>) {
clusters.forEach { cluster ->
cluster.appearance.setIcon(
TextImageProvider(buildClusterImage(cluster.size))
)
}
}
}
)
// Добавление точек
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 — радиус в пикселях для объединения. minZoom — начиная с какого уровня зума точки показываются по отдельности.
Mapbox: встроенная кластеризация через GeoJSON
Mapbox поддерживает кластеризацию на уровне источника данных — всё происходит на стороне рендерера без дополнительных библиотек:
style.addSource(
GeoJsonSource.Builder("locations")
.featureCollection(featureCollection)
.cluster(true)
.clusterMaxZoom(14)
.clusterRadius(50)
.build()
)
// Слой кластеров
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) }
)
)
}
)
// Слой с числом внутри кластера
style.addLayer(
SymbolLayer("cluster-count", "locations").apply {
filter(has("point_count"))
textField(get("point_count_abbreviated"))
textSize(12.0)
textColor(Color.WHITE)
}
)
// Слой одиночных точек
style.addLayer(
SymbolLayer("unclustered-point", "locations").apply {
filter(not(has("point_count")))
iconImage("custom-pin")
}
)
Подход через GeoJSON-источник масштабируется до десятков тысяч точек без заметного падения FPS — рендеринг полностью на GPU.
Тап по кластеру: zoom to bounds
При клике по кластеру карта должна приближаться так, чтобы все точки кластера вошли в экран:
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
}
Сроки
1–3 дня. Стандартная кластеризация — 1 день. Кастомный вид кластеров + анимация раскрытия + тап с зумом — 2–3 дня. Стоимость рассчитывается индивидуально.







