How to implement address autocomplete in SwiftUI
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
-
@Observable completer class.
AddressCompleterextendsNSObjectso it can satisfyMKLocalSearchCompleterDelegate. Marking it@Observable(iOS 17 Observation framework) means SwiftUI automatically tracks reads ofresults— no@PublishedorObservableObjectboilerplate needed. -
Driving the query fragment. The
TextFieldcallscompleter.update(query:)on every keystroke viaonChange(of: query). Settingcompleter.resultTypes = .addressrestricts results to postal addresses only, reducing noise from business or landmark completions. -
Floating suggestion list. The list renders only when
fieldFocused && !completer.results.isEmpty, so it disappears automatically when the user dismisses the keyboard. ALazyVStackinside aScrollViewcapped at 240 pt avoids layout overflow for long result sets. -
Two-stage resolution.
MKLocalSearchCompletiononly gives you a title/subtitle string pair. Theresolve(_:)method builds a fullMKLocalSearch.Requestfrom that completion and awaits the response, then formats the resultingCNPostalAddresswithCNPostalAddressFormatterinto a locale-correct multiline string your form can persist. -
Animated transitions and accessibility. The list uses a combined
.opacity + .move(edge: .top)transition for a polished feel. Each suggestion row carries anaccessibilityLabelcombining 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
-
MapKit entitlement on device vs. Simulator.
MKLocalSearchCompleterworks fine in the Simulator but silently returns zero results on a physical device if your app is missing the Location When In Use permission and you have setregionPriority = .required. Use.defaultfor soft region bias unless you genuinely need hard restriction. -
Do not create
MKLocalSearchper keystroke.MKLocalSearchCompleteralready debounces internally — feed it every character. But never fire a fullMKLocalSearchrequest per keystroke; only callresolve(_:)once the user taps a suggestion. Firing search requests eagerly exhausts your MapKit quota and triggers rate-limiting. -
VoiceOver focus management. When the suggestions list appears, VoiceOver does not
automatically announce it. Post a
UIAccessibility.post(notification: .screenChanged, argument: nil)notification insidecompleterDidUpdateResults(viaDispatchQueue.main.async) to move VoiceOver focus to the updated list.
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.