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

Add an Icon to the Map in Flutter

This tutorial shows how to add custom icon images to the map style and use them as symbols on markers or layers.

Prerequisites

Before you begin, ensure you have:

Add Asset Image as Map Icon

Load a local asset image and add it to the map style for use in symbol layers. Images are registered with StyleController.addImage, and layers are added with StyleController.addLayer:

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

class AddIconToMapScreen extends StatefulWidget {
  @override
  _AddIconToMapScreenState createState() => _AddIconToMapScreenState();
}

class _AddIconToMapScreenState extends State<AddIconToMapScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Add Icon to Map')),
      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) {
          _addIconAndSymbolLayer(style);
        },
      ),
    );
  }

  Future<void> _addIconAndSymbolLayer(StyleController style) async {
    // Load the icon image from assets
    final ByteData bytes = await rootBundle.load('assets/icons/pin.png');
    final Uint8List imageData = bytes.buffer.asUint8List();

    // Add the image to the map style
    await style.addImage('custom-pin', imageData);

    // Create a GeoJSON source with points (coordinates are [lng, lat])
    final geoJson = {
      'type': 'FeatureCollection',
      'features': [
        {
          'type': 'Feature',
          'properties': {'name': 'Eiffel Tower'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.2945, 48.8584],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Louvre Museum'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.3376, 48.8606],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Notre-Dame'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.3499, 48.8530],
          },
        },
      ],
    };

    // Add source and symbol layer using the custom icon
    await style.addSource(
      GeoJsonSource(id: 'landmarks', data: jsonEncode(geoJson)),
    );

    await style.addLayer(
      const SymbolStyleLayer(
        id: 'landmarks-icons',
        sourceId: 'landmarks',
        layout: {
          'icon-image': 'custom-pin',
          'icon-size': 0.5,
          'text-field': ['get', 'name'],
          'text-size': 12.0,
          'text-offset': [0.0, 1.5],
          'text-anchor': 'top',
        },
      ),
    );
  }
}

Make sure to declare the asset in pubspec.yaml:

yaml
flutter:
  assets:
    - assets/icons/pin.png

Multiple Icon Types

Add different icons for different place categories:

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

class MultiIconScreen extends StatefulWidget {
  @override
  _MultiIconScreenState createState() => _MultiIconScreenState();
}

class _MultiIconScreenState extends State<MultiIconScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Multiple Icons')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(2.3400, 48.8566), // lng, lat
              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: (StyleController style) {
              _addMultipleIcons(style);
            },
          ),
          // Legend
          Positioned(
            bottom: 16,
            left: 16,
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(12),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Text('Legend',
                        style: TextStyle(fontWeight: FontWeight.bold)),
                    SizedBox(height: 4),
                    _legendRow(Icons.restaurant, Colors.red, 'Restaurants'),
                    _legendRow(Icons.hotel, Colors.blue, 'Hotels'),
                    _legendRow(Icons.museum, Colors.green, 'Museums'),
                  ],
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _legendRow(IconData icon, Color color, String label) {
    return Padding(
      padding: EdgeInsets.symmetric(vertical: 2),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(icon, color: color, size: 18),
          SizedBox(width: 6),
          Text(label, style: TextStyle(fontSize: 13)),
        ],
      ),
    );
  }

  Future<void> _addMultipleIcons(StyleController style) async {
    // Load different icon assets
    final restaurantBytes =
        await rootBundle.load('assets/icons/restaurant.png');
    final hotelBytes = await rootBundle.load('assets/icons/hotel.png');
    final museumBytes = await rootBundle.load('assets/icons/museum.png');

    // addImages batches all three into a single native call
    await style.addImages({
      'icon-restaurant': restaurantBytes.buffer.asUint8List(),
      'icon-hotel': hotelBytes.buffer.asUint8List(),
      'icon-museum': museumBytes.buffer.asUint8List(),
    });

    final geoJson = {
      'type': 'FeatureCollection',
      'features': [
        {
          'type': 'Feature',
          'properties': {'name': 'Le Jules Verne', 'icon': 'icon-restaurant'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.2945, 48.8580],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Hotel Ritz', 'icon': 'icon-hotel'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.3285, 48.8682],
          },
        },
        {
          'type': 'Feature',
          'properties': {'name': 'Louvre Museum', 'icon': 'icon-museum'},
          'geometry': {
            'type': 'Point',
            'coordinates': [2.3376, 48.8606],
          },
        },
      ],
    };

    await style.addSource(
      GeoJsonSource(id: 'places', data: jsonEncode(geoJson)),
    );

    // Use a data-driven icon based on the 'icon' property.
    // MapLibre style expressions — ['get', 'icon'] — read the value at
    // render time; there is no `{icon}` string-token syntax in this SDK.
    await style.addLayer(
      const SymbolStyleLayer(
        id: 'places-layer',
        sourceId: 'places',
        layout: {
          'icon-image': ['get', 'icon'],
          'icon-size': 0.4,
          'text-field': ['get', 'name'],
          'text-size': 11.0,
          'text-offset': [0.0, 1.8],
          'text-anchor': 'top',
        },
        paint: {'text-color': '#333333'},
      ),
    );
  }
}

Generate Icon from Flutter Widget

Create an icon programmatically using Canvas drawing:

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

class GeneratedIconScreen extends StatefulWidget {
  @override
  _GeneratedIconScreenState createState() => _GeneratedIconScreenState();
}

class _GeneratedIconScreenState extends State<GeneratedIconScreen> {
  MapController? mapController;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Generated Icon')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(2.3400, 48.8566), // lng, lat
          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: (StyleController style) {
          _addGeneratedIcons(style);
        },
      ),
    );
  }

  /// Draw a colored circle icon with a label
  Future<Uint8List> _generateCircleIcon(
      Color color, String label, double size) async {
    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder);

    // Draw filled circle
    final paint = Paint()..color = color;
    canvas.drawCircle(Offset(size / 2, size / 2), size / 2, paint);

    // Draw border
    final borderPaint = Paint()
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeWidth = 3;
    canvas.drawCircle(
        Offset(size / 2, size / 2), size / 2 - 1.5, borderPaint);

    // Draw label
    final textPainter = TextPainter(
      text: TextSpan(
        text: label,
        style: TextStyle(
            color: Colors.white,
            fontSize: size * 0.35,
            fontWeight: FontWeight.bold),
      ),
      textDirection: TextDirection.ltr,
    );
    textPainter.layout();
    textPainter.paint(
      canvas,
      Offset(
          (size - textPainter.width) / 2, (size - textPainter.height) / 2),
    );

    final picture = recorder.endRecording();
    final image = await picture.toImage(size.toInt(), size.toInt());
    final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
    return bytes!.buffer.asUint8List();
  }

  Future<void> _addGeneratedIcons(StyleController style) async {
    // Generate numbered icons
    final colors = [Colors.blue, Colors.red, Colors.green];
    final labels = ['1', '2', '3'];
    final positions = [
      [2.2945, 48.8584],
      [2.3376, 48.8606],
      [2.3499, 48.8530],
    ];
    final names = ['Eiffel Tower', 'Louvre', 'Notre-Dame'];

    for (int i = 0; i < 3; i++) {
      final iconData = await _generateCircleIcon(colors[i], labels[i], 64);
      await style.addImage('gen-icon-$i', iconData);
    }

    final features = <Map<String, dynamic>>[];
    for (int i = 0; i < positions.length; i++) {
      features.add({
        'type': 'Feature',
        'properties': {'name': names[i], 'iconId': 'gen-icon-$i'},
        'geometry': {
          'type': 'Point',
          'coordinates': positions[i],
        },
      });
    }

    await style.addSource(
      GeoJsonSource(
        id: 'generated-icons',
        data: jsonEncode({
          'type': 'FeatureCollection',
          'features': features,
        }),
      ),
    );

    await style.addLayer(
      const SymbolStyleLayer(
        id: 'generated-icons-layer',
        sourceId: 'generated-icons',
        layout: {
          'icon-image': ['get', 'iconId'],
          'icon-size': 0.6,
          'text-field': ['get', 'name'],
          'text-size': 12.0,
          'text-offset': [0.0, 2.0],
          'text-anchor': 'top',
        },
      ),
    );
  }
}

Next Steps


Tip: For data-driven icons, set 'icon-image' to a MapLibre expression like ['get', 'propertyName'] — the map engine evaluates it per feature at render time. This lets you use different icons for different categories from a single layer.