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

Map Interactions with Flutter and MapMetrics ​

This tutorial will show you how to handle various map interactions, events, and user gestures in your Flutter MapMetrics applications.

How Events Work ​

MapMetricsView has no per-gesture callbacks like onMapClick or onCameraMove. Instead there is a single onEvent: (MapEvent event) callback, and you switch on the event's runtime type (MapEvent is a sealed class) with Dart's case pattern matching:

dart
onEvent: (event) {
  if (event case MapEventClick()) {
    // event.point is a Position(lng, lat)
  } else if (event case MapEventLongClick()) {
    // event.point
  } else if (event case MapEventMoveCamera()) {
    // event.camera is a MapCamera(center, zoom, bearing, pitch)
  } else if (event case MapEventCameraIdle()) {
    // camera has stopped moving
  }
}

onMapCreated and onStyleLoaded remain separate, dedicated callbacks on MapMetricsView — only gesture/camera/lifecycle events route through onEvent.

Basic Map Interactions ​

Here's a comprehensive example of handling different map interactions:

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

class MapInteractionsScreen extends StatefulWidget {
  @override
  _MapInteractionsScreenState createState() => _MapInteractionsScreenState();
}

class _MapInteractionsScreenState extends State<MapInteractionsScreen> {
  MapController? mapController;
  Position? lastTappedLocation;
  Position? lastLongPressedLocation;
  MapCamera? currentCamera;
  bool isMapMoving = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Map Interactions'),
        actions: [
          IconButton(
            icon: Icon(Icons.info),
            onPressed: _showInteractionInfo,
          ),
        ],
      ),
      body: Column(
        children: [
          // Interaction status panel
          Container(
            padding: EdgeInsets.all(16),
            color: Colors.grey[100],
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'Map Status',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 8),
                Text('Moving: ${isMapMoving ? "Yes" : "No"}'),
                if (currentCamera != null)
                  Text('Zoom: ${currentCamera!.zoom.toStringAsFixed(2)}'),
                if (lastTappedLocation != null)
                  Text('Last Tap: ${lastTappedLocation!.lat.toStringAsFixed(4)}, ${lastTappedLocation!.lng.toStringAsFixed(4)}'),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(-74.0060, 40.7128), // lng, lat — New York
                initZoom: 12,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (MapController controller) {
                setState(() {
                  mapController = controller;
                });
              },
              onStyleLoaded: (StyleController style) {
                print('Map style loaded successfully!');
              },
              onEvent: (event) {
                switch (event) {
                  case MapEventClick():
                    setState(() {
                      lastTappedLocation = event.point;
                    });
                    _handleMapClick(event.point);
                  case MapEventLongClick():
                    setState(() {
                      lastLongPressedLocation = event.point;
                    });
                    _handleMapLongClick(event.point);
                  case MapEventMoveCamera():
                    setState(() {
                      currentCamera = event.camera;
                      isMapMoving = true;
                    });
                  case MapEventCameraIdle():
                    setState(() {
                      isMapMoving = false;
                    });
                    _handleCameraIdle();
                  default:
                    break;
                }
              },
            ),
          ),
        ],
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton.small(
            heroTag: "reset",
            onPressed: _resetMap,
            child: Icon(Icons.refresh),
          ),
          SizedBox(height: 8),
          FloatingActionButton.small(
            heroTag: "fit",
            onPressed: _fitToBounds,
            child: Icon(Icons.fit_screen),
          ),
        ],
      ),
    );
  }

  void _handleMapClick(Position coordinates) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Map Clicked'),
        content: Text(
          'Latitude: ${coordinates.lat.toStringAsFixed(6)}\n'
          'Longitude: ${coordinates.lng.toStringAsFixed(6)}',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('OK'),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              _addMarkerAtLocation(coordinates);
            },
            child: Text('Add Marker'),
          ),
        ],
      ),
    );
  }

  void _handleMapLongClick(Position coordinates) {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Map Long Pressed'),
        content: Text(
          'Latitude: ${coordinates.lat.toStringAsFixed(6)}\n'
          'Longitude: ${coordinates.lng.toStringAsFixed(6)}',
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('OK'),
          ),
          TextButton(
            onPressed: () {
              Navigator.pop(context);
              _flyToLocation(coordinates);
            },
            child: Text('Fly To'),
          ),
        ],
      ),
    );
  }

  void _handleCameraIdle() {
    print('Camera stopped moving');
    // You can perform actions when the map stops moving
    // For example, load data for the current viewport
  }

  void _addMarkerAtLocation(Position coordinates) {
    // See "Markers and Annotations" for adding a WidgetLayer marker here
    print('Adding marker at: $coordinates');
  }

  void _flyToLocation(Position coordinates) {
    mapController?.animateCamera(center: coordinates, zoom: 15);
  }

  void _resetMap() {
    mapController?.animateCamera(
      center: Position(-74.0060, 40.7128),
      zoom: 12,
    );
  }

  void _fitToBounds() {
    mapController?.fitBounds(
      bounds: const LngLatBounds(
        longitudeWest: -74.1,
        longitudeEast: -73.9,
        latitudeSouth: 40.7,
        latitudeNorth: 40.8,
      ),
    );
  }

  void _showInteractionInfo() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Interaction Guide'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('• Tap: Show location info'),
            Text('• Long Press: Fly to location'),
            Text('• Drag: Pan the map'),
            Text('• Pinch: Zoom in/out'),
            Text('• Double Tap: Zoom in'),
          ],
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('OK'),
          ),
        ],
      ),
    );
  }
}

Gesture Handling ​

Enabling and Disabling Gestures ​

The real API controls which gestures the map responds to declaratively, via MapOptions.gestures, not by toggling individual callback wiring. Pass a MapGestures to enable/disable rotate, pan, zoom, and pitch independently:

dart
class GestureHandlingScreen extends StatefulWidget {
  @override
  _GestureHandlingScreenState createState() => _GestureHandlingScreenState();
}

class _GestureHandlingScreenState extends State<GestureHandlingScreen> {
  MapController? mapController;
  bool isGestureEnabled = true;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Gesture Handling'),
        actions: [
          Switch(
            value: isGestureEnabled,
            onChanged: (value) {
              setState(() {
                isGestureEnabled = value;
              });
            },
          ),
        ],
      ),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-74.0060, 40.7128),
          initZoom: 12,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
          gestures: MapGestures(
            pan: isGestureEnabled,
            zoom: isGestureEnabled,
            rotate: isGestureEnabled,
            pitch: isGestureEnabled,
          ),
        ),
        onMapCreated: (controller) => mapController = controller,
        onEvent: (event) {
          if (!isGestureEnabled) return;
          if (event case MapEventClick()) {
            _handleClick(event.point);
          } else if (event case MapEventLongClick()) {
            _handleLongClick(event.point);
          }
        },
      ),
    );
  }

  void _handleClick(Position coordinates) {
    print('Map clicked at: $coordinates');
  }

  void _handleLongClick(Position coordinates) {
    print('Map long clicked at: $coordinates');
  }
}

MapOptions is rebuilt whenever isGestureEnabled changes, which pushes the new MapGestures down to the native map — see GesturesPage in the SDK's own example app for the same pattern with individually-toggleable gesture chips.

Camera Controls ​

Advanced Camera Operations ​

There is no CameraUpdate.zoomIn() / .zoomOut() / .rotateBy() / .tiltTo() factory in the real API. Read the current state with controller.getCamera() (synchronous) and pass the adjusted values to animateCamera:

dart
class CameraControlsScreen extends StatefulWidget {
  @override
  _CameraControlsScreenState createState() => _CameraControlsScreenState();
}

class _CameraControlsScreenState extends State<CameraControlsScreen> {
  MapController? mapController;
  MapCamera? currentCamera;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Camera Controls')),
      body: Stack(
        children: [
          MapMetricsView(
            options: MapOptions(
              initCenter: Position(-74.0060, 40.7128),
              initZoom: 12,
              initStyle:
                  'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
            ),
            onMapCreated: (controller) => mapController = controller,
            onEvent: (event) {
              if (event case MapEventMoveCamera()) {
                setState(() {
                  currentCamera = event.camera;
                });
              }
            },
          ),
          // Camera controls overlay
          Positioned(
            top: 20,
            right: 20,
            child: Column(
              children: [
                FloatingActionButton.small(
                  heroTag: "zoomIn",
                  onPressed: _zoomIn,
                  child: Icon(Icons.add),
                ),
                SizedBox(height: 8),
                FloatingActionButton.small(
                  heroTag: "zoomOut",
                  onPressed: _zoomOut,
                  child: Icon(Icons.remove),
                ),
                SizedBox(height: 8),
                FloatingActionButton.small(
                  heroTag: "rotate",
                  onPressed: _rotateMap,
                  child: Icon(Icons.rotate_right),
                ),
                SizedBox(height: 8),
                FloatingActionButton.small(
                  heroTag: "tilt",
                  onPressed: _tiltMap,
                  child: Icon(Icons.view_in_ar),
                ),
              ],
            ),
          ),
          // Camera info overlay
          Positioned(
            bottom: 20,
            left: 20,
            child: Container(
              padding: EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.white.withValues(alpha: 0.9),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  if (currentCamera != null) ...[
                    Text('Zoom: ${currentCamera!.zoom.toStringAsFixed(2)}'),
                    Text('Bearing: ${currentCamera!.bearing.toStringAsFixed(1)}°'),
                    Text('Pitch: ${currentCamera!.pitch.toStringAsFixed(1)}°'),
                  ],
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }

  void _zoomIn() {
    final camera = mapController?.getCamera();
    if (camera == null) return;
    mapController?.animateCamera(zoom: camera.zoom + 1);
  }

  void _zoomOut() {
    final camera = mapController?.getCamera();
    if (camera == null) return;
    mapController?.animateCamera(zoom: camera.zoom - 1);
  }

  void _rotateMap() {
    final camera = mapController?.getCamera();
    if (camera == null) return;
    final newBearing = (camera.bearing + 45) % 360;
    mapController?.animateCamera(bearing: newBearing);
  }

  void _tiltMap() {
    final camera = mapController?.getCamera();
    if (camera == null) return;
    final newPitch = camera.pitch > 0 ? 0.0 : 45.0;
    mapController?.animateCamera(pitch: newPitch);
  }
}

Event Handling ​

Comprehensive Event Management ​

dart
class EventHandlingScreen extends StatefulWidget {
  @override
  _EventHandlingScreenState createState() => _EventHandlingScreenState();
}

class _EventHandlingScreenState extends State<EventHandlingScreen> {
  MapController? mapController;
  List<String> eventLog = [];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Event Handling'),
        actions: [
          IconButton(
            icon: Icon(Icons.clear),
            onPressed: _clearEventLog,
          ),
        ],
      ),
      body: Column(
        children: [
          // Event log
          Container(
            height: 200,
            padding: EdgeInsets.all(16),
            color: Colors.grey[100],
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(
                  'Event Log',
                  style: TextStyle(fontWeight: FontWeight.bold),
                ),
                SizedBox(height: 8),
                Expanded(
                  child: ListView.builder(
                    itemCount: eventLog.length,
                    itemBuilder: (context, index) {
                      return Text(
                        eventLog[eventLog.length - 1 - index],
                        style: TextStyle(fontSize: 12),
                      );
                    },
                  ),
                ),
              ],
            ),
          ),
          // Map
          Expanded(
            child: MapMetricsView(
              options: MapOptions(
                initCenter: Position(-74.0060, 40.7128),
                initZoom: 12,
                initStyle:
                    'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
              ),
              onMapCreated: (controller) {
                mapController = controller;
                _logEvent('Map created');
              },
              onStyleLoaded: (style) {
                _logEvent('Style loaded');
              },
              onEvent: (event) {
                switch (event) {
                  case MapEventClick():
                    _logEvent('Map clicked at ${event.point.lat.toStringAsFixed(4)}, ${event.point.lng.toStringAsFixed(4)}');
                  case MapEventLongClick():
                    _logEvent('Map long clicked at ${event.point.lat.toStringAsFixed(4)}, ${event.point.lng.toStringAsFixed(4)}');
                  case MapEventMoveCamera():
                    _logEvent('Camera moving - Zoom: ${event.camera.zoom.toStringAsFixed(2)}');
                  case MapEventCameraIdle():
                    _logEvent('Camera idle');
                  default:
                    break;
                }
              },
            ),
          ),
        ],
      ),
    );
  }

  void _logEvent(String event) {
    setState(() {
      final timestamp = DateTime.now().toString().substring(11, 19);
      eventLog.add('[$timestamp] $event');
      
      // Keep only last 50 events
      if (eventLog.length > 50) {
        eventLog.removeAt(0);
      }
    });
  }

  void _clearEventLog() {
    setState(() {
      eventLog.clear();
    });
  }
}

There is no onError callback on MapMetricsView. Native errors (for example, a failed animateCamera cancelled mid-flight) surface as a thrown PlatformException from the specific MapController call you awaited — wrap individual controller calls in try/catch rather than listening for a global error event (see the ControllerPage example in the SDK repo).

Performance Optimization ​

Debounced Interactions ​

dart
import 'dart:async';

class OptimizedInteractionsScreen extends StatefulWidget {
  @override
  _OptimizedInteractionsScreenState createState() => _OptimizedInteractionsScreenState();
}

class _OptimizedInteractionsScreenState extends State<OptimizedInteractionsScreen> {
  MapController? mapController;
  Timer? _debounceTimer;
  MapCamera? lastCamera;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Optimized Interactions')),
      body: MapMetricsView(
        options: MapOptions(
          initCenter: Position(-74.0060, 40.7128),
          initZoom: 12,
          initStyle:
              'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
        ),
        onMapCreated: (controller) => mapController = controller,
        onEvent: (event) {
          if (event case MapEventMoveCamera()) {
            _debouncedCameraMove(event.camera);
          } else if (event case MapEventClick()) {
            _debouncedMapClick(event.point);
          }
        },
      ),
    );
  }

  void _debouncedCameraMove(MapCamera camera) {
    // Cancel previous timer
    _debounceTimer?.cancel();
    
    // Set new timer
    _debounceTimer = Timer(Duration(milliseconds: 300), () {
      _handleCameraMove(camera);
    });
  }

  void _debouncedMapClick(Position coordinates) {
    // Cancel previous timer
    _debounceTimer?.cancel();
    
    // Set new timer
    _debounceTimer = Timer(Duration(milliseconds: 100), () {
      _handleMapClick(coordinates);
    });
  }

  void _handleCameraMove(MapCamera camera) {
    // Only process if position changed significantly
    if (lastCamera == null ||
        (camera.zoom - lastCamera!.zoom).abs() > 0.5 ||
        _distance(camera.center, lastCamera!.center) > 0.001) {
      
      lastCamera = camera;
      print('Camera moved to: ${camera.center}, zoom: ${camera.zoom}');
      
      // Perform expensive operations here
      _loadDataForViewport(camera);
    }
  }

  void _handleMapClick(Position coordinates) {
    print('Map clicked at: $coordinates');
    // Perform click-related operations
  }

  void _loadDataForViewport(MapCamera camera) {
    // Simulate loading data for the current viewport
    print('Loading data for viewport...');
  }

  double _distance(Position a, Position b) {
    final latDiff = a.lat - b.lat;
    final lngDiff = a.lng - b.lng;
    return sqrt(latDiff * latDiff + lngDiff * lngDiff);
  }

  @override
  void dispose() {
    _debounceTimer?.cancel();
    super.dispose();
  }
}

Best Practices ​

Interaction Guidelines ​

  1. Debounce Frequent Events: Use timers to debounce MapEventMoveCamera events
  2. Batch Operations: Group related operations to improve performance
  3. Error Handling: Wrap individual MapController calls in try/catch — there's no global onError callback
  4. Memory Management: Cancel timers in dispose()
  5. User Feedback: Provide visual feedback for user interactions

Common Patterns ​

dart
// Pattern 1: State-based interactions, driven by the single onEvent callback
class StateBasedInteractions {
  bool isMapReady = false;
  bool isUserInteracting = false;

  void onMapCreated(MapController controller) {
    isMapReady = true;
    // Enable interactions
  }

  void onEvent(MapEvent event) {
    switch (event) {
      case MapEventMoveCamera():
        isUserInteracting = true;
        // Handle movement
      case MapEventCameraIdle():
        isUserInteracting = false;
        // Perform final actions
      default:
        break;
    }
  }
}

// Pattern 2: Event-driven architecture, re-broadcasting MapEvent on a Stream
class EventDrivenInteractions {
  final StreamController<MapEvent> _eventController = StreamController.broadcast();

  Stream<MapEvent> get events => _eventController.stream;

  void onEvent(MapEvent event) => _eventController.add(event);
}

Next Steps ​

Now that you understand map interactions, try:


Pro Tip: Use debouncing for MapEventMoveCamera events to improve performance when handling large datasets or performing expensive operations based on map position.