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

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:

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:

dart
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:

dart
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:

dart
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 ​


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.