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

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")
)
Important

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.

note

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.