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

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/Map and one WemapGeoARSDK/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 @State in the same view as the Map. 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 weak from a view model — starting a navigation is app logic, not view state. It is only valid while the Map is 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.

ModifierDelivers
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.

BindingDirection
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.