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

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:

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:

dart
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:

dart
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

ValueInputOutput
noneParisParis
uppercaseParisPARIS
lowercaseParisparis

Next Steps


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.