Skip to content

Add Clusters in Flutter

This tutorial shows how to group nearby markers into clusters for better performance and readability when you have many data points on the map.

Prerequisites

Before you begin, ensure you have:

Basic Clustering

When you have many markers close together, clustering groups them into a single icon showing the count. As the user zooms in, clusters break apart into individual markers:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
import 'dart:math';

class ClusterExampleScreen extends StatefulWidget {
  @override
  _ClusterExampleScreenState createState() => _ClusterExampleScreenState();
}

class _ClusterExampleScreenState extends State<ClusterExampleScreen> {
  MapMetricsController? mapController;
  Set<Marker> markers = {};
  double currentZoom = 12.0;

  // Sample data: 100 random points around Paris
  late final List<LatLng> allPoints;

  @override
  void initState() {
    super.initState();
    final random = Random(42);
    allPoints = List.generate(100, (_) {
      return LatLng(
        48.82 + random.nextDouble() * 0.08, // Lat range around Paris
        2.28 + random.nextDouble() * 0.14,  // Lng range around Paris
      );
    });
    _updateMarkers();
  }

  void _updateMarkers() {
    // Simple clustering: group nearby points based on zoom level
    final double gridSize = 0.01 * pow(2, 15 - currentZoom).toDouble();
    final Map<String, List<LatLng>> grid = {};

    for (final point in allPoints) {
      final key =
          '${(point.latitude / gridSize).floor()}_${(point.longitude / gridSize).floor()}';
      grid.putIfAbsent(key, () => []).add(point);
    }

    final Set<Marker> newMarkers = {};

    for (final entry in grid.entries) {
      final points = entry.value;
      // Calculate center of the group
      final avgLat = points.map((p) => p.latitude).reduce((a, b) => a + b) / points.length;
      final avgLng = points.map((p) => p.longitude).reduce((a, b) => a + b) / points.length;
      final center = LatLng(avgLat, avgLng);

      if (points.length == 1) {
        // Single point — show as regular marker
        newMarkers.add(
          Marker(
            markerId: MarkerId(entry.key),
            position: center,
            icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
            infoWindow: InfoWindow(
              title: 'Point',
              snippet:
                  '${center.latitude.toStringAsFixed(4)}, ${center.longitude.toStringAsFixed(4)}',
            ),
          ),
        );
      } else {
        // Cluster — show count
        newMarkers.add(
          Marker(
            markerId: MarkerId('cluster_${entry.key}'),
            position: center,
            icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueBlue),
            infoWindow: InfoWindow(
              title: '${points.length} points',
              snippet: 'Zoom in to see details',
            ),
          ),
        );
      }
    }

    setState(() {
      markers = newMarkers;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Clustered Markers')),
      body: Column(
        children: [
          Container(
            padding: EdgeInsets.all(10),
            color: Colors.grey[100],
            child: Text(
              '${allPoints.length} total points  |  ${markers.length} visible markers  |  Zoom: ${currentZoom.toStringAsFixed(1)}',
              style: TextStyle(fontSize: 13),
            ),
          ),
          Expanded(
            child: MapMetrics(
              styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_ID?token=YOUR_API_KEY',
              onMapCreated: (controller) => mapController = controller,
              onCameraIdle: () async {
                final position = await mapController?.getCameraPosition();
                if (position != null && position.zoom != currentZoom) {
                  currentZoom = position.zoom;
                  _updateMarkers();
                }
              },
              initialCameraPosition: CameraPosition(
                target: LatLng(48.8566, 2.3522),
                zoom: 12.0,
              ),
              markers: markers,
            ),
          ),
        ],
      ),
    );
  }
}

Cluster with Custom Widget Icons

Create visually distinct cluster icons that show the count and change color based on size:

dart
Future<BitmapDescriptor> _createClusterIcon(int count) async {
  Color color;
  double size;

  if (count < 10) {
    color = Colors.blue;
    size = 40;
  } else if (count < 50) {
    color = Colors.orange;
    size = 50;
  } else {
    color = Colors.red;
    size = 60;
  }

  return await BitmapDescriptor.fromWidget(
    Container(
      width: size,
      height: size,
      decoration: BoxDecoration(
        color: color.withOpacity(0.8),
        shape: BoxShape.circle,
        border: Border.all(color: Colors.white, width: 2),
        boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
      ),
      alignment: Alignment.center,
      child: Text(
        '$count',
        style: TextStyle(
          color: Colors.white,
          fontWeight: FontWeight.bold,
          fontSize: size * 0.35,
        ),
      ),
    ),
  );
}

Tap Cluster to Zoom In

Zoom into a cluster when tapped:

dart
Marker(
  markerId: MarkerId('cluster_${entry.key}'),
  position: center,
  icon: clusterIcon,
  onTap: () {
    // Zoom in to break apart the cluster
    mapController?.animateCamera(
      CameraUpdate.newLatLngZoom(center, currentZoom + 2),
    );
  },
)

Cluster Size Colors

CountColorSize
1–9BlueSmall (40px)
10–49OrangeMedium (50px)
50+RedLarge (60px)

Next Steps


Tip: Recalculate clusters on onCameraIdle (not onCameraMove) to avoid excessive recomputation during panning.