Skip to content

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:

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


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.