Skip to content

Sync Multiple Maps Side by Side

This tutorial shows how to display two MapMetrics maps side by side and keep their camera positions synchronized — useful for comparing map styles, before/after views, or satellite vs. street map.

Prerequisites

Synchronized Dual Maps

Create two maps that stay in sync:

kotlin
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.MapMetricsMap
import org.maplibre.android.maps.Style

class SyncMapsActivity : AppCompatActivity() {

    private lateinit var mapViewLeft: MapView
    private lateinit var mapViewRight: MapView
    private var mapLeft: MapMetricsMap? = null
    private var mapRight: MapMetricsMap? = null

    private var isSyncing = false // prevent infinite loop

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_sync_maps)

        mapViewLeft = findViewById(R.id.mapViewLeft)
        mapViewRight = findViewById(R.id.mapViewRight)

        mapViewLeft.onCreate(savedInstanceState)
        mapViewRight.onCreate(savedInstanceState)

        // Initialize left map
        mapViewLeft.getMapAsync { map ->
            mapLeft = map
            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics.org/styles/STYLE_A?token=YOUR_API_KEY"
                )
            )
            map.cameraPosition = CameraPosition.Builder()
                .target(LatLng(48.8566, 2.3522))
                .zoom(12.0)
                .build()

            setupSync()
        }

        // Initialize right map
        mapViewRight.getMapAsync { map ->
            mapRight = map
            map.setStyle(
                Style.Builder().fromUri(
                    "https://gateway.mapmetrics.org/styles/STYLE_B?token=YOUR_API_KEY"
                )
            )
            map.cameraPosition = CameraPosition.Builder()
                .target(LatLng(48.8566, 2.3522))
                .zoom(12.0)
                .build()

            setupSync()
        }
    }

    private fun setupSync() {
        // Only set up once both maps are ready
        if (mapLeft == null || mapRight == null) return

        // Sync left → right
        mapLeft?.addOnCameraMoveListener {
            if (isSyncing) return@addOnCameraMoveListener
            isSyncing = true
            mapLeft?.cameraPosition?.let { pos ->
                mapRight?.moveCamera(
                    CameraUpdateFactory.newCameraPosition(pos)
                )
            }
            isSyncing = false
        }

        // Sync right → left
        mapRight?.addOnCameraMoveListener {
            if (isSyncing) return@addOnCameraMoveListener
            isSyncing = true
            mapRight?.cameraPosition?.let { pos ->
                mapLeft?.moveCamera(
                    CameraUpdateFactory.newCameraPosition(pos)
                )
            }
            isSyncing = false
        }
    }

    // Lifecycle — must forward to BOTH map views
    override fun onStart() {
        super.onStart()
        mapViewLeft.onStart()
        mapViewRight.onStart()
    }

    override fun onResume() {
        super.onResume()
        mapViewLeft.onResume()
        mapViewRight.onResume()
    }

    override fun onPause() {
        super.onPause()
        mapViewLeft.onPause()
        mapViewRight.onPause()
    }

    override fun onStop() {
        super.onStop()
        mapViewLeft.onStop()
        mapViewRight.onStop()
    }

    override fun onDestroy() {
        super.onDestroy()
        mapViewLeft.onDestroy()
        mapViewRight.onDestroy()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        mapViewLeft.onSaveInstanceState(outState)
        mapViewRight.onSaveInstanceState(outState)
    }
}

Layout (res/layout/activity_sync_maps.xml):

xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal">

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapViewLeft"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />

    <View
        android:layout_width="2dp"
        android:layout_height="match_parent"
        android:background="#333333" />

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapViewRight"
        android:layout_width="0dp"
        android:layout_height="match_parent"
        android:layout_weight="1" />

</LinearLayout>

Vertical Split (Top/Bottom)

For portrait orientation, stack maps vertically:

xml
<LinearLayout
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapViewTop"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <View
        android:layout_width="match_parent"
        android:layout_height="2dp"
        android:background="#333333" />

    <org.maplibre.android.maps.MapView
        android:id="@+id/mapViewBottom"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

</LinearLayout>

Style Comparison Use Cases

Left MapRight MapPurpose
StreetsSatelliteTerrain comparison
Light themeDark themeTheme preview
Current dataHistorical dataTime comparison
Default styleCustom styleStyle development

Next Steps


Tip: The isSyncing flag is critical — without it, map A's move triggers map B's listener, which triggers map A's listener, creating an infinite loop. Always guard against re-entrant sync.