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
| Parameter | Purpose |
|---|---|
session | The session this map renders. Required. |
modifier | Standard Compose modifier. |
config | Rendering configuration. Read once, at creation. |
initialCamera | The camera the map opens at, instead of the map data's. Read once, at creation. |
cameraPositionState | Two-way camera state. See below. |
cameraUpdateFrequency | How often cameraPositionState is written back to. |
proxyUpdateFrequency | How often onProxyUpdate fires — ON_END (default) or CONTINUOUS. |
onLoaded | Invoked with the loaded map view. Its managers are available from here on. |
onFailed | Invoked with the loading error. |
onPhaseChange | Every LoadPhase transition, including the initial Loading. Drive a spinner with it. |
onTouch | Taps that selected no POI, as a point in the map's coordinate system. |
onProxyUpdate | A read-only MapProxy onto the camera and geometry, whenever the camera changes. |
factory | Builds 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
positiondoes 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 fromonLoadedwhen you want a camera flight. - Assigning
positionis a command to the map, not a local write. The map is the only writer ofposition, 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.CONTINUOUSwrites back on every frame of every gesture, recomposing every reader ofpositionwith 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 frombuildingManager's flows and permission handling viarememberLauncherForActivityResult.fragments/LevelsFragment.kt— a thinComposeViewhost, 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.