Overview
MapKit provides three search primitives: MKLocalSearch for structured place and point-of-interest queries, MKLocalSearchCompleter for real-time autocomplete suggestions, and CLGeocoder for converting between addresses and coordinates. Combining them well produces a search experience that rivals Apple Maps itself. For displaying results as pins, see mapkit-annotations; for animating to results, see mapkit-camera; for street-level previews of a selected result, see mapkit-look-around.
Use MKLocalSearch for place and POI queries
MKLocalSearch takes an MKLocalSearch.Request and returns MKLocalSearch.Response with an array of MKMapItem. Scope the search to the visible region so results are relevant.
func search(query: String, region: MKCoordinateRegion) async throws -> [MKMapItem] {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
request.resultTypes = [.pointOfInterest, .address]
let response = try await MKLocalSearch(request: request).start()
return response.mapItems
}Limit result count with request.resultTypes and the pointOfInterestFilter property. Returning unbounded results and discarding them client-side wastes network and memory.
Drive autocomplete with MKLocalSearchCompleter
MKLocalSearchCompleter produces suggestion strings as the user types. Observe completions using Combine or an @Observable wrapper.
@Observable final class SearchModel: NSObject, MKLocalSearchCompleterDelegate {
var completions: [MKLocalSearchCompletion] = []
private let completer = MKLocalSearchCompleter()
override init() {
super.init()
completer.delegate = self
completer.resultTypes = [.pointOfInterest, .query]
}
func update(query: String, region: MKCoordinateRegion) {
completer.region = region
completer.queryFragment = query
}
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
completions = completer.results
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
completions = []
}
}Bind queryFragment to a text field. Do not debounce the assignment; MKLocalSearchCompleter already rate-limits internally. Debouncing adds latency without benefit.
Resolve a completion to MKMapItem before displaying on the map
MKLocalSearchCompletion is a display suggestion, not a coordinate. Convert it to an MKMapItem with a second MKLocalSearch call before placing an annotation or flying the camera.
func resolve(_ completion: MKLocalSearchCompletion) async throws -> MKMapItem? {
let request = MKLocalSearch.Request(completion: completion)
let response = try await MKLocalSearch(request: request).start()
return response.mapItems.first
}Show a loading indicator between tap and pin appearance. The round trip is fast but not instant, and a frozen UI is worse than a brief spinner.
Geocode addresses with CLGeocoder when you have free-form text
CLGeocoder resolves unstructured address strings and reverse-geocodes coordinates to placemarks. Use it when the input is an address rather than a place name.
func geocode(_ addressString: String) async throws -> CLPlacemark? {
let placemarks = try await CLGeocoder().geocodeAddressString(addressString)
return placemarks.first
}
func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> CLPlacemark? {
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
return try await CLGeocoder().reverseGeocodeLocation(location).first
}CLGeocoder makes a network request. Cache results in core-data when you geocode the same addresses repeatedly (e.g. a stored address book).
Cancel in-flight searches when the query changes
Holding a live MKLocalSearch instance and calling cancel() when a new query arrives avoids stale results overwriting fresher ones.
@Observable final class PlaceSearchModel {
var results: [MKMapItem] = []
private var activeSearch: MKLocalSearch?
func search(query: String, region: MKCoordinateRegion) async {
activeSearch?.cancel()
guard !query.isEmpty else { results = []; return }
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = query
request.region = region
let search = MKLocalSearch(request: request)
activeSearch = search
results = (try? await search.start())?.mapItems ?? []
}
}Scope searches to the visible region with region and regionPriority
MKLocalSearch.Request.regionPriority controls whether the region is a strict filter or a ranking hint. Use .required when out-of-region results are useless; use .default to allow Apple Maps to include a few near-region results when local density is low.
request.region = mapRegion
request.regionPriority = .requiredUpdate the region from the map’s onMapCameraChange(frequency: .onEnd) callback; do not hard-code a fixed region.
Filter by point-of-interest category for category browsing
MKPointOfInterestFilter restricts results to specific MKPointOfInterestCategory values. Use it for category browse screens (restaurants, parks, transit).
request.pointOfInterestFilter = MKPointOfInterestFilter(including: [.restaurant, .cafe])Combine with a UI segmented control or a chip row mapped to category groups. Store the user’s last-used category filter preference in UserDefaults.