Skip to main content
Version: 1.x (beta)

MapSDK with Jetpack Compose

WemapMapComposeSDK adds one composable, WemapMap, over WemapMapSDK's WemapMapView. It is the Compose counterpart of Map in the iOS SDK's SwiftUI layer.

For the View-based API — and for creating a session, which is identical either way — see MapSDK Getting Started.

Installation

Compose support is a separate artifact, so View-based apps are never made to depend on the Compose runtime:

implementation("com.getwemap.sdk:map-compose:<version>")

It brings com.getwemap.sdk:map with it. Requirements beyond the Map SDK's own: a Compose BOM of 2026.06.01 or newer. No compileSdk or AGP bump is needed.

The shape of the API

There is one composable and no parallel Compose API. WemapMap hands you the loaded WemapMapView, and every manager, the camera, navigation and POI selection are reached through it exactly as in a View-based app:

@Composable
fun MapScreen(session: MapSession) {

var mapView by remember { mutableStateOf<WemapMapView?>(null) }

WemapMap(
session = session,
modifier = Modifier.fillMaxSize(),
onLoaded = { mapView = it },
onFailed = { error -> Log.e("Map", "Failed to load the map", error) }
)

Button(onClick = { mapView?.pointOfInterestManager?.hideAllPois() }) {
Text("Hide all POIs")
}
}

Callbacks are parameters, not modifiers — that is the Compose idiom. One consequence to know: a parameter is single-valued, so unlike the iOS .onLoaded { } modifier there is no multicast. A wrapper composable that needs to add its own handler composes the lambdas by hand.

Parameters

ParameterPurpose
sessionThe session this map renders. Required.
modifierStandard Compose modifier.
configRendering configuration. Read once, at creation.
initialCameraThe camera the map opens at, instead of the map data's. Read once, at creation.
cameraPositionStateTwo-way camera state. See below.
cameraUpdateFrequencyHow often cameraPositionState is written back to.
proxyUpdateFrequencyHow often onProxyUpdate fires — ON_END (default) or CONTINUOUS.
onLoadedInvoked with the loaded map view. Its managers are available from here on.
onFailedInvoked with the loading error.
onPhaseChangeEvery LoadPhase transition, including the initial Loading. Drive a spinner with it.
onTouchTaps that selected no POI, as a point in the map's coordinate system.
onProxyUpdateA read-only MapProxy onto the camera and geometry, whenever the camera changes.
factoryBuilds the view, for integrators with a WemapMapView subclass. Do not call configure in it.

There are no parameters for things that are already flows

The SDK's manager callbacks are Flows, and Compose reads a flow directly — so there is deliberately no focusedBuilding or navigationInfo parameter:

val building by (mapView?.buildingManager?.focusedBuildings ?: emptyFlow())
.collectAsStateWithLifecycle(null)

Two-way camera state

Pass a MapCameraPositionState when the camera is app state — something to persist, restore, drive from a search result, or mirror into another view. Omit it otherwise: reading the camera does not need it (onProxyUpdate, or the view's own cameraStates).

val cameraState = rememberMapCameraPositionState {
position = MapCameraState(LatLng(48.88, 2.35), zoom = 18.0) // optional; omit for the map data's camera
}

WemapMap(session = session, cameraPositionState = cameraState)

Text("zoom ${cameraState.position?.zoom}")
Button(onClick = { cameraState.position = MapCameraState(searchResult, zoom = 19.0) }) { Text("Go") }

rememberMapCameraPositionState is rememberSaveable-backed, so the camera survives configuration changes and process death. When the state carries a position it also becomes the map's opening camera, and it wins over the initialCamera parameter — a two-way state is the live source of truth.

position is null until the map reports its first camera, because a map has no meaningful camera before it has been laid out. Leaving it null means "open where the map data says".

Three behaviours worth knowing:

  • Assigning position does not animate. A state write is synchronisation, not a user action; animating it would stream intermediate frames back into your state. Animate deliberately through the map view from onLoaded when you want a camera flight.
  • Assigning position is a command to the map, not a local write. The map is the only writer of position, which is what makes a feedback loop between your state and the map impossible. It also means the value you read back is whatever the map actually did — if it clamps your zoom to the map's limits, you see the clamped value.
  • Prefer the default cameraUpdateFrequency. MapUpdateFrequency.CONTINUOUS writes back on every frame of every gesture, recomposing every reader of position with it.

Session ownership

Hoist the session into a ViewModel, never a remember. It is network-loaded and cannot go into a Bundle, so a remember drops it on rotation. Call session.deinit() from that owner:

class MapScreenViewModel : ViewModel() {
var session: MapSession? = null
override fun onCleared() { session?.deinit() }
}

One session per screen, shared with the screen's GeoARView and location sources — that shared instance is what keeps navigation, POI selection and user location consistent.

Passing a different session tears the map down and builds a new one, because a view's session is set once. The map view is also destroyed automatically when it leaves composition.

Lifecycle

You forward nothing. WemapMapView discovers the ViewTreeLifecycleOwner when it attaches and drives MapLibre's lifecycle itself, and leaving composition tears it down.

The one case that needs attention is a host with no lifecycle owner on the view tree — a plain ComposeView inside a Dialog, for instance. The view logs a warning; set a lifecycle owner on the view tree, or assign mapView.lifecycle yourself.

Two things that will bite you

onLoaded is not replayed

Supply the callbacks from the first composition. A handler first passed after loading already settled is not invoked retroactively — the subscription is established once per view instance and reads the handlers indirectly, which is what stops onLoaded firing again on every recomposition. For the current state at any time, read mapView.loadPhase or collect loadPhases.

Mirroring an event flow into state

Flows that carry discrete events (activeLevelChanges, selectionUpdates, touchedPois, every errors) replay nothing to a new collector, so rendering from one means mirroring it into state and seeding that state from somewhere else. Use produceState, and be careful not to reach for a keyed remember plus a LaunchedEffect:

// ✅ one state object; the producer re-seeds it when a key changes
val activeLevel by produceState<Level?>(building?.activeLevel, building, buildingManager) {
value = building?.activeLevel
buildingManager.activeLevelChanges.collect { (_, level) -> value = level }
}

// ❌ silently stale: the keyed `remember` hands out a NEW MutableState when `building` changes, while the
// effect keeps writing to the one it captured on its first pass. The map switches levels, the UI does not.
var activeLevel by remember(building) { mutableStateOf(building?.activeLevel) }
LaunchedEffect(buildingManager) {
buildingManager.activeLevelChanges.collect { (_, level) -> activeLevel = level }
}

StateFlow-backed values (focusedBuildings, loadPhases, VPS states) do not have this problem — they replay, so collectAsStateWithLifecycle() is all they need.

A complete sample

examples/map ships the levels sample written in Compose:

  • compose/LevelsScreen.kt — the whole screen, including a Compose levels switcher built from buildingManager's flows and permission handling via rememberLauncherForActivityResult.
  • fragments/LevelsFragment.kt — a thin ComposeView host, present only because that app navigates with a nav graph.

The other sample screens in the same app are still View-based, so the two can be compared side by side.