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

Show Polygon Info on Click in Flutter

This tutorial shows how to display information about a polygon when the user taps on it — useful for showing zone details, area statistics, or district information.

Prerequisites

Before you begin, ensure you have:

A Note on Tap Detection

The real MapMetrics SDK doesn't have a Google-Maps-style Polygon widget with its own onTap callback. Instead, MapMetricsView renders geometry declaratively through layers: [PolygonLayer(...), ...], and taps are surfaced globally through onEvent as a MapEventClick, which gives you the tapped Position (lng, lat) — not which polygon was hit. To know which zone was tapped, do a point-in-polygon test yourself against your own zone data. That's what _hitTestZones() below does.

Tappable Polygons with Info Card

Tap a polygon to see its details in a bottom card:

dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';

class PolygonInfoScreen extends StatefulWidget {
  @override
  _PolygonInfoScreenState createState() => _PolygonInfoScreenState();
}

class _PolygonInfoScreenState extends State<PolygonInfoScreen> {
  MapController? mapController;
  String? selectedZoneId;

  final List<Map<String, dynamic>> zones = [
    {
      'id': 'zone_a',
      'name': '1st Arrondissement',
      'description': 'Historic center with the Louvre, Tuileries Garden, and Palais Royal.',
      'population': '17,600',
      'area': '1.83 km²',
      'color': Colors.blue,
      // lng, lat order — Position, not LatLng
      'ring': [
        Position(2.327, 48.865),
        Position(2.345, 48.865),
        Position(2.345, 48.856),
        Position(2.327, 48.856),
        Position(2.327, 48.865),
      ],
    },
    {
      'id': 'zone_b',
      'name': '4th Arrondissement',
      'description': 'Home to Notre-Dame, Île de la Cité, and the Marais district.',
      'population': '28,600',
      'area': '1.60 km²',
      'color': Colors.green,
      'ring': [
        Position(2.345, 48.858),
        Position(2.365, 48.858),
        Position(2.365, 48.848),
        Position(2.345, 48.848),
        Position(2.345, 48.858),
      ],
    },
    {
      'id': 'zone_c',
      'name': '5th Arrondissement',
      'description': 'The Latin Quarter with the Panthéon and Luxembourg Gardens.',
      'population': '60,200',
      'area': '2.54 km²',
      'color': Colors.orange,
      'ring': [
        Position(2.335, 48.852),
        Position(2.360, 48.852),
        Position(2.360, 48.842),
        Position(2.335, 48.842),
        Position(2.335, 48.852),
      ],
    },
  ];

  Map<String, dynamic>? get selectedZone =>
      selectedZoneId != null
          ? zones.firstWhere((z) => z['id'] == selectedZoneId)
          : null;

  /// One PolygonLayer per zone so each keeps its own fill color, plus a
  /// PolylineLayer per zone so the selected zone can get a thicker outline —
  /// PolygonLayer only exposes a single outline *color*, not a stroke width.
  List<Layer> get _zoneLayers {
    final layers = <Layer>[];
    for (final zone in zones) {
      final isSelected = zone['id'] == selectedZoneId;
      final Color color = zone['color'] as Color;
      final ring = (zone['ring'] as List<Position>);

      layers.add(
        PolygonLayer(
          polygons: [
            Polygon(coordinates: [ring]),
          ],
          color: color.withValues(alpha: isSelected ? 0.35 : 0.15),
          outlineColor: isSelected ? color : color.withValues(alpha: 0.6),
        ),
      );
      layers.add(
        PolylineLayer(
          polylines: [
            LineString(coordinates: ring),
          ],
          color: color,
          width: isSelected ? 3 : 2,
        ),
      );
    }
    return layers;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('District Info')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              initCenter: Position(2.347, 48.855), // lng, lat
              initZoom: 14.0,
            ),
            onMapCreated: (controller) => mapController = controller,
            onEvent: (event) {
              if (event case MapEventClick(point: final tapped)) {
                setState(() {
                  selectedZoneId = _hitTestZones(tapped);
                });
              }
            },
            layers: _zoneLayers,
          ),
          // Info card
          if (selectedZone != null)
            Positioned(
              bottom: 24,
              left: 16,
              right: 16,
              child: Card(
                elevation: 6,
                shape: RoundedRectangleBorder(
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Padding(
                  padding: EdgeInsets.all(16),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          Container(
                            width: 14,
                            height: 14,
                            decoration: BoxDecoration(
                              color: selectedZone!['color'],
                              borderRadius: BorderRadius.circular(3),
                            ),
                          ),
                          SizedBox(width: 8),
                          Expanded(
                            child: Text(
                              selectedZone!['name'],
                              style: TextStyle(
                                fontSize: 18,
                                fontWeight: FontWeight.bold,
                              ),
                            ),
                          ),
                          IconButton(
                            icon: Icon(Icons.close, size: 20),
                            onPressed: () =>
                                setState(() => selectedZoneId = null),
                            padding: EdgeInsets.zero,
                            constraints: BoxConstraints(),
                          ),
                        ],
                      ),
                      SizedBox(height: 8),
                      Text(
                        selectedZone!['description'],
                        style: TextStyle(color: Colors.grey[700], fontSize: 14),
                      ),
                      SizedBox(height: 12),
                      Row(
                        children: [
                          _statChip(Icons.people, 'Population',
                              selectedZone!['population']),
                          SizedBox(width: 16),
                          _statChip(
                              Icons.square_foot, 'Area', selectedZone!['area']),
                        ],
                      ),
                    ],
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }

  /// Returns the id of the first zone whose ring contains [point], or null.
  String? _hitTestZones(Position point) {
    for (final zone in zones) {
      if (_pointInRing(point, zone['ring'] as List<Position>)) {
        return zone['id'] as String;
      }
    }
    return null;
  }

  /// Simple ray-casting point-in-polygon test over a closed ring of
  /// (lng, lat) [Position]s.
  bool _pointInRing(Position point, List<Position> ring) {
    var inside = false;
    for (var i = 0, j = ring.length - 1; i < ring.length; j = i++) {
      final xi = ring[i].lng, yi = ring[i].lat;
      final xj = ring[j].lng, yj = ring[j].lat;
      final intersects = ((yi > point.lat) != (yj > point.lat)) &&
          (point.lng < (xj - xi) * (point.lat - yi) / (yj - yi) + xi);
      if (intersects) inside = !inside;
    }
    return inside;
  }

  Widget _statChip(IconData icon, String label, String value) {
    return Row(
      children: [
        Icon(icon, size: 16, color: Colors.grey),
        SizedBox(width: 4),
        Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(label, style: TextStyle(fontSize: 11, color: Colors.grey)),
            Text(value,
                style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
          ],
        ),
      ],
    );
  }
}

Highlighting the Selected Polygon

The selected polygon gets:

  • A thicker outline (PolylineLayer(width: 3) vs 2)
  • A more opaque fill (0.35 vs 0.15 alpha)
  • A full-color outline instead of a semi-transparent one

This visual feedback makes it clear which zone is selected.

Next Steps


Tip: Because MapMetricsView only exposes a single global onEvent click stream rather than a per-shape tap callback, keep your own geometry (like zones above) around so you can run hit-testing against the tapped Position yourself.