Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

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:

swift
// ❌ "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

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:

SymbolAvailable from
MapAtlas, Session, Suggestion, MapAtlasErrormacOS 12 / iOS 15
GeocodeSearchController (@Observable)macOS 14 / iOS 17
Everything in MapAtlasGeocoderUImacOS 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

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

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

swift
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:):

swift
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, no Origin header sent. See Choosing a key for a Swift app.
  • .originNotAllowed — request's Origin isn't on the key's allow-list.
  • .quotaExceeded — OSM tier cap exhausted; carries selfHostURL.
  • .tierUnsupported — thrown by createSession() on the .osm tier and on MapAtlas.selfHosted(baseURL:) clients, and by autocomplete(_:) on the .v2 tier.
  • .notRetrievable — thrown by retrieve(_:)/retrieveBatch(_:) when a Suggestion has no ord (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.
swift
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: