androidengineers.Book a session

Designing a Ride-Sharing App

Maps Rendering & Clustering

article20 minHard

Rendering a map with many markers efficiently requires clustering (grouping nearby markers) and careful performance considerations. Showing 500 individual markers on a map without clustering is unusable.

Google Maps SDK Setup

// implementation("com.google.android.gms:play-services-maps:18.2.0")
// implementation("com.google.maps.android:maps-ktx:5.0.0")  // Kotlin extensions
// implementation("com.google.maps.android:maps-utils-ktx:5.0.0")  // Clustering, utils
// In Fragment
class MapFragment : Fragment() {
    private lateinit var map: GoogleMap

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
        return inflater.inflate(R.layout.fragment_map, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        val mapFragment = childFragmentManager.findFragmentById(R.id.map) as SupportMapFragment
        viewLifecycleOwner.lifecycleScope.launch {
            map = mapFragment.awaitMap()  // maps-ktx suspend extension
            setupMap()
        }
    }

    private fun setupMap() {
        map.apply {
            uiSettings.isZoomControlsEnabled = true
            uiSettings.isMyLocationButtonEnabled = false  // use custom button
            mapType = GoogleMap.MAP_TYPE_NORMAL
        }
    }
}

Marker Clustering

// Item for clustering
class RestaurantItem(val restaurant: Restaurant) : ClusterItem {
    private val position = LatLng(restaurant.lat, restaurant.lng)
    override fun getPosition() = position
    override fun getTitle() = restaurant.name
    override fun getSnippet() = restaurant.cuisine
    override fun getZIndex() = 0f
}

// Setup clustering
class RestaurantMapFragment : Fragment() {
    private lateinit var clusterManager: ClusterManager<RestaurantItem>

    private fun setupClusterManager() {
        clusterManager = ClusterManager<RestaurantItem>(requireContext(), map).apply {
            setOnClusterClickListener { cluster ->
                // Zoom to fit cluster
                val builder = LatLngBounds.builder()
                cluster.items.forEach { builder.include(it.position) }
                map.animateCamera(CameraUpdateFactory.newLatLngBounds(builder.build(), 100))
                true
            }

            setOnClusterItemClickListener { item ->
                showRestaurantDetails(item.restaurant)
                true
            }

            // Custom cluster renderer with icon
            renderer = RestaurantClusterRenderer(requireContext(), map, this)
        }

        map.setOnCameraIdleListener(clusterManager)
        map.setOnMarkerClickListener(clusterManager)
    }

    fun addRestaurants(restaurants: List<Restaurant>) {
        clusterManager.clearItems()
        clusterManager.addItems(restaurants.map { RestaurantItem(it) })
        clusterManager.cluster()
    }
}

Custom Cluster Renderer

class RestaurantClusterRenderer(
    context: Context,
    map: GoogleMap,
    clusterManager: ClusterManager<RestaurantItem>
) : DefaultClusterRenderer<RestaurantItem>(context, map, clusterManager) {

    override fun onBeforeClusterItemRendered(item: RestaurantItem, markerOptions: MarkerOptions) {
        markerOptions
            .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_ORANGE))
            .title(item.restaurant.name)
    }

    override fun onBeforeClusterRendered(
        cluster: Cluster<RestaurantItem>,
        markerOptions: MarkerOptions
    ) {
        // Draw a custom cluster icon showing the count
        val clusterIcon = drawClusterIcon(cluster.size)
        markerOptions.icon(BitmapDescriptorFactory.fromBitmap(clusterIcon))
    }

    private fun drawClusterIcon(count: Int): Bitmap {
        val size = 96
        val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)

        val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = Color.parseColor("#FF5722")
        }
        val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = Color.WHITE
            textSize = 32f
            textAlign = Paint.Align.CENTER
        }

        canvas.drawCircle(size / 2f, size / 2f, size / 2f, circlePaint)
        canvas.drawText(count.toString(), size / 2f, size / 2f + 12f, textPaint)
        return bitmap
    }
}

Performance: Viewport-Only Markers

For very large datasets, only add markers visible in the current viewport:

map.setOnCameraIdleListener {
    val bounds = map.projection.visibleRegion.latLngBounds
    val visibleRestaurants = allRestaurants.filter { restaurant ->
        bounds.contains(LatLng(restaurant.lat, restaurant.lng))
    }
    clusterManager.clearItems()
    clusterManager.addItems(visibleRestaurants.map { RestaurantItem(it) })
    clusterManager.cluster()
}

Heatmaps for Density

// implementation("com.google.maps.android:maps-utils-ktx:5.0.0")

val heatmapData = restaurants.map { WeightedLatLng(LatLng(it.lat, it.lng), it.ratingWeight) }
val heatmapTileProvider = HeatmapTileProvider.Builder()
    .weightedData(heatmapData)
    .radius(50)
    .maxIntensity(10.0)
    .build()

map.addTileOverlay(TileOverlayOptions().tileProvider(heatmapTileProvider))

Key Takeaways

TechniqueWhen to use
ClusterManager50+ markers on screen at any zoom level
Custom cluster rendererBranded cluster icons; show count; accessibility
Viewport-only markers10,000+ items; only load what's visible
CameraIdleListenerReload/refilter markers after camera stops moving
HeatmapsDensity visualization (e.g., ride demand, restaurant density)
awaitMap()Kotlin coroutine extension; cleaner than getMapAsync

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Maps Rendering & Clustering | Android System Design | Android Engineers