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:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style ID from the MapMetrics Portal
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:
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)vs2) - A more opaque fill (
0.35vs0.15alpha) - A full-color outline instead of a semi-transparent one
This visual feedback makes it clear which zone is selected.
Next Steps
- Add a Polygon — Basic polygon drawing
- Popup on Click — Popups on markers
- Multiple Geometries — Mix polygons with other shapes
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.