```html SwiftUI: How to Implement Address Autocomplete (iOS 17+, 2026)

How to implement address autocomplete in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: MapKit / MKLocalSearch Updated: May 11, 2026
TL;DR

Wrap MKLocalSearchCompleter in an @Observable class, drive its queryFragment from a TextField, and show the results in a floating list. Tap a suggestion to resolve it with MKLocalSearch and get the full postal address.

@Observable
final class AddressCompleter: NSObject, MKLocalSearchCompleterDelegate {
    var results: [MKLocalSearchCompletion] = []
    private let completer = MKLocalSearchCompleter()

    override init() {
        super.init()
        completer.delegate = self
        completer.resultTypes = .address
    }

    func search(_ query: String) {
        completer.queryFragment = query
    }

    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        results = completer.results
    }
}

Full implementation

The approach has two layers: a lightweight AddressCompleter observable that streams live completion results from MapKit, and an AddressAutocompleteField SwiftUI view that presents those results as a floating overlay beneath the text field. When the user selects a suggestion, a second MKLocalSearch request resolves the full CNPostalAddress from the MKMapItem. The whole thing compiles without any third-party dependency — only MapKit and Contacts are needed.

import SwiftUI
import MapKit
import Contacts

// MARK: - Observable Completer

@Observable
final class AddressCompleter: NSObject, MKLocalSearchCompleterDelegate {
    var results: [MKLocalSearchCompletion] = []
    var isSearching = false

    private let completer = MKLocalSearchCompleter()

    override init() {
        super.init()
        completer.delegate = self
        completer.resultTypes = .address
    }

    func update(query: String) {
        guard !query.isEmpty else { results = []; return }
        completer.queryFragment = query
    }

    // MARK: MKLocalSearchCompleterDelegate
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        results = completer.results
    }

    func completer(_ completer: MKLocalSearchCompleter,
                   didFailWithError error: Error) {
        results = []
    }

    // MARK: Resolve full address from a suggestion
    @MainActor
    func resolve(_ completion: MKLocalSearchCompletion) async -> String? {
        isSearching = true
        defer { isSearching = false }
        let req = MKLocalSearch.Request(completion: completion)
        req.resultTypes = .address
        let search = MKLocalSearch(request: req)
        guard let resp = try? await search.start(),
              let item = resp.mapItems.first else { return nil }
        let addr = item.placemark.postalAddress
        return addr.map {
            CNPostalAddressFormatter.string(from: $0, style: .mailingAddress)
        }
    }
}

// MARK: - View

struct AddressAutocompleteField: View {
    @Binding var selectedAddress: String

    @State private var query = ""
    @State private var completer = AddressCompleter()
    @State private var isFocused = false
    @FocusState private var fieldFocused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            // Input row
            HStack {
                Image(systemName: "magnifyingglass")
                    .foregroundStyle(.secondary)
                TextField("Search address…", text: $query)
                    .focused($fieldFocused)
                    .autocorrectionDisabled()
                    .textInputAutocapitalization(.words)
                    .onChange(of: query) { _, new in
                        completer.update(query: new)
                    }
                if !query.isEmpty {
                    Button {
                        query = ""
                        selectedAddress = ""
                        completer.update(query: "")
                    } label: {
                        Image(systemName: "xmark.circle.fill")
                            .foregroundStyle(.secondary)
                    }
                    .accessibilityLabel("Clear address")
                }
            }
            .padding(12)
            .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12))
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .strokeBorder(fieldFocused ? Color.accentColor : .clear, lineWidth: 1.5)
            )

            // Suggestions list
            if fieldFocused && !completer.results.isEmpty {
                ScrollView {
                    LazyVStack(alignment: .leading, spacing: 0) {
                        ForEach(completer.results, id: \.title) { result in
                            Button {
                                Task {
                                    if let full = await completer.resolve(result) {
                                        selectedAddress = full
                                        query = result.title
                                    }
                                    fieldFocused = false
                                    completer.results = []
                                }
                            } label: {
                                VStack(alignment: .leading, spacing: 2) {
                                    Text(result.title)
                                        .font(.subheadline)
                                        .foregroundStyle(.primary)
                                    if !result.subtitle.isEmpty {
                                        Text(result.subtitle)
                                            .font(.caption)
                                            .foregroundStyle(.secondary)
                                    }
                                }
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .padding(.horizontal, 14)
                                .padding(.vertical, 10)
                            }
                            .buttonStyle(.plain)
                            .accessibilityLabel("\(result.title), \(result.subtitle)")
                            Divider().padding(.leading, 14)
                        }
                    }
                }
                .frame(maxHeight: 240)
                .background(.background)
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .shadow(color: .black.opacity(0.08), radius: 12, y: 4)
                .transition(.opacity.combined(with: .move(edge: .top)))
            }
        }
        .animation(.easeInOut(duration: 0.15), value: completer.results.isEmpty)
        .overlay(alignment: .center) {
            if completer.isSearching {
                ProgressView().padding(4)
            }
        }
    }
}

// MARK: - Preview

#Preview {
    @Previewable @State var address = ""
    VStack(alignment: .leading, spacing: 16) {
        AddressAutocompleteField(selectedAddress: $address)
        if !address.isEmpty {
            Text(address)
                .font(.footnote)
                .foregroundStyle(.secondary)
                .padding(.horizontal, 4)
        }
    }
    .padding()
}

How it works

  1. @Observable completer class. AddressCompleter extends NSObject so it can satisfy MKLocalSearchCompleterDelegate. Marking it @Observable (iOS 17 Observation framework) means SwiftUI automatically tracks reads of results — no @Published or ObservableObject boilerplate needed.
  2. Driving the query fragment. The TextField calls completer.update(query:) on every keystroke via onChange(of: query). Setting completer.resultTypes = .address restricts results to postal addresses only, reducing noise from business or landmark completions.
  3. Floating suggestion list. The list renders only when fieldFocused && !completer.results.isEmpty, so it disappears automatically when the user dismisses the keyboard. A LazyVStack inside a ScrollView capped at 240 pt avoids layout overflow for long result sets.
  4. Two-stage resolution. MKLocalSearchCompletion only gives you a title/subtitle string pair. The resolve(_:) method builds a full MKLocalSearch.Request from that completion and awaits the response, then formats the resulting CNPostalAddress with CNPostalAddressFormatter into a locale-correct multiline string your form can persist.
  5. Animated transitions and accessibility. The list uses a combined .opacity + .move(edge: .top) transition for a polished feel. Each suggestion row carries an accessibilityLabel combining title and subtitle so VoiceOver users hear the full context before activating.

Variants

Restrict search to a country or region

// Inside AddressCompleter.init():
completer.resultTypes = .address

// Restrict to United States addresses only
completer.region = MKCoordinateRegion(
    center: CLLocationCoordinate2D(latitude: 37.0902, longitude: -95.7129),
    latitudinalMeters: 5_000_000,
    longitudinalMeters: 5_000_000
)

// Or use the device locale to auto-restrict:
// completer.regionPriority = .required  // iOS 18+, use .default for soft bias

Return structured address components instead of a formatted string

Rather than passing the formatted string up to the parent, expose the raw MKPlacemark or CNPostalAddress via a closure or binding. Change the resolve return type to MKMapItem? and let the caller destructure item.placemark.postalAddress fields (.street, .city, .postalCode, .isoCountryCode) directly into separate @State or SwiftData model properties.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement address autocomplete in SwiftUI for iOS 17+.
Use MapKit MKLocalSearchCompleter and MKLocalSearch.
Wrap the completer in an @Observable class with async resolve().
Make it accessible (VoiceOver labels on each suggestion row).
Add a #Preview with realistic sample data (pre-filled query).

In Soarias' Build phase, drop this prompt into the active screen context so Claude Code can wire the @Binding directly to your SwiftData model field and generate a matching unit test for the completer delegate.

Related

FAQ

Does this work on iOS 16?

The MKLocalSearchCompleter API itself is available back to iOS 9.3, but the @Observable macro requires iOS 17. To support iOS 16 you would replace @Observable with ObservableObject + @Published, and use @StateObject instead of @State for the completer instance in the view. The async MKLocalSearch.start() overload requires iOS 15+.

Can I get GPS coordinates (latitude/longitude) alongside the address?

Yes. The resolved MKMapItem exposes item.placemark.coordinate as a CLLocationCoordinate2D. Change resolve to return the full MKMapItem and read both .placemark.postalAddress and .placemark.coordinate from the call site — no extra geocoding round-trip needed.

What's the UIKit equivalent?

In UIKit you would use the same MKLocalSearchCompleter delegate pattern, but display results in a UITableView presented inside a UISearchController. Apple's own Choosing the right location authorization level sample project demonstrates this pattern. The SwiftUI version above is considerably less boilerplate.

Last reviewed: 2026-05-11 by the Soarias team.

```