Tap to Highlight Features in Flutter
This tutorial shows how to highlight map features when the user taps on them — the Flutter equivalent of hover effects on web maps.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style ID from the MapMetrics Portal
Highlight Tapped Marker
For markers with real per-marker tap detection, use WidgetLayer inside mapChildren — it renders actual Flutter widgets pinned to map coordinates, so each one gets its own GestureDetector:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class TapHighlightScreen extends StatefulWidget {
@override
_TapHighlightScreenState createState() => _TapHighlightScreenState();
}
class _TapHighlightScreenState extends State<TapHighlightScreen> {
MapController? mapController;
String? selectedMarkerId;
final List<Map<String, dynamic>> cities = [
{'id': 'paris', 'name': 'Paris', 'point': Position(2.3522, 48.8566)},
{'id': 'london', 'name': 'London', 'point': Position(-0.1276, 51.5074)},
{'id': 'berlin', 'name': 'Berlin', 'point': Position(13.405, 52.52)},
{'id': 'rome', 'name': 'Rome', 'point': Position(12.4964, 41.9028)},
{'id': 'madrid', 'name': 'Madrid', 'point': Position(-3.7038, 40.4168)},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Tap to Highlight')),
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(5.0, 48.0), // lng, lat
initZoom: 4.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onEvent: (event) {
// Deselect when tapping the map background (WidgetLayer
// markers intercept their own taps before this fires).
if (event is MapEventClick) {
setState(() {
selectedMarkerId = null;
});
}
},
mapChildren: [
WidgetLayer(
allowInteraction: true,
markers: cities.map((city) {
final isSelected = city['id'] == selectedMarkerId;
return Marker(
point: city['point'] as Position,
size: const Size.square(36),
alignment: Alignment.bottomCenter,
child: GestureDetector(
onTap: () {
setState(() {
selectedMarkerId = city['id'] as String;
});
},
child: Icon(
Icons.location_on,
color: isSelected ? Colors.blue : Colors.red,
size: 36,
),
),
);
}).toList(),
),
],
),
// Info panel for selected city
if (selectedMarkerId != null)
Positioned(
top: 16,
left: 16,
right: 16,
child: _buildInfoPanel(),
),
],
),
);
}
Widget _buildInfoPanel() {
final city = cities.firstWhere((c) => c['id'] == selectedMarkerId);
final point = city['point'] as Position;
return Card(
elevation: 4,
child: ListTile(
leading: Icon(Icons.location_on, color: Colors.blue, size: 32),
title: Text(city['name'] as String,
style: TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(
'Lat: ${point.lat}, Lng: ${point.lng}',
),
trailing: IconButton(
icon: Icon(Icons.close),
onPressed: () {
setState(() {
selectedMarkerId = null;
});
},
),
),
);
}
}Highlight Circles on Tap
CircleLayer is declarative and styles every circle in the list the same way, and there's no per-circle onTap. Render two CircleLayers — one for the highlighted circle, one for the rest — and figure out which circle was tapped yourself from the Position in MapEventClick, the same way the original approximate-distance check worked, just fed by onEvent instead of a fictional onMapClick:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class CircleTapHighlightScreen extends StatefulWidget {
@override
_CircleTapHighlightScreenState createState() =>
_CircleTapHighlightScreenState();
}
class _CircleTapHighlightScreenState extends State<CircleTapHighlightScreen> {
MapController? mapController;
int? highlightedIndex;
final List<Map<String, dynamic>> locations = [
{'name': 'Paris', 'point': Position(2.3522, 48.8566), 'visitors': '30M'},
{'name': 'London', 'point': Position(-0.1276, 51.5074), 'visitors': '20M'},
{'name': 'Berlin', 'point': Position(13.405, 52.52), 'visitors': '14M'},
{'name': 'Rome', 'point': Position(12.4964, 41.9028), 'visitors': '10M'},
{'name': 'Madrid', 'point': Position(-3.7038, 40.4168), 'visitors': '7M'},
{'name': 'Amsterdam', 'point': Position(4.9041, 52.3676), 'visitors': '8M'},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Circle Tap Highlight')),
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(5.0, 48.0),
initZoom: 4.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onEvent: (event) {
if (event case MapEventClick(point: final tapped)) {
_checkCircleTap(tapped);
}
},
layers: _buildCircleLayers(),
),
if (highlightedIndex != 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: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
child: Icon(Icons.location_city,
color: Colors.white, size: 24),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
locations[highlightedIndex!]['name'] as String,
style: TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
Text(
'Annual visitors: ${locations[highlightedIndex!]['visitors']}',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
IconButton(
icon: Icon(Icons.close),
onPressed: () {
setState(() {
highlightedIndex = null;
});
},
),
],
),
),
),
),
],
),
);
}
List<Layer> _buildCircleLayers() {
final points = locations
.map((loc) => Point(coordinates: loc['point'] as Position))
.toList();
final layers = <Layer>[
CircleLayer(
points: [
for (var i = 0; i < points.length; i++)
if (i != highlightedIndex) points[i],
],
color: Colors.blue.withValues(alpha: 0.2),
radius: 14,
strokeColor: Colors.blue.withValues(alpha: 0.6),
strokeWidth: 1,
),
];
if (highlightedIndex != null) {
layers.add(
CircleLayer(
points: [points[highlightedIndex!]],
color: Colors.blue.withValues(alpha: 0.5),
radius: 20,
strokeColor: Colors.blue,
strokeWidth: 3,
),
);
}
return layers;
}
void _checkCircleTap(Position tapPosition) {
// Find the closest location within a threshold
int? closestIndex;
double closestDistance = double.infinity;
for (int i = 0; i < locations.length; i++) {
final point = locations[i]['point'] as Position;
final distance = _approximateDistance(
tapPosition.lat.toDouble(),
tapPosition.lng.toDouble(),
point.lat.toDouble(),
point.lng.toDouble(),
);
if (distance < 0.5 && distance < closestDistance) {
// ~0.5 degrees threshold
closestDistance = distance;
closestIndex = i;
}
}
setState(() {
highlightedIndex = closestIndex;
});
}
/// Simple approximate distance in degrees
double _approximateDistance(
double lat1, double lng1, double lat2, double lng2) {
final dLat = (lat1 - lat2);
final dLng = (lng1 - lng2);
return (dLat * dLat + dLng * dLng);
}
}Highlight Polygon Region on Tap
Same idea as the circle example: PolygonLayer styles a whole list uniformly, so split the highlighted region into its own layer and do the point-in-polygon test yourself against the tapped Position:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class PolygonTapHighlightScreen extends StatefulWidget {
@override
_PolygonTapHighlightScreenState createState() =>
_PolygonTapHighlightScreenState();
}
class _PolygonTapHighlightScreenState
extends State<PolygonTapHighlightScreen> {
MapController? mapController;
String? selectedRegion;
final Map<String, List<Position>> regions = {
'North': [
Position(1.0, 49.0),
Position(4.0, 49.0),
Position(4.0, 51.0),
Position(1.0, 51.0),
Position(1.0, 49.0),
],
'East': [
Position(4.0, 47.0),
Position(8.0, 47.0),
Position(8.0, 49.0),
Position(4.0, 49.0),
Position(4.0, 47.0),
],
'South': [
Position(1.0, 43.0),
Position(4.0, 43.0),
Position(4.0, 45.0),
Position(1.0, 45.0),
Position(1.0, 43.0),
],
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Polygon Tap Highlight')),
body: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(4.0, 47.0),
initZoom: 5.0,
),
onMapCreated: (MapController controller) {
mapController = controller;
},
onEvent: (event) {
if (event case MapEventClick(point: final tapped)) {
setState(() {
selectedRegion = _hitTestRegions(tapped);
});
}
},
layers: regions.entries.map((entry) {
final isSelected = entry.key == selectedRegion;
return PolygonLayer(
polygons: [
Polygon(coordinates: [entry.value]),
],
color: isSelected
? Colors.blue.withValues(alpha: 0.5)
: Colors.grey.withValues(alpha: 0.2),
outlineColor: isSelected ? Colors.blue : Colors.grey,
);
}).toList(),
),
);
}
String? _hitTestRegions(Position point) {
for (final entry in regions.entries) {
if (_pointInRing(point, entry.value)) return entry.key;
}
return null;
}
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;
}
}Next Steps
- Popup on Click — Show popups when tapping features
- Show Polygon Info on Click — Display region details
- Filter Markers — Show/hide markers by category
Tip: WidgetLayer markers get real per-widget gesture detection, so prefer them when you need per-marker taps. For CircleLayer/PolygonLayer shapes there's no per-shape tap callback — hit-test the Position from MapEventClick against your own data instead.