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:
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:
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:
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:
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
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
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
- Debounce Frequent Events: Use timers to debounce
MapEventMoveCameraevents - Batch Operations: Group related operations to improve performance
- Error Handling: Wrap individual
MapControllercalls intry/catch— there's no globalonErrorcallback - Memory Management: Cancel timers in
dispose() - User Feedback: Provide visual feedback for user interactions
Common Patterns
// 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.