Map Widgets Getting Started
Add the levels rail and the itinerary form to a Wemap map, in SwiftUI or UIKit.
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, Android and Flutter, and driven by values rather than by the SDK's managers.
Requirements
See the common requirements in Getting Started with Wemap SDKs. The widgets ship from their own package on a 0.x version line,
Swift Package Manager only — its Map widgets section says how to add them. They need iOS 15.
Switching levels
The quickest way in is the modifier on Map, which wires the focused building and the active level
for you:
import WemapMapSDK
import WemapMapWidgetsSDK
Map(session: session)
.levelsSwitcher()
The rail also marks the level the user is standing on with a dot, which pulses while their position is being
updated and becomes a static ring once the map stops trusting it — the same moment the location indicator greys.
Pass showsUserLevel: false to leave it out.
Every widget is also a plain SwiftUI view over values, so it can be placed anywhere, previewed, and driven from state you already have:
LevelsSwitcher(levels: building?.levels ?? [], selection: $level, userLevels: userLevels)
Planning an itinerary
itineraryForm(isPresented:alignment:destination:travelMode:isWheelchairAccessible:itineraryOptions:fitsCamera:camera:style:pickerStyle:onItineraries:onFailure:)
puts an ItineraryForm over the map and wires the whole flow behind it — resolving each end,
computing the itinerary and drawing it:
Map(session: session)
.itineraryForm(isPresented: $planningItinerary)
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 a pin at the map's centre. 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:
Map(session: session)
.itineraryForm(
isPresented: $planningItinerary,
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
init(_:fallbackName:), which takes either end of a computed
Leg:
.itineraryForm(
isPresented: $planningItinerary,
destination: ItineraryEndpoint(leg.start, fallbackName: "Where I started")
)
If this screen binds the camera with camera(_:frequency:), pass that binding as
camera: — a bound camera is the live source of truth and SwiftUI re-applies it over the framing otherwise.
And a map that is following the user re-centres on the next fix, so stop following before you present the
form.
UIKit
UIKit screens get the same widgets through host controllers, which mirror the SDK's state and size themselves. Create them once the map has loaded, since the managers do not exist before then:
_ = try await mapView.awaitLoaded()
let levels = LevelsSwitcherController(
buildingManager: mapView.buildingManager, userLocationManager: mapView.userLocationManager
)
levels.attach(to: self, in: mapView, alignment: .trailing)
ItineraryFormController is the itinerary form's counterpart:
let form = ItineraryFormController(mapView: mapView, onClose: { [weak self] in self?.form?.detach() })
form.attach(to: self, in: mapView, alignment: .top)
attach(to:in:alignment:) places a widget exactly where the SwiftUI modifier places it —
edgeSpacing in from the container's safe area — and never gives the card a
height, which follows the form as it grows and shrinks. For any other placement, attach(to:in:) does the
child-controller bookkeeping alone and leaves the constraints to you.
Dismissal is yours on this path, since there is no isPresented to write:
detach() takes the card and the pin off the map and removes the
itinerary the form drew.
Placement
levelsSwitcher() puts the rail edgeSpacing from the edge you align it to,
which is all most screens need. When that edge is already busy — your own locate-me button, a bottom sheet, or
MapLibre's attribution button, which sits in the bottom-trailing corner — take the placement over with the closure
form and inset it yourself:
Map(session: session)
.levelsSwitcher(alignment: .bottomTrailing) { rail in
rail.padding(.trailing, 8).padding(.bottom, 88)
}
The closure receives the wired rail and returns whatever goes over the map, so it can decorate as well as inset;
edgeSpacing is the inset the default placement would have applied, if you want
to match it. itineraryForm has the same pair of entry points, and its closure receives whichever card is on
screen — the form, or the picker while a point is being chosen.
The host modifiers return the map, so they compose with each other and with the map's own modifiers in any order:
Map(session: session)
.levelsSwitcher()
.itineraryForm(isPresented: $planning)
.camera($camera)
Styling
A widget takes its colours, fonts and radii from the WidgetTheme in the environment, so
one modifier restyles every widget on a screen at once:
Map(session: session)
.levelsSwitcher()
.wemapWidgetTheme(WidgetTheme(primaryColor: .accentColor, surfaceCornerRadius: 20))
Pass a per-widget style — LevelsSwitcherStyle, ItineraryFormStyle or
MapPointPickerStyle — when one control
needs to differ from the rest.