Swift SDK
MapAtlasGeocoder is a Swift client for the v2 geocoding API — autocomplete, place retrieval, and forward/reverse geocoding — for iOS and macOS. Foundation only, no third-party dependencies, async/await throughout.
Not published yet
Not on the Swift Package Index yet. Add it as a git-based Swift Package today — see below; the coordinates don't change once it's indexed.
Install
Platforms: macOS 12+, iOS 15+ for MapAtlasGeocoder. The optional MapAtlasGeocoderUI product needs macOS 14 / iOS 17 — see Platform floors below.
You cannot add this by URL yet
geocoder-sdk is a monorepo with no root Package.swift — the manifest lives at Swift/Package.swift. SwiftPM only ever looks at the repository root and has no way to target a subdirectory of a remote repository, so both of these fail:
// ❌ "package at 'https://github.com/MapMetrics/geocoder-sdk' has no Package.swift"
.package(url: "https://github.com/MapMetrics/geocoder-sdk", branch: "main"),
// ❌ worse: resolves, and silently gives you no Swift package at all.
.package(url: "https://github.com/MapMetrics/geocoder-sdk", from: "1.0.0"),The second is the nastier one. The repository's only tag, v1.0.0, predates the Swift package entirely — that tree contains Flutter/, NPM/ and openapi/ and no Swift/ directory. Pinning it does not error in a way that points at the cause.
Xcode's File → Add Package Dependencies… has the same limitation: there is no "select a subdirectory" step to perform, despite what this page previously said.
Until the Swift package is split out or indexed, depend on it by path.
Xcode
Clone the repository, then File → Add Package Dependencies… → Add Local… and select the Swift/ directory (the folder containing Package.swift), not the repository root. Add the MapAtlasGeocoder product to your target.
Package.swift
dependencies: [
.package(path: "../Geocoder-SDK/Swift"),
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "MapAtlasGeocoder", package: "Swift"),
// Optional drop-in search UI — see ./search-ui.md
.product(name: "MapAtlasGeocoderUI", package: "Swift"),
]
),
]With a path dependency the package: label is the directory name (Swift), not the repository name. Once the package is published under its own repository the .package(url:from:) form will work and package: becomes the repository name again.
Platform floors
The package declares platforms: [.macOS(.v12), .iOS(.v15)], and SwiftPM applies that to every target — there is no per-target platform in SPM. The newer pieces gate themselves with @available instead:
| Symbol | Available from |
|---|---|
MapAtlas, Session, Suggestion, MapAtlasError | macOS 12 / iOS 15 |
GeocodeSearchController (@Observable) | macOS 14 / iOS 17 |
Everything in MapAtlasGeocoderUI | macOS 14 / iOS 17 |
So the manifest advertising iOS 15 does not mean GeocodeSearchController is usable there — @Observable requires iOS 17, and the type is annotated accordingly. Wrap uses in if #available(iOS 17, *), or raise your deployment target.
Quickstart
import MapAtlasGeocoder
let mapatlas = MapAtlas(token: "YOUR_API_KEY")
// One session covers a whole search — every keystroke, then the pick.
let session = try mapatlas.geocoding.createSession()
// As the user types. Debounced inside the client (see below); suggestions
// carry no coordinates.
let results = try await session.suggest("Nieuwezijds")
// When they pick one, hand back the suggestion — not an id.
let place = try await session.retrieve(results[0])
print(place.latitude, place.longitude)
// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
let places = try await session.retrieveBatch(Array(results.prefix(3)))
// One-shot lookups, when there's no user typing to debounce. Both return GeoJSON.
let forward = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", country: "nl")
let reverse = try await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89)The reactive layer: GeocodeSearchController
GeocodeSearchController is a headless, @Observable, @MainActor controller — bind your own markup to it. Requires macOS 14 / iOS 17 (the rest of the package works down to macOS 12 / iOS 15 — @Observable alone needs the newer OS; see Platform floors).
If you want the list, keyboard handling, history, favourites and category chips built for you rather than binding your own markup, use the MapAtlasGeocoderUI product instead — this controller is the headless layer underneath it.
import SwiftUI
import MapAtlasGeocoder
struct AddressField: View {
@State private var controller: GeocodeSearchController
var onSelect: (RetrievedPlace) -> Void
init(client: MapAtlas, onSelect: @escaping (RetrievedPlace) -> Void) {
_controller = State(initialValue: try! GeocodeSearchController(client: client, country: "nl"))
self.onSelect = onSelect
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Search an address…", text: $controller.query)
.textFieldStyle(.roundedBorder)
if controller.isLoading {
ProgressView()
}
if let error = controller.error {
Text(error.message).foregroundStyle(.red).font(.caption)
}
List(controller.suggestions, id: \.text) { suggestion in
Button(suggestion.placeName ?? suggestion.text ?? "") {
Task {
if let place = await controller.select(suggestion) {
onSelect(place)
}
}
}
// Suggestions can be non-retrievable (e.g. a bare "Amsterdam"
// locality row) — still shown, but selecting one surfaces
// `.notRetrievable` into `controller.error` instead of a crash.
.disabled(!suggestion.isRetrievable)
}
}
}
}Guarantees: one Session is created for the controller's lifetime and reused across every keystroke; select(_:) retrieves and closes it, and the next query change reopens it automatically. minLength (default 2) is enforced locally — no request, no session activity, below it. Out-of-order responses are discarded: each search is tagged with a sequence number, and a slow response for an earlier keystroke arriving after a newer one is never applied. Errors land in controller.error, never thrown into a view.
Where the debounce actually lives
The controller does not debounce. Coalescing happens one layer down, in Session.suggest() inside the client — configured by MapAtlas(debounceMs:), default 150. Do not wrap controller.query in a second timer; stacking one on top of the client's only adds lag. If 150 ms is wrong for you, change debounceMs on the client.
One exception: the .osm tier has no Session, so nothing downstream coalesces and the controller debounces there itself, using the same value.
Don't use \.text or id as a SwiftUI list identity
The List(controller.suggestions, id: \.text) above is illustrative but fragile — text repeats across rows (two "Amsterdam" entries for q=amsterdam), and the gateway's own id field repeats too (two rows share "place.0"). Duplicate SwiftUI identities cause rows to reuse each other's state rather than crashing, so this misbehaves quietly. Prefer MapAtlasGeocoderUI, whose MapAtlasSearchRow carries an id already de-collided for you.
The OSM tier
tier: .osm talks to the free, OpenStreetMap-only endpoints (/osm-geocode/, /osm-reverse/, /osm-autocomplete/) via the osm-geocode scope. No session/retrieve flow — search(), reverse(), and autocomplete(_:) are the only three operations. Calling createSession() on this tier throws .tierUnsupported. Rate-limited to 10,000 requests/key/day plus a global monthly cap; exhausting it throws .quotaExceeded with a selfHostURL.
let mapatlas = MapAtlas(token: "YOUR_KEY", tier: .osm)
let result = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
if case .osm(let raw) = result {
// raw: OsmResponse (= JSONValue) — narrow it yourself at the call site.
}autocomplete(_:) on this tier is the opposite of v2 autocomplete: no session, no retrieve() step, results already carry coordinates. It's only available with tier: .osm — on the default .v2 tier it throws .tierUnsupported and points you at createSession() + suggest(_:) instead.
Self-hosting
The free tier's engine is open source: MapMetrics/atlas-osm-geocoder, deployable on Cloudflare Workers with a prebuilt planet bundle. Point this package at your own instance with MapAtlas.selfHosted(baseURL:):
let mapatlas = MapAtlas.selfHosted(baseURL: URL(string: "https://my-worker.workers.dev")!)
try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
try await mapatlas.geocoding.autocomplete("Nieuwezijds")
try await mapatlas.geocoding.reverse(lat: 52.37, lon: 4.89)A self-hosted instance takes no token and uses different paths (/search, /reverse, /autocomplete, no token parameter) — there is no token/getToken option on this constructor at all, since a self-hosted instance has no auth and this package never sends one. createSession() (and session-based retrieval) throw .tierUnsupported here too — this engine has no session concept at all.
Choosing a key for a Swift app
Origin restriction is a browser-only feature — it's enforced by checking the Origin header a browser sends, and native apps never send one. A native app can never satisfy an origin-restricted key — this is the error you'll hit if you reuse a browser-restricted key in an iOS app. Use an unrestricted key for iOS/macOS instead. See API Keys & Security for the full model.
Errors
MapAtlasError is a Swift enum with one case per failure mode. Switch on it, or read .message / .status / .code / .name:
.tokenNotFound— key was never provisioned..tokenInactive— key exists but is deactivated..scopeNotAllowed— key is valid but not scoped for this operation..originRequired— key is origin-restricted, noOriginheader sent. See Choosing a key for a Swift app..originNotAllowed— request'sOriginisn't on the key's allow-list..quotaExceeded— OSM tier cap exhausted; carriesselfHostURL..tierUnsupported— thrown bycreateSession()on the.osmtier and onMapAtlas.selfHosted(baseURL:)clients, and byautocomplete(_:)on the.v2tier..notRetrievable— thrown byretrieve(_:)/retrieveBatch(_:)when aSuggestionhas noord(isRetrievable == false)..network— the request never completed, or the response wasn't parseable JSON..decoding— the response was valid JSON but didn't match the expected shape..unknown— any other non-2xx response, not covered above.
import MapAtlasGeocoder
do {
_ = try await mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", country: "nl")
} catch let error as MapAtlasError {
switch error {
case .originRequired:
// this key can't be used from a native app — swap it for an unrestricted one
break
case .quotaExceeded(_, _, _, let selfHostURL):
print("quota exceeded, self-host at:", selfHostURL as Any)
default:
print(error.name, error.message)
}
}Not implemented yet
This package covers geocoding only — routing, matrix, map matching, optimization, and isochrones are not implemented here. The TypeScript and Dart SDKs cover those endpoints; see JavaScript or Flutter if you need routing from a shared backend or a cross-platform Dart layer.
Endpoint reference
This SDK is a typed wrapper — for the full parameter list, response shape, and gateway-level gotchas each call is protecting you from, see: