Change the Case of Labels in Flutter
This tutorial shows how to change the text case of map labels — converting to uppercase, lowercase, or title case at runtime.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style URL from the MapMetrics Portal
A Real Constraint: No setLayoutProperty / setPaintProperty
There is no mapController.setLayoutProperty(...) or mapController.setPaintProperty(...) in the SDK — StyleController only exposes addLayer and removeLayer. That means you cannot reach into the base style's built-in label layers (place-city-label, road-label, etc., baked into the style JSON you loaded) and flip their text-transform at runtime, because you don't hold their full layer definitions to re-add them.
What you can do — and what both examples below do — is add your own SymbolStyleLayer for the labels you want to control, from data you supply. Because you authored that layer's paint/layout maps yourself, "changing" its text-transform is just removeLayer followed by addLayer with the same source but an updated layout map.
Change Label Text Transform
Use the text-transform layout property on a symbol layer you own to change label casing:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class LabelCaseScreen extends StatefulWidget {
@override
_LabelCaseScreenState createState() => _LabelCaseScreenState();
}
const _sourceId = 'city-labels-source';
const _layerId = 'city-labels-layer';
class _LabelCaseScreenState extends State<LabelCaseScreen> {
MapController? mapController;
StyleController? styleController;
String currentCase = 'none';
final List<Map<String, String>> caseOptions = [
{'value': 'none', 'label': 'Default'},
{'value': 'uppercase', 'label': 'UPPERCASE'},
{'value': 'lowercase', 'label': 'lowercase'},
];
// A few Paris points of interest to label — stand-in for your own POI data.
final List<Map<String, Object>> _places = [
{'name': 'Eiffel Tower', 'lng': 2.2945, 'lat': 48.8584},
{'name': 'Louvre', 'lng': 2.3376, 'lat': 48.8606},
{'name': 'Notre-Dame', 'lng': 2.3499, 'lat': 48.8530},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Label Case')),
body: Column(
children: [
// Case selector
Container(
padding: EdgeInsets.all(12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: caseOptions.map((option) {
return ChoiceChip(
label: Text(option['label']!),
selected: currentCase == option['value'],
onSelected: (selected) {
if (selected) {
setState(() => currentCase = option['value']!);
_applyTextTransform(option['value']!);
}
},
);
}).toList(),
),
),
// Map
Expanded(
child: MapMetricsView(
options: MapOptions(
initCenter: Position(2.3522, 48.8566), // Paris (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: (style) async {
styleController = style;
await style.addSource(
GeoJsonSource(id: _sourceId, data: jsonEncode(_placesGeoJson())),
);
await _addLabelLayer(currentCase);
},
),
),
],
),
);
}
Map<String, Object?> _placesGeoJson() => {
'type': 'FeatureCollection',
'features': _places.map((p) => {
'type': 'Feature',
'properties': {'name': p['name']},
'geometry': {
'type': 'Point',
'coordinates': [p['lng'], p['lat']],
},
}).toList(),
};
Future<void> _addLabelLayer(String textCase) async {
await styleController?.addLayer(
SymbolStyleLayer(
id: _layerId,
sourceId: _sourceId,
layout: {
'text-field': const ['get', 'name'],
'text-transform': textCase,
'text-size': 13,
},
paint: const {
'text-color': '#333333',
'text-halo-color': '#FFFFFF',
'text-halo-width': 1.5,
},
),
);
}
Future<void> _applyTextTransform(String textCase) async {
// No setLayoutProperty — swap the layer we own out and back in.
await styleController?.removeLayer(_layerId);
await _addLabelLayer(textCase);
}
}Custom Label Styling
Combine text case changes with font size and color adjustments — again, on a layer built from your own data, swapped via removeLayer + addLayer on preset changes:
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class LabelStyleScreen extends StatefulWidget {
@override
_LabelStyleScreenState createState() => _LabelStyleScreenState();
}
const _sourceId = 'city-labels-source';
const _layerId = 'city-labels-layer';
class _LabelStyleScreenState extends State<LabelStyleScreen> {
MapController? mapController;
StyleController? styleController;
String activePreset = 'default';
final Map<String, Map<String, dynamic>> presets = {
'default': {
'name': 'Default',
'textTransform': 'none',
'textSize': 13,
'textColor': '#333333',
},
'bold_caps': {
'name': 'Bold Caps',
'textTransform': 'uppercase',
'textSize': 15,
'textColor': '#1a1a1a',
},
'subtle': {
'name': 'Subtle',
'textTransform': 'lowercase',
'textSize': 11,
'textColor': '#999999',
},
'highlight': {
'name': 'Highlight',
'textTransform': 'uppercase',
'textSize': 14,
'textColor': '#1565c0',
},
};
final List<Map<String, Object>> _places = [
{'name': 'Eiffel Tower', 'lng': 2.2945, 'lat': 48.8584},
{'name': 'Louvre', 'lng': 2.3376, 'lat': 48.8606},
{'name': 'Notre-Dame', 'lng': 2.3499, 'lat': 48.8530},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Label Styling')),
body: Column(
children: [
Container(
padding: EdgeInsets.all(8),
child: Wrap(
spacing: 8,
children: presets.entries.map((entry) {
return ChoiceChip(
label: Text(entry.value['name']),
selected: activePreset == entry.key,
onSelected: (selected) {
if (selected) {
setState(() => activePreset = entry.key);
_applyPreset(entry.value);
}
},
);
}).toList(),
),
),
Expanded(
child: MapMetricsView(
options: MapOptions(
initCenter: Position(2.3522, 48.8566),
initZoom: 13.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: (style) async {
styleController = style;
await style.addSource(
GeoJsonSource(id: _sourceId, data: jsonEncode(_placesGeoJson())),
);
await _addLabelLayer(presets[activePreset]!);
},
),
),
],
),
);
}
Map<String, Object?> _placesGeoJson() => {
'type': 'FeatureCollection',
'features': _places.map((p) => {
'type': 'Feature',
'properties': {'name': p['name']},
'geometry': {
'type': 'Point',
'coordinates': [p['lng'], p['lat']],
},
}).toList(),
};
Future<void> _addLabelLayer(Map<String, dynamic> preset) async {
await styleController?.addLayer(
SymbolStyleLayer(
id: _layerId,
sourceId: _sourceId,
layout: {
'text-field': const ['get', 'name'],
'text-transform': preset['textTransform'],
'text-size': preset['textSize'],
},
paint: {
'text-color': preset['textColor'],
'text-halo-color': '#FFFFFF',
'text-halo-width': 1.5,
},
),
);
}
Future<void> _applyPreset(Map<String, dynamic> preset) async {
await styleController?.removeLayer(_layerId);
await _addLabelLayer(preset);
}
}Text Transform Options
| Value | Input | Output |
|---|---|---|
none | Paris | Paris |
uppercase | Paris | PARIS |
lowercase | Paris | paris |
Next Steps
- Custom Map Styling — Full style customization
- Change Layer Color — Dynamic color changes
- Building Color by Zoom — Zoom-dependent styling
Tip: Uppercase labels look great on maps at low zoom levels (country/state names), while default casing is better at street level where readability matters more. If you need the base style's built-in labels to change case, do it at style-authoring time in the style JSON itself (in the MapMetrics Portal) rather than at runtime — the Flutter SDK has no hook to rewrite properties of layers it didn't add.