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 URL from the MapMetrics Portal
Side-by-Side Synced Maps
Two maps with different styles, sharing the same camera position:
dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class SyncMapsScreen extends StatefulWidget {
@override
_SyncMapsScreenState createState() => _SyncMapsScreenState();
}
class _SyncMapsScreenState extends State<SyncMapsScreen> {
MapMetricsController? mapControllerA;
MapMetricsController? mapControllerB;
bool isSyncing = false; // Prevent infinite sync loops
final CameraPosition initialPosition = CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 13.0,
);
@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: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_LIGHT_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerA = controller,
onCameraMove: (position) => _syncToB(position),
onCameraIdle: () => isSyncing = false,
initialCameraPosition: initialPosition,
),
),
// Divider
Container(width: 2, color: Colors.grey[400]),
// Map B (Dark)
Expanded(
child: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_DARK_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerB = controller,
onCameraMove: (position) => _syncToA(position),
onCameraIdle: () => isSyncing = false,
initialCameraPosition: initialPosition,
),
),
],
),
),
],
),
);
}
void _syncToB(CameraPosition position) {
if (isSyncing) return;
isSyncing = true;
mapControllerB?.moveCamera(
CameraUpdate.newCameraPosition(position),
);
}
void _syncToA(CameraPosition position) {
if (isSyncing) return;
isSyncing = true;
mapControllerA?.moveCamera(
CameraUpdate.newCameraPosition(position),
);
}
}Stacked Comparison (Top/Bottom)
Compare two styles in a vertical layout:
dart
Expanded(
child: Column(
children: [
// Map A (top half)
Expanded(
child: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_A?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerA = controller,
onCameraMove: (position) => _syncToB(position),
initialCameraPosition: initialPosition,
),
),
Container(height: 2, color: Colors.grey),
// Map B (bottom half)
Expanded(
child: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_STYLE_B?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerB = controller,
onCameraMove: (position) => _syncToA(position),
initialCameraPosition: initialPosition,
),
),
],
),
)Before/After Slider
A swipeable comparison with a divider line:
dart
import 'package:flutter/material.dart';
import 'package:mapmetrics/mapmetrics.dart';
class BeforeAfterMapScreen extends StatefulWidget {
@override
_BeforeAfterMapScreenState createState() => _BeforeAfterMapScreenState();
}
class _BeforeAfterMapScreenState extends State<BeforeAfterMapScreen> {
MapMetricsController? mapControllerA;
MapMetricsController? 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)
MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_DARK_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerB = controller,
onCameraMove: (pos) {
if (!isSyncing) {
isSyncing = true;
mapControllerA?.moveCamera(
CameraUpdate.newCameraPosition(pos));
}
},
onCameraIdle: () => isSyncing = false,
initialCameraPosition: CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 13.0,
),
),
// Map A (clipped to left of divider)
ClipRect(
clipper: _LeftClipper(dividerX),
child: MapMetrics(
styleUrl: 'https://gateway.mapmetrics.org/styles/YOUR_LIGHT_STYLE_ID?token=YOUR_API_KEY',
onMapCreated: (controller) => mapControllerA = controller,
onCameraMove: (pos) {
if (!isSyncing) {
isSyncing = true;
mapControllerB?.moveCamera(
CameraUpdate.newCameraPosition(pos));
}
},
onCameraIdle: () => isSyncing = false,
initialCameraPosition: CameraPosition(
target: LatLng(48.8566, 2.3522),
zoom: 13.0,
),
),
),
// 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 onCameraIdle.