Map Widgets Getting Started
Add the levels rail and the itinerary form to a Wemap map, in Jetpack Compose or Views.
Overview
WemapMapWidgetsSDK is the map controls you would otherwise write yourself — the levels rail and the itinerary
form that plans a route across the map — styled the way the Wemap design is on the web, iOS and Flutter, and
driven by values rather than by the SDK's managers.
Requirements
See the common requirements in Getting started. The widgets ship as their own
artifact on a 0.x version line — its Map widgets section says how to add
them. They are Jetpack Compose inside, so like the Compose modules they need a Compose BOM of 2026.06.01 or
newer, even on a View-based screen.
Switching levels
The quickest way in is the connected rail, whose state holder wires the focused building and the active level for you. Place it over the map once the map has loaded:
import com.getwemap.sdk.map.widgets.levels.LevelsSwitcher
import com.getwemap.sdk.map.widgets.levels.rememberLevelsSwitcherState
@Composable
fun MapScreen(session: MapSession) {
var mapView by remember { mutableStateOf<WemapMapView?>(null) }
Box {
WemapMap(session = session, modifier = Modifier.fillMaxSize(), onLoaded = { mapView = it })
mapView?.let {
LevelsSwitcher(
state = rememberLevelsSwitcherState(it.buildingManager, it.locationManager),
modifier = Modifier.align(Alignment.CenterEnd).padding(16.dp)
)
}
}
}
The rail appears when the map focuses an indoor building and disappears when it loses focus. Tapping a level
switches the map to it, and anything else that switches level — a navigation crossing floors, a POI selected on
another one — moves the rail. Given the map's locationManager, it also marks the level the user is standing on with
a dot, which pulses while their position is current and becomes a hollow ring once it has gone stale. Leave the
manager out to leave the dot out.
Every widget is also a plain composable over values, so it can be placed anywhere, previewed, and driven from state you already have:
LevelsSwitcher(levels = building.levels, selectedLevel = level, onLevelSelected = { level = it })
Planning an itinerary
rememberItineraryFormState wires an ItineraryForm to the map and runs the whole flow behind it — resolving each
end, computing the itinerary, drawing it and framing it:
val formState = rememberItineraryFormState(mapView, onFailure = { failure -> showFailure(failure) })
Box {
WemapMap(session = session, modifier = Modifier.fillMaxSize(), onLoaded = { mapView = it })
ItineraryForm(state = formState, modifier = Modifier.align(Alignment.TopCenter).padding(16.dp))
MapPointPickerPin(state = formState, modifier = Modifier.align(Alignment.Center))
}
There is no compute button, because there is nothing to submit: the form is the request. Setting either end, flipping the wheelchair switch or swapping the two recomputes the itinerary and redraws it.
Each end is filled either from the user's position or by picking a point on the map, which replaces the card with
a MapPointPicker and shows the pin at the map's centre — which is why MapPointPickerPin sits beside the form
rather than inside it. Both ends can be picked by hand, so the form works on a screen with no location source
at all — the user's position is the shortcut, not the way in, and it resolves only while something on the screen
already has location running.
When the user arrives from a point of interest they already chose, seed the end they are heading for, and the itinerary is computed the moment the form appears:
val formState = rememberItineraryFormState(
mapView,
destination = ItineraryEndpoint(name = poi.name, coordinate = poi.coordinate)
)
Planning from an itinerary already on screen — a route back from where the last one arrived — goes through the
ItineraryEndpoint(destination, fallbackName) constructor, which takes either end of a computed Leg:
val formState = rememberItineraryFormState(
mapView,
destination = ItineraryEndpoint(leg.start, fallbackName = "Where I started")
)
The form draws no error of its own: an itinerary that could not be computed leaves a card looking exactly like one
nobody has asked anything of yet. Putting the failure on screen is your app's, in your app's own chrome, and
ItineraryFormFailure carries everything needed for that — what went wrong, and the action that asks the same
question again when there is one.
Framing the itinerary stops the map following the user, since the two cannot both hold. Pass
fitsCamera = falseto leave the camera where it is.
Views
View-based screens get the same widgets as views, which mirror the SDK's state and size themselves. Hand them the map's managers once it has loaded, since the managers do not exist before then:
mapView.awaitLoaded() // com.getwemap.sdk.core.awaitLoaded
levelsSwitcherView.buildingManager = mapView.buildingManager
levelsSwitcherView.userLocationManager = mapView.locationManager
ItineraryFormView is the itinerary form's counterpart:
itineraryFormView.mapView = mapView
itineraryFormView.onClose = { itineraryFormView.mapView = null }
Give ItineraryFormView the map's own bounds, not the card's: it draws the picker's pin at its centre, and the
pin has to be centred on the map. The card is placed inside it, at cardAlignment.
Dismissal is yours on this path, since nothing takes the form off screen for you: setting mapView back to null
removes the itinerary the form drew, unmarks the chosen ends and clears the two fields. Setting the rail's
buildingManager back to null stops it following the map, and it draws nothing.
Both views need a
ViewTreeLifecycleOwnerand aViewTreeSavedStateRegistryOwner, as any Compose view in a View hierarchy does — anActivityorFragmentprovides both.
Placement
A widget is a composable you place over the map yourself, so it goes wherever its modifier puts it. 16.dp in
from the edge you align it to is all most screens need. When that edge is already busy — your own locate-me
button, a bottom sheet, or MapLibre's logo and attribution button, which sit in the bottom-start corner — inset it
further:
LevelsSwitcher(
state = levelsState,
modifier = Modifier.align(Alignment.BottomEnd).padding(end = 8.dp, bottom = 88.dp)
)
On a View-based screen, the rail is placed like any other view in your layout, and the itinerary form's card by
ItineraryFormView.cardAlignment within the map's bounds.
Styling
A widget takes its colours, fonts and radii from the WidgetTheme around it, so one wrapper restyles every widget
on a screen at once:
WemapWidgetTheme(WidgetTheme(primaryColor = MaterialTheme.colorScheme.primary, surfaceCornerRadius = 20.dp)) {
Box {
WemapMap(session = session, modifier = Modifier.fillMaxSize(), onLoaded = { mapView = it })
mapView?.let { LevelsSwitcher(state = rememberLevelsSwitcherState(it.buildingManager)) }
}
}
The views take the same WidgetTheme through their theme property.
The widgets draw from WidgetTheme rather than from MaterialTheme, so a rail looks like the Wemap design
whatever the host app's colours. The defaults are light-only on purpose: a widget floats over map tiles whose style
the SDK does not control, so following the system appearance would put a dark card over a light map. Supply a
theme to go dark.
Pass a per-widget style — LevelsSwitcherStyle, ItineraryFormStyle, MapPointPickerStyle — when one control
needs to differ from the rest.