Add a Layer Below Labels in Flutter
This tutorial shows how to insert new layers below the map's text labels — so your data layers don't cover up important place names and road labels.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
StyleController.addLayer(layer, {belowLayerId})really does support inserting below an existing layer, matching the concept here. What the SDK does not expose is a way to list the loaded style's layers at runtime (there is nogetStyleLayers()), so you can't auto-detect "the first symbol layer" the way the original tutorial did. In practice you already know your style — open its JSON (from the MapMetrics Portal, or fetch theinitStyleURL) and note theidof the first"type": "symbol"layer once, then hardcode it as a constant.
Insert Layer Below Labels
Add a polygon fill that renders beneath all text labels:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class LayerBelowLabelsScreen extends StatefulWidget {
@override
_LayerBelowLabelsScreenState createState() =>
_LayerBelowLabelsScreenState();
}
class _LayerBelowLabelsScreenState extends State<LayerBelowLabelsScreen> {
MapController? mapController;
// The id of the first label (symbol) layer in YOUR_STYLE.json.
// Find it once by inspecting the style JSON — there is no runtime lookup.
static const _firstSymbolLayerId = 'place-labels';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Layer Below Labels')),
body: MapMetricsView(
options: MapOptions(
initCenter: Position(2.3522, 48.8566), // lng, lat
initZoom: 12.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onStyleLoaded: (StyleController style) {
_addLayerBelowLabels(style);
},
),
);
}
Future<void> _addLayerBelowLabels(StyleController style) async {
// Add polygon source
final geoJson = {
'type': 'Feature',
'properties': {},
'geometry': {
'type': 'Polygon',
'coordinates': [
[
[2.28, 48.84],
[2.42, 48.84],
[2.42, 48.88],
[2.28, 48.88],
[2.28, 48.84],
]
],
},
};
await style.addSource(
GeoJsonSource(id: 'highlight-area', data: jsonEncode(geoJson)),
);
// Insert fill layer BELOW the labels
await style.addLayer(
const FillStyleLayer(
id: 'highlight-fill',
sourceId: 'highlight-area',
paint: {'fill-color': '#3b82f6', 'fill-opacity': 0.3},
),
belowLayerId: _firstSymbolLayerId,
);
// Insert outline also below labels
await style.addLayer(
const LineStyleLayer(
id: 'highlight-outline',
sourceId: 'highlight-area',
paint: {'line-color': '#1d4ed8', 'line-width': 2.0},
),
belowLayerId: _firstSymbolLayerId,
);
}
}Multiple Data Layers with Proper Ordering
Add several data layers that all sit below labels. Because there is no setLayerVisibility, toggles are implemented with removeLayer / addLayer(belowLayerId:):
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class OrderedLayersScreen extends StatefulWidget {
@override
_OrderedLayersScreenState createState() => _OrderedLayersScreenState();
}
class _OrderedLayersScreenState extends State<OrderedLayersScreen> {
MapController? mapController;
StyleController? styleController;
bool showZones = true;
bool showRoutes = true;
// Find this id once by inspecting YOUR_STYLE.json — no runtime lookup exists.
static const _firstSymbolLayerId = 'place-labels';
static const _zoneFillLayer = FillStyleLayer(
id: 'zone-fill',
sourceId: 'zones',
paint: {'fill-color': '#22c55e', 'fill-opacity': 0.2},
);
static const _zoneOutlineLayer = LineStyleLayer(
id: 'zone-outline',
sourceId: 'zones',
paint: {'line-color': '#15803d', 'line-width': 2.0},
);
static const _routeLineLayer = LineStyleLayer(
id: 'route-line',
sourceId: 'route',
layout: {'line-join': 'round', 'line-cap': 'round'},
paint: {'line-color': '#ef4444', 'line-width': 4.0},
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Ordered Layers')),
body: Stack(
children: [
MapMetricsView(
options: MapOptions(
initCenter: Position(2.345, 48.857), // lng, lat
initZoom: 12.0,
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onStyleLoaded: (StyleController style) {
styleController = style;
_addOrderedLayers(style);
},
),
// Layer toggles
Positioned(
top: 16,
right: 16,
child: Card(
child: Padding(
padding: EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showZones,
onChanged: (val) {
setState(() => showZones = val!);
_toggleLayer(_zoneFillLayer, val!);
_toggleLayer(_zoneOutlineLayer, val!);
},
),
Text('Zones'),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
value: showRoutes,
onChanged: (val) {
setState(() => showRoutes = val!);
_toggleLayer(_routeLineLayer, val!);
},
),
Text('Routes'),
],
),
],
),
),
),
),
],
),
);
}
Future<void> _addOrderedLayers(StyleController style) async {
// Layer 1: Zone polygons (bottom)
final zoneGeoJson = {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'properties': {'name': 'Zone A'},
'geometry': {
'type': 'Polygon',
'coordinates': [[
[2.30, 48.84], [2.36, 48.84],
[2.36, 48.87], [2.30, 48.87], [2.30, 48.84],
]],
},
},
{
'type': 'Feature',
'properties': {'name': 'Zone B'},
'geometry': {
'type': 'Polygon',
'coordinates': [[
[2.34, 48.85], [2.40, 48.85],
[2.40, 48.88], [2.34, 48.88], [2.34, 48.85],
]],
},
},
],
};
await style.addSource(
GeoJsonSource(id: 'zones', data: jsonEncode(zoneGeoJson)),
);
await style.addLayer(_zoneFillLayer, belowLayerId: _firstSymbolLayerId);
await style.addLayer(_zoneOutlineLayer, belowLayerId: _firstSymbolLayerId);
// Layer 2: Route lines (above zones, below labels)
final routeGeoJson = {
'type': 'Feature',
'properties': {},
'geometry': {
'type': 'LineString',
'coordinates': [
[2.29, 48.86], [2.34, 48.855], [2.38, 48.86], [2.41, 48.865],
],
},
};
await style.addSource(
GeoJsonSource(id: 'route', data: jsonEncode(routeGeoJson)),
);
await style.addLayer(_routeLineLayer, belowLayerId: _firstSymbolLayerId);
}
Future<void> _toggleLayer(StyleLayer layer, bool visible) async {
final style = styleController;
if (style == null) return;
if (visible) {
await style.addLayer(layer, belowLayerId: _firstSymbolLayerId);
} else {
await style.removeLayer(layer.id);
}
}
}Layer Ordering
| Position | Layer Types | Labels Visible? |
|---|---|---|
| Top (default) | Data added without belowLayerId | Covered by data |
| Below labels | Data added with belowLayerId: firstSymbolLayerId | Yes, readable |
| Bottom | Base map tiles | Always below everything |
Next Steps
- Add a GeoJSON Polygon — Draw polygons
- Change Layer Color — Dynamic layer styling
- Add a GeoJSON Line — Draw lines
Tip: Always insert data layers below labels in production apps. Users expect to see place names even when data overlays are active. Since this SDK has no runtime style introspection, open your style JSON once, note the id of its first symbol layer, and keep it as a constant in code.