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:
| Need | Use |
|---|---|
| Many simple points with a text label and/or a style-sprite icon, no per-marker interaction | MarkerLayer 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:
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:
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:
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:):
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:
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
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
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:
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:
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:
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():
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.