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.