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

Display a Popup in Flutter

This tutorial shows different ways to display popups and info overlays on your MapMetrics Flutter map — from simple tap-to-show cards to custom bottom sheets.

Prerequisites

Before you begin, ensure you have:

No built-in popup widget: the SDK has no Marker/InfoWindow pair on the map widget itself and no native popup bubble. Interactive markers are built with WidgetLayer — a mapChildren entry that places ordinary Flutter widgets (wrapped in Marker(point:, child:)) at map coordinates — combined with a GestureDetector for taps, and any Flutter overlay (Positioned card, showModalBottomSheet, etc.) for the popup content itself.

Basic Tap-to-Show Popup

Place tappable pins with WidgetLayer and show a small card with the place's title and description when one is tapped:

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

class BasicPopupScreen extends StatefulWidget {
  @override
  _BasicPopupScreenState createState() => _BasicPopupScreenState();
}

class _BasicPopupScreenState extends State<BasicPopupScreen> {
  MapController? mapController;
  Map<String, dynamic>? selectedPlace;

  final List<Map<String, dynamic>> places = [
    {
      'id': 'eiffel',
      'name': 'Eiffel Tower',
      'snippet': 'Built in 1889 — 330m tall',
      'position': Position(2.2945, 48.8584),
    },
    {
      'id': 'louvre',
      'name': 'Louvre Museum',
      'snippet': 'Home of the Mona Lisa',
      'position': Position(2.3376, 48.8606),
    },
    {
      'id': 'notre_dame',
      'name': 'Notre-Dame Cathedral',
      'snippet': 'Gothic masterpiece since 1163',
      'position': Position(2.3499, 48.8530),
    },
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Basic Popup')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(2.3200, 48.8566),
              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;
            },
            mapChildren: [
              WidgetLayer(
                allowInteraction: true,
                markers: places.map((place) {
                  return Marker(
                    point: place['position'] as Position,
                    size: const Size.square(36),
                    alignment: Alignment.bottomCenter,
                    child: GestureDetector(
                      onTap: () => setState(() => selectedPlace = place),
                      child: const Icon(
                        Icons.location_on,
                        color: Colors.red,
                        size: 36,
                      ),
                    ),
                  );
                }).toList(),
              ),
            ],
          ),
          if (selectedPlace != null)
            Positioned(
              bottom: 24,
              left: 16,
              right: 16,
              child: Card(
                child: ListTile(
                  title: Text(selectedPlace!['name'] as String),
                  subtitle: Text(selectedPlace!['snippet'] as String),
                  trailing: IconButton(
                    icon: Icon(Icons.close),
                    onPressed: () => setState(() => selectedPlace = null),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

Custom Popup Overlay

Show a floating card popup when tapping a marker, and dismiss it when the base map itself is tapped (via onEvent's MapEventClick):

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

class CustomPopupScreen extends StatefulWidget {
  @override
  _CustomPopupScreenState createState() => _CustomPopupScreenState();
}

class _CustomPopupScreenState extends State<CustomPopupScreen> {
  MapController? mapController;
  Map<String, dynamic>? selectedPlace;

  final List<Map<String, dynamic>> places = [
    {
      'id': 'eiffel',
      'name': 'Eiffel Tower',
      'description': 'Iconic iron lattice tower on the Champ de Mars.',
      'position': Position(2.2945, 48.8584),
      'rating': 4.7,
    },
    {
      'id': 'louvre',
      'name': 'Louvre Museum',
      'description': 'World\'s largest art museum and historic monument.',
      'position': Position(2.3376, 48.8606),
      'rating': 4.8,
    },
    {
      'id': 'sacre_coeur',
      'name': 'Sacre-Coeur',
      'description': 'White-domed basilica atop Montmartre hill.',
      'position': Position(2.3431, 48.8867),
      'rating': 4.6,
    },
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Custom Popup')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(2.3200, 48.8600),
              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;
            },
            onEvent: (event) {
              if (event is MapEventClick) {
                // Dismiss popup when tapping the map itself
                setState(() {
                  selectedPlace = null;
                });
              }
            },
            mapChildren: [
              WidgetLayer(allowInteraction: true, markers: _buildMarkers()),
            ],
          ),
          // Custom popup card
          if (selectedPlace != null)
            Positioned(
              bottom: 24,
              left: 16,
              right: 16,
              child: _buildPopupCard(),
            ),
        ],
      ),
    );
  }

  List<Marker> _buildMarkers() {
    return places.map((place) {
      final position = place['position'] as Position;
      return Marker(
        point: position,
        size: const Size.square(36),
        alignment: Alignment.bottomCenter,
        child: GestureDetector(
          onTap: () {
            setState(() {
              selectedPlace = place;
            });
            // Center the map on the tapped marker
            mapController?.animateCamera(center: position);
          },
          child: const Icon(Icons.location_on, color: Colors.red, size: 36),
        ),
      );
    }).toList();
  }

  Widget _buildPopupCard() {
    return Card(
      elevation: 8,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
      child: Padding(
        padding: EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Expanded(
                  child: Text(
                    selectedPlace!['name'],
                    style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.close),
                  onPressed: () {
                    setState(() {
                      selectedPlace = null;
                    });
                  },
                ),
              ],
            ),
            SizedBox(height: 4),
            Row(
              children: [
                Icon(Icons.star, color: Colors.amber, size: 18),
                SizedBox(width: 4),
                Text('${selectedPlace!['rating']}'),
              ],
            ),
            SizedBox(height: 8),
            Text(
              selectedPlace!['description'],
              style: TextStyle(color: Colors.grey[700]),
            ),
            SizedBox(height: 12),
            Row(
              children: [
                ElevatedButton.icon(
                  onPressed: () {
                    // Handle directions action
                  },
                  icon: Icon(Icons.directions, size: 18),
                  label: Text('Directions'),
                ),
                SizedBox(width: 8),
                OutlinedButton.icon(
                  onPressed: () {
                    // Handle share action
                  },
                  icon: Icon(Icons.share, size: 18),
                  label: Text('Share'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Bottom Sheet Popup

Use a bottom sheet for more detailed information:

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

class BottomSheetPopupScreen extends StatefulWidget {
  @override
  _BottomSheetPopupScreenState createState() =>
      _BottomSheetPopupScreenState();
}

class _BottomSheetPopupScreenState extends State<BottomSheetPopupScreen> {
  MapController? mapController;

  final List<Map<String, dynamic>> landmarks = [
    {
      'id': 'eiffel',
      'name': 'Eiffel Tower',
      'address': 'Champ de Mars, 5 Av. Anatole France',
      'hours': 'Open 9:30 AM - 11:45 PM',
      'position': Position(2.2945, 48.8584),
    },
    {
      'id': 'arc',
      'name': 'Arc de Triomphe',
      'address': 'Place Charles de Gaulle',
      'hours': 'Open 10:00 AM - 10:30 PM',
      'position': Position(2.2950, 48.8738),
    },
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Bottom Sheet Popup')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(2.3000, 48.8600),
          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;
        },
        mapChildren: [
          WidgetLayer(
            allowInteraction: true,
            markers: landmarks.map((landmark) {
              final position = landmark['position'] as Position;
              return Marker(
                point: position,
                size: const Size.square(36),
                alignment: Alignment.bottomCenter,
                child: GestureDetector(
                  onTap: () => _showBottomSheet(landmark),
                  child: const Icon(
                    Icons.location_on,
                    color: Colors.red,
                    size: 36,
                  ),
                ),
              );
            }).toList(),
          ),
        ],
      ),
    );
  }

  void _showBottomSheet(Map<String, dynamic> landmark) {
    mapController?.animateCamera(center: landmark['position'] as Position);

    showModalBottomSheet(
      context: context,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
      ),
      builder: (context) {
        return Padding(
          padding: EdgeInsets.all(20),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Center(
                child: Container(
                  width: 40,
                  height: 4,
                  decoration: BoxDecoration(
                    color: Colors.grey[300],
                    borderRadius: BorderRadius.circular(2),
                  ),
                ),
              ),
              SizedBox(height: 16),
              Text(
                landmark['name'],
                style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
              ),
              SizedBox(height: 8),
              Row(
                children: [
                  Icon(Icons.location_on, size: 16, color: Colors.grey),
                  SizedBox(width: 4),
                  Text(landmark['address'],
                      style: TextStyle(color: Colors.grey[600])),
                ],
              ),
              SizedBox(height: 4),
              Row(
                children: [
                  Icon(Icons.access_time, size: 16, color: Colors.green),
                  SizedBox(width: 4),
                  Text(landmark['hours'],
                      style: TextStyle(color: Colors.green)),
                ],
              ),
              SizedBox(height: 16),
              SizedBox(
                width: double.infinity,
                child: ElevatedButton(
                  onPressed: () => Navigator.pop(context),
                  child: Text('Get Directions'),
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

Next Steps


Tip: For production apps, use the bottom sheet approach — it feels native on mobile and provides more space for content. Use the basic tap-to-show card for quick prototypes.