Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Markers and Annotations with Flutter and MapMetrics ​

This tutorial will show you how to add markers, annotations, and custom overlays to your MapMetrics map in Flutter.

Two Ways to Show Markers ​

The real API has no Marker + Set<Marker> widget parameter, MarkerId, InfoWindow, or BitmapDescriptor. There are two real mechanisms instead, and which one you reach for depends on what you need:

NeedUse
Many simple points with a text label and/or a style-sprite icon, no per-marker interactionMarkerLayer in layers: — declarative, backed by a native symbol layer
Tappable, draggable, or arbitrary-Flutter-widget markers (icons, badges, custom cards)WidgetLayer with Marker children in mapChildren: — actual Flutter widgets positioned over the map

Default to MarkerLayer for map-anchored pins

MarkerLayer renders inside the map's own draw pass, so a pin stays welded to its coordinate. WidgetLayer does something fundamentally different: each marker is a Flutter widget living above the map, repositioned in Dart by round-tripping its coordinate through toScreenLocation on every frame.

That difference is visible and it is not subtle:

  • Lag and jitter while panning or zooming — the widget is positioned from last frame's projection, so it trails the map by a frame.
  • No tilt or rotation — widgets stay screen-aligned; they will not lean with pitch or spin with bearing the way a symbol layer does.
  • No depth sorting — a widget marker draws over 3D buildings instead of being occluded by them.
  • Degrades quickly — the per-frame platform-channel round trip is per marker, so a few dozen is where it starts to hurt.

Reach for WidgetLayer when you genuinely need Flutter content or gestures — a tappable card, a draggable handle, an Image.network badge. For a plain location pin, MarkerLayer is both cheaper and better behaved.

Known issue: on iOS the SDK currently print()s debug output from inside toScreenLocation (lib/src/platform/ios/map_state.dart), so a WidgetLayer floods the console with === DART toScreenLocation DEBUG === every frame. That is an SDK bug, not something your code is doing wrong — but it is another reason not to use WidgetLayer for a marker that MarkerLayer can draw.

Adding Basic Markers ​

MarkerLayer takes a list of Point geometries (from the geotypes package) and one shared style (text/icon) for all of them:

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

class MarkersScreen extends StatefulWidget {
  @override
  _MarkersScreenState createState() => _MarkersScreenState();
}

class _MarkersScreenState extends State<MarkersScreen> {
  MapController? mapController;
  List<Point> points = [];

  @override
  void initState() {
    super.initState();
    _initializeMarkers();
  }

  void _initializeMarkers() {
    points = [
      Point(coordinates: Position(-74.0060, 40.7128)), // New York
      Point(coordinates: Position(-122.4194, 37.7749)), // San Francisco
      Point(coordinates: Position(-87.6298, 41.8781)), // Chicago
    ];
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('MapMetrics Markers'),
        actions: [
          IconButton(
            icon: Icon(Icons.add_location),
            onPressed: _addRandomMarker,
          ),
        ],
      ),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-98.5795, 39.8283), // Center of USA
          initZoom: 4,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (MapController controller) {
          setState(() {
            mapController = controller;
          });
        },
        onEvent: (event) {
          if (event case MapEventClick()) {
            _addMarkerAtLocation(event.point);
          }
        },
        layers: [
          MarkerLayer(
            points: points,
            iconImage: 'marker-15', // built-in style sprite name; see "Custom Marker Icons"
            iconSize: 1.2,
            textField: '',
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _clearMarkers,
        child: Icon(Icons.clear_all),
      ),
    );
  }

  void _addMarkerAtLocation(Position position) {
    setState(() {
      points = [...points, Point(coordinates: position)];
    });
  }

  void _addRandomMarker() {
    final lat = 25.0 + (Random().nextDouble() * 20.0); // 25-45 latitude
    final lng = -125.0 + (Random().nextDouble() * 50.0); // -125 to -75 longitude

    _addMarkerAtLocation(Position(lng, lat));
  }

  void _clearMarkers() {
    setState(() {
      points = [];
    });
  }
}

MarkerLayer re-renders whenever the points list you pass it changes — there's no separate add/remove call, you rebuild the widget tree with a new list (as _addMarkerAtLocation does above with setState).

Custom Marker Icons ​

Using a Style Sprite Icon ​

MarkerLayer.iconImage refers to an image already loaded into the map style's sprite sheet — it is not a Flutter asset path or network URL. Load a custom image into the style with StyleController.addImage from onStyleLoaded, then reference it by name:

dart
MapMetricsView(
  // ... other properties
  onStyleLoaded: (StyleController style) async {
    final iconData = await rootBundle.load('assets/images/custom_marker.png');
    await style.addImage('custom_marker', iconData.buffer.asUint8List());
  },
  layers: [
    MarkerLayer(
      points: points,
      iconImage: 'custom_marker',
      iconSize: 1,
    ),
  ],
)

Using a Flutter Widget as the Icon ​

For arbitrary Flutter content (an Image.network, a colored Icon, a custom badge) rather than a style sprite, use WidgetLayer instead of MarkerLayer — see Handle Marker Taps below.

There is no BitmapDescriptor.defaultMarkerWithHue() — for simple colored pins, either use iconColor on MarkerLayer (works with SDF icons only) or draw an Icon(Icons.location_on, color: ...) inside a WidgetLayer marker.

Different Icons in One Map ​

MarkerLayer applies one iconImage to every point in the layer. To show several icon types, use one MarkerLayer per icon rather than dropping to WidgetLayer — they all render in the map's own draw pass, so the pins stay welded to the map:

dart
layers: [
  MarkerLayer(points: airports, iconImage: 'airport-15', iconSize: 1.2),
  MarkerLayer(points: offices,  iconImage: 'commercial-15', iconSize: 1.1),
],

iconImage is a sprite name from the loaded style, not a Flutter asset or a URL. If the name is not in the style's sprite sheet the marker renders nothing at all — silently, with no error. Check against your own style, or load your own image first with StyleController.addImage (see above) and reference that name.

Handle Marker Taps ​

MarkerLayer has no onTap/onMarkerTapped callback — it's a declarative shape list, not a set of interactive objects. For tap-to-show-info behavior, use WidgetLayer with real GestureDetector-wrapped Flutter widgets, positioned by Marker(point:, size:, child:):

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

class TappableMarkersScreen extends StatefulWidget {
  @override
  _TappableMarkersScreenState createState() => _TappableMarkersScreenState();
}

class _TappableMarkersScreenState extends State<TappableMarkersScreen> {
  late final MapController _controller;

  final _places = [
    {'name': 'New York City', 'snippet': 'The Big Apple', 'point': Position(-74.0060, 40.7128)},
    {'name': 'San Francisco', 'snippet': 'The Golden Gate City', 'point': Position(-122.4194, 37.7749)},
    {'name': 'Chicago', 'snippet': 'The Windy City', 'point': Position(-87.6298, 41.8781)},
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Tappable Markers')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-98.5795, 39.8283),
          initZoom: 4,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (controller) => _controller = controller,
        mapChildren: [
          WidgetLayer(
            allowInteraction: true,
            markers: _places.map((place) {
              return Marker(
                point: place['point'] as Position,
                size: const Size(40, 40),
                alignment: Alignment.bottomCenter,
                child: GestureDetector(
                  onTap: () => _showMarkerInfo(place),
                  child: const Icon(Icons.location_on, color: Colors.red, size: 40),
                ),
              );
            }).toList(),
          ),
        ],
      ),
    );
  }

  void _showMarkerInfo(Map<String, Object> place) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(place['name'] as String),
        content: Text(place['snippet'] as String),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('Close'),
          ),
        ],
      ),
    );
  }
}

WidgetLayer(allowInteraction: true) is required for GestureDetector callbacks inside markers to fire — without it, the layer forwards all touches through to the map underneath so it doesn't block panning.

Clustered Markers ​

For better performance with many points, the real API has native clustering support — this isn't the same shape as the fictional API's Set<Marker> clustering. Add a GeoJsonSource with cluster: true, then add three layers for the cluster circles, the cluster count labels, and the unclustered points, using StyleController directly:

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

class ClusteredMarkersScreen extends StatefulWidget {
  @override
  _ClusteredMarkersScreenState createState() => _ClusteredMarkersScreenState();
}

const _sourceId = 'clustered-points';

class _ClusteredMarkersScreenState extends State<ClusteredMarkersScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Clustered Markers')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-74.0060, 40.7128),
          initZoom: 9,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (controller) => mapController = controller,
        onStyleLoaded: _onStyleLoaded,
      ),
    );
  }

  Future<void> _onStyleLoaded(StyleController style) async {
    // Generate random points around New York
    final center = Position(-74.0060, 40.7128);
    final features = List.generate(200, (i) {
      final random = Random(i);
      final lng = center.lng + (random.nextDouble() - 0.5) * 0.3;
      final lat = center.lat + (random.nextDouble() - 0.5) * 0.3;
      return {
        'type': 'Feature',
        'properties': <String, Object?>{},
        'geometry': {
          'type': 'Point',
          'coordinates': [lng, lat],
        },
      };
    });
    final geojson = jsonEncode({'type': 'FeatureCollection', 'features': features});

    await style.addSource(
      GeoJsonSource(
        id: _sourceId,
        data: geojson,
        cluster: true,
        clusterRadius: 50,
        clusterMaxZoom: 14,
      ),
    );

    await style.addLayer(
      const CircleStyleLayer(
        id: 'clusters',
        sourceId: _sourceId,
        paint: {
          'circle-color': [
            'step', ['get', 'point_count'],
            '#51bbd6', 20, '#f1f075', 50, '#f28cb1',
          ],
          'circle-radius': [
            'step', ['get', 'point_count'],
            16, 20, 24, 50, 32,
          ],
        },
      ),
    );

    await style.addLayer(
      const SymbolStyleLayer(
        id: 'cluster-count',
        sourceId: _sourceId,
        layout: {
          'text-field': '{point_count_abbreviated}',
          'text-font': ['Open Sans Regular'],
          'text-size': 12,
        },
      ),
    );

    await style.addLayer(
      const CircleStyleLayer(
        id: 'unclustered-point',
        sourceId: _sourceId,
        paint: {
          'circle-color': '#11b4da',
          'circle-radius': 4,
          'circle-stroke-width': 1,
          'circle-stroke-color': '#fff',
        },
      ),
    );
  }
}

Cluster circles automatically split apart into individual points as you zoom in past clusterMaxZoom — no manual re-clustering code is needed. See the SDK's CLUSTERING.md for the full parameter reference.

Polygons and Polylines ​

Polygon/Polyline + Set<...> widget params don't exist — use PolygonLayer/PolylineLayer in layers: instead, backed by geotypes Polygon/LineString geometries:

Adding Polygons ​

dart
final polygons = [
  Polygon(
    coordinates: [
      [
        Position(-74.0, 40.7),
        Position(-73.9, 40.7),
        Position(-73.9, 40.8),
        Position(-74.0, 40.8),
        Position(-74.0, 40.7), // ring must close
      ],
    ],
  ),
];

MapMetricsView(
  // ... other properties
  layers: [
    PolygonLayer(
      polygons: polygons,
      color: Colors.red.withValues(alpha: 0.3),
      outlineColor: Colors.red,
    ),
  ],
)

Adding Polylines ​

dart
final polylines = [
  LineString(
    coordinates: [
      Position(-74.0060, 40.7128), // New York
      Position(-75.1652, 39.9526), // Philadelphia
      Position(-77.0369, 38.9072), // Washington DC
    ],
  ),
];

MapMetricsView(
  // ... other properties
  layers: [
    PolylineLayer(polylines: polylines, color: Colors.blue, width: 3),
  ],
)

Circles ​

CircleLayer draws screen-space circles (constant pixel radius, not a geographic radius in meters) around a list of Points:

dart
final points = [Point(coordinates: Position(-74.0060, 40.7128))];

MapMetricsView(
  // ... other properties
  layers: [
    CircleLayer(
      points: points,
      radius: 20, // pixels, not meters
      color: Colors.blue.withValues(alpha: 0.2),
      strokeWidth: 2,
      strokeColor: Colors.blue,
    ),
  ],
)

If you need a true geographic-radius circle (e.g. "5km around this point"), generate a Polygon ring of points along that radius yourself and draw it with PolygonLayer — see Hexagon Layer for the same kind of geometry generation.

Advanced Marker Features ​

Draggable Markers ​

There is no draggable/onDragEnd on a declarative Marker for MarkerLayer. Drag support means real Flutter drag gestures, so it belongs to WidgetLayer, converting screen drag deltas back to map coordinates with MapController.toLngLat:

dart
WidgetLayer(
  allowInteraction: true,
  markers: [
    Marker(
      point: markerPosition,
      size: const Size(40, 40),
      alignment: Alignment.bottomCenter,
      child: GestureDetector(
        onPanUpdate: (details) async {
          final newPosition = await controller.toLngLat(details.globalPosition);
          setState(() => markerPosition = newPosition);
        },
        child: const Icon(Icons.location_on, color: Colors.red, size: 40),
      ),
    ),
  ],
)

Flat and Rotating Markers ​

WidgetLayer's Marker supports flat (lie down with map pitch instead of always facing the camera) and rotate (spin with map bearing instead of staying screen-up) booleans directly:

dart
Marker(
  point: Position(-74.0060, 40.7128),
  size: const Size(50, 50),
  flat: true,   // Marker tilts flat with the map instead of staying upright
  rotate: true, // Marker rotates to match the map's bearing
  child: const Icon(Icons.navigation, color: Colors.red, size: 50),
)

Performance Optimization ​

Marker Management ​

MarkerLayer is backed by a native symbol layer, so it already handles large point counts efficiently — you rarely need manual viewport culling for it the way you would for hundreds of Flutter WidgetLayer markers (each of those is a real widget in the tree). If you do have thousands of WidgetLayer markers, filter to the visible region using getVisibleRegion():

dart
class OptimizedMarkersScreen extends StatefulWidget {
  @override
  _OptimizedMarkersScreenState createState() => _OptimizedMarkersScreenState();
}

class _OptimizedMarkersScreenState extends State<OptimizedMarkersScreen> {
  MapController? mapController;
  List<Position> visiblePoints = [];
  List<Position> allPoints = [];

  @override
  void initState() {
    super.initState();
    _loadAllPoints();
  }

  void _loadAllPoints() {
    // Load all your data points here
    allPoints = [/* your data */];
  }

  Future<void> _updateVisiblePoints() async {
    final controller = mapController;
    if (controller == null) return;

    final region = await controller.getVisibleRegion();
    final filtered = allPoints.where((point) {
      return point.lng >= region.longitudeWest &&
          point.lng <= region.longitudeEast &&
          point.lat >= region.latitudeSouth &&
          point.lat <= region.latitudeNorth;
    }).take(100); // Limit to 100 markers for performance

    setState(() {
      visiblePoints = filtered.toList();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Optimized Markers')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-74.0060, 40.7128),
          initZoom: 10,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (controller) => mapController = controller,
        onEvent: (event) {
          if (event case MapEventCameraIdle()) {
            _updateVisiblePoints();
          }
        },
        layers: [
          MarkerLayer(
            points: visiblePoints.map((p) => Point(coordinates: p)).toList(),
          ),
        ],
      ),
    );
  }
}

Next Steps ​

Now that you can add markers and annotations, try:


Pro Tip: Use the MapMetrics Portal to create custom map styles that complement your markers. You can adjust the map's color scheme to make your markers stand out better.