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

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:

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:

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

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

dart
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


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.