Sync Multiple Maps in Flutter
This tutorial shows how to display two maps side by side and keep their camera positions synchronized so when you move one, the other follows.
Prerequisites
Before you begin, ensure you have:
- Completed the Flutter Setup Guide
- A MapMetrics API key and style ID from the MapMetrics Portal
A Note on Camera Sync
There's no onCameraMove/onCameraIdle callback pair on MapMetricsView. Camera changes are surfaced through the single onEvent stream as MapEventMoveCamera (carries a MapCamera with center, zoom, bearing, pitch) and MapEventCameraIdle. Listen for both and drive the other map's MapController.moveCamera(...).
Side-by-Side Synced Maps
Two maps with different styles, sharing the same camera position:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
const _initCenter = Position(2.3522, 48.8566); // lng, lat — Paris
const _initZoom = 13.0;
class SyncMapsScreen extends StatefulWidget {
@override
_SyncMapsScreenState createState() => _SyncMapsScreenState();
}
class _SyncMapsScreenState extends State<SyncMapsScreen> {
MapController? mapControllerA;
MapController? mapControllerB;
bool isSyncing = false; // Prevent infinite sync loops
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Synced Maps')),
body: Column(
children: [
// Labels
Row(
children: [
Expanded(
child: Container(
padding: EdgeInsets.all(8),
color: Colors.blue[50],
child: Text('Light Style',
textAlign: TextAlign.center,
style: TextStyle(fontWeight: FontWeight.bold)),
),
),
Expanded(
child: Container(
padding: EdgeInsets.all(8),
color: Colors.grey[800],
child: Text('Dark Style',
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold, color: Colors.white)),
),
),
],
),
// Maps
Expanded(
child: Row(
children: [
// Map A (Light)
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_LIGHT_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: _initCenter,
initZoom: _initZoom,
),
onMapCreated: (controller) => mapControllerA = controller,
onEvent: (event) => _handleEvent(event, isMapA: true),
),
),
// Divider
Container(width: 2, color: Colors.grey[400]),
// Map B (Dark)
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_DARK_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: _initCenter,
initZoom: _initZoom,
),
onMapCreated: (controller) => mapControllerB = controller,
onEvent: (event) => _handleEvent(event, isMapA: false),
),
),
],
),
),
],
),
);
}
void _handleEvent(MapEvent event, {required bool isMapA}) {
if (event case MapEventMoveCamera(camera: final camera)) {
_syncCamera(camera, toMapA: !isMapA);
} else if (event is MapEventCameraIdle) {
isSyncing = false;
}
}
void _syncCamera(MapCamera camera, {required bool toMapA}) {
if (isSyncing) return;
isSyncing = true;
final target = toMapA ? mapControllerA : mapControllerB;
target?.moveCamera(
center: camera.center,
zoom: camera.zoom,
bearing: camera.bearing,
pitch: camera.pitch,
);
}
}Stacked Comparison (Top/Bottom)
Compare two styles in a vertical layout:
Expanded(
child: Column(
children: [
// Map A (top half)
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_A/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: _initCenter,
initZoom: _initZoom,
),
onMapCreated: (controller) => mapControllerA = controller,
onEvent: (event) => _handleEvent(event, isMapA: true),
),
),
Container(height: 2, color: Colors.grey),
// Map B (bottom half)
Expanded(
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_STYLE_B/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: _initCenter,
initZoom: _initZoom,
),
onMapCreated: (controller) => mapControllerB = controller,
onEvent: (event) => _handleEvent(event, isMapA: false),
),
),
],
),
)Before/After Slider
A swipeable comparison with a divider line:
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BeforeAfterMapScreen extends StatefulWidget {
@override
_BeforeAfterMapScreenState createState() => _BeforeAfterMapScreenState();
}
class _BeforeAfterMapScreenState extends State<BeforeAfterMapScreen> {
MapController? mapControllerA;
MapController? mapControllerB;
double dividerPosition = 0.5; // 0.0 to 1.0
bool isSyncing = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Before / After')),
body: LayoutBuilder(
builder: (context, constraints) {
final dividerX = constraints.maxWidth * dividerPosition;
return GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() {
dividerPosition =
(details.localPosition.dx / constraints.maxWidth)
.clamp(0.15, 0.85);
});
},
child: Stack(
children: [
// Map B (full width, underneath)
MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_DARK_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(2.3522, 48.8566),
initZoom: 13.0,
),
onMapCreated: (controller) => mapControllerB = controller,
onEvent: (event) {
if (event case MapEventMoveCamera(camera: final camera)) {
if (!isSyncing) {
isSyncing = true;
mapControllerA?.moveCamera(
center: camera.center,
zoom: camera.zoom,
bearing: camera.bearing,
pitch: camera.pitch,
);
}
} else if (event is MapEventCameraIdle) {
isSyncing = false;
}
},
),
// Map A (clipped to left of divider)
ClipRect(
clipper: _LeftClipper(dividerX),
child: MapMetricsView(
options: MapOptions(
initStyle:
'https://gateway.mapmetrics-atlas.net/styles/?fileName=YOUR_LIGHT_STYLE_ID/YOUR_STYLE.json&token=YOUR_API_KEY',
initCenter: Position(2.3522, 48.8566),
initZoom: 13.0,
),
onMapCreated: (controller) => mapControllerA = controller,
onEvent: (event) {
if (event case MapEventMoveCamera(camera: final camera)) {
if (!isSyncing) {
isSyncing = true;
mapControllerB?.moveCamera(
center: camera.center,
zoom: camera.zoom,
bearing: camera.bearing,
pitch: camera.pitch,
);
}
} else if (event is MapEventCameraIdle) {
isSyncing = false;
}
},
),
),
// Divider line
Positioned(
left: dividerX - 2,
top: 0,
bottom: 0,
child: Container(
width: 4,
color: Colors.white,
child: Center(
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 4)],
),
child: Icon(Icons.drag_handle, size: 16),
),
),
),
),
],
),
);
},
),
);
}
}
class _LeftClipper extends CustomClipper<Rect> {
final double width;
_LeftClipper(this.width);
@override
Rect getClip(Size size) => Rect.fromLTWH(0, 0, width, size.height);
@override
bool shouldReclip(_LeftClipper oldClipper) => oldClipper.width != width;
}Next Steps
- Custom Map Styling — Create different styles to compare
- Fullscreen Map — Immersive single-map view
- Set Pitch and Bearing — Synced 3D views
Tip: Use the isSyncing flag to prevent infinite update loops where Map A updates Map B, which updates Map A again. Reset it on MapEventCameraIdle.