Using the map in SwiftUI
Embed a Wemap map in a SwiftUI view, and bind its state to yours.
Overview
WemapMapSDK/Map is a SwiftUI view over the same WemapMapSDK/MapView the UIKit API exposes. Everything
imperative — navigation, points of interest, itineraries — is reached through the map view handed to onLoaded,
while the state you want to render is delivered through bindings.
import SwiftUI
import WemapMapSDK
struct MapScreen: View {
let session: MapSession
@State private var map: MapView?
@State private var trackingMode: MLNUserTrackingMode = .none
var body: some View {
VStack {
Map(session: session)
.onLoaded { map = $0 }
.onFailed { print("Failed to load the map: \($0)") }
.userTrackingMode($trackingMode)
Button("Locate me") {
trackingMode = .followWithHeading
}
}
}
}
Own the session above the view
A WemapMapSDK/MapSession loads a map's data once and is shared by the Wemap views and location sources of a
screen — that sharing is what keeps navigation, point-of-interest selection and the user location consistent
between them. So create it in app state, a router, or a view model, and pass it down. A session created inside a
view dies with the view and refetches its data over the network on every navigation.
- Important: One session serves one
WemapMapSDK/Mapand oneWemapGeoARSDK/GeoAR— that pairing is what it is for. Two maps on one session is not supported: they contend for a single renderer slot, the newer one wins, and the older stops receiving selection, itinerary and navigation updates while still drawing. Give the second map its own session. The SDK logs an error naming the fix if it happens.
struct MapContainer: View {
@State private var session: MapSession?
var body: some View {
if let session {
MapScreen(session: session)
} else {
ProgressView()
.task {
session = try? await MapSession(mapID: 19158, token: "YOUR_TOKEN")
}
}
}
}
One session can feed several views at once — a Map and a WemapGeoARSDK AR view, for instance. A map view is
bound to exactly one session for its whole life, so passing a different session rebuilds the map; the SDK keys
that itself and you never write .id(_:) for it.
The map view is a handle, not view state
The MapView from onLoaded exists so a control outside the map can call into the SDK. Nothing in your body
derives from it, which is why three rules apply:
- Declare the
@Statein the same view as theMap. In a parent that outlives a conditionally-shown map, it keeps the dismantled map view alive and delays its teardown until the parent goes. - Clear it when you change session. The old map view stays functional but is no longer on screen, so calls on it quietly affect nothing.
- In a bigger app, hold it
weakfrom a view model — starting a navigation is app logic, not view state. It is only valid while theMapis on screen, because nothing else retains the view.
Everything reached through the handle is non-optional: holding it at all proves the map finished loading.
Observing
Observation modifiers compose — applying one twice runs both handlers, so a wrapper view can add its own without stomping yours.
| Modifier | Delivers |
|---|---|
WemapMapSDK/Map/onLoaded(_:) | the loaded map view, once |
WemapMapSDK/Map/onFailed(_:) | the failure, if loading fails |
WemapMapSDK/Map/onPhaseChange(_:) | every WemapCoreSDK/LoadPhase transition |
WemapMapSDK/Map/onTouch(_:) | taps that did not select a point of interest |
WemapMapSDK/Map/onProxyUpdate(frequency:_:) | a WemapMapSDK/MapProxy when the camera changes |
WemapMapSDK/MapProxy is a read-only view onto the camera and geometry — coordinate conversion, visible
bounds, and whether the initial camera has been applied. It is snapshotted at access time, so read it when you
need it rather than storing it.
onProxyUpdate defaults to WemapMapSDK/MapUpdateFrequency/onEnd, which delivers once the map settles.
WemapMapSDK/MapUpdateFrequency/continuous also delivers during animation and scrolling, so a handler that
writes @State will re-evaluate your body on every frame — use it deliberately.
Binding
Two-way bindings keep your state and the map's in step, in both directions. Unlike the observation modifiers, a binding is single: applying one twice keeps only the last, because two bindings would be two sources of truth for the same write.
| Binding | Direction |
|---|---|
WemapMapSDK/Map/userTrackingMode(_:) | both — the SDK changes it too, see below |
WemapMapSDK/Map/activeLevel(_:) | both — nil when no building is focused |
WemapMapSDK/Map/selectedPOIs(_:) | both |
WemapMapSDK/Map/camera(_:frequency:) | both — see below |
WemapMapSDK/Map/focusedBuilding(_:) | map → your state |
WemapMapSDK/Map/navigationInfo(_:) | map → your state, reset to nil when the navigation stops |
userTrackingMode is the one to reach for first: it is the only value the SDK changes on its own, so a
hand-rolled round trip oscillates.
Map(session: session)
.focusedBuilding($building)
.activeLevel($level)
if let building {
Picker("Level", selection: $level) {
ForEach(building.levels, id: \.self) { level in
Text(level.name).tag(Level?.some(level))
}
}
.pickerStyle(.segmented)
}
Selecting through selectedPOIs never moves the camera — a binding write is state, not a user action. Call
WemapMapSDK/MapPointOfInterestManaging/centerToPOI(_:animated:zoom:) on the map view when you want the map to
move.
The camera
WemapMapSDK/MapCameraState is a value type — center, zoom, bearing, pitch, and why it last moved. Two states
are equal when they frame the same view of the world: lastChangeReason is excluded from equality, which is
what lets a camera you built compare equal to the same camera the map reported, so a two-way binding settles
instead of looping.
Only need an opening camera? Don't take a binding at all:
Map(session: session)
.initialCamera(MapCameraState(center: entrance, zoom: 18))
Need the camera as app state — restore it on launch, drive it from a search result, mirror it into a second view? Bind it:
@State private var camera = MapCameraState(center: entrance, zoom: 18)
Map(session: session)
.camera($camera) // .onEnd by default
A binding write applies without animation: it is state synchronization, not a user action. When you want a camera flight, do it explicitly through the map view:
.onLoaded { mapView in
mapView.setCamera(mapView.camera, animated: true)
}
A binding and initialCamera do not combine — a binding is the live source of truth and its first value opens the
map. Either way the opening camera replaces the one the map data declares.
Waiting for the camera
The camera can only be applied once the view has a real size, which in SwiftUI can happen after loading finishes. Await it rather than polling the view's bounds:
.onLoaded { mapView in
Task {
try? await mapView.awaitInitialCamera()
print("Opened at \(mapView.proxy.camera.center)")
}
}
Subclassing the map view
If you subclass WemapMapSDK/MapView, build it yourself:
Map(session: session) { frame, session, config in
MyMapView(frame: frame, session: session, config: config)
}
Naming
The view is called Map, following MapboxMaps.Map and MapKit.Map. If you import both MapKit and
WemapMapSDK without qualification, disambiguate with WemapMapSDK.Map.