How to implement auto-complete for address using Apple Map Kit
Swift 5 + Combine + (Optionally) SwiftUI solution
There seem to be a number of comments on other solutions wanting a version compatible with more recent versions of Swift. Plus, It seems likely that (as I did), people will need a SwiftUI solution as well.
This builds on previous suggestions, but uses Combine to monitor the input, debounce it, and then provide results through a Publisher.
The MapSearch
ObservableObject
is easily used in SwiftUI (example provided), but could also be used in non-SwiftUI situations as well.
MapSearch ObservableObject
import SwiftUI
import Combine
import MapKit
class MapSearch : NSObject, ObservableObject {
@Published var locationResults : [MKLocalSearchCompletion] = []
@Published var searchTerm = ""
private var cancellables : Set<AnyCancellable> = []
private var searchCompleter = MKLocalSearchCompleter()
private var currentPromise : ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
override init() {
super.init()
searchCompleter.delegate = self
$searchTerm
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ (currentSearchTerm) in
self.searchTermToResults(searchTerm: currentSearchTerm)
})
.sink(receiveCompletion: { (completion) in
//handle error
}, receiveValue: { (results) in
self.locationResults = results
})
.store(in: &cancellables)
}
func searchTermToResults(searchTerm: String) -> Future<[MKLocalSearchCompletion], Error> {
Future { promise in
self.searchCompleter.queryFragment = searchTerm
self.currentPromise = promise
}
}
}
extension MapSearch : MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
currentPromise?(.success(completer.results))
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
//could deal with the error here, but beware that it will finish the Combine publisher stream
//currentPromise?(.failure(error))
}
}
SwiftUI interface, including mapped locations
struct ContentView: View {
@StateObject private var mapSearch = MapSearch()
var body: some View {
NavigationView {
Form {
Section {
TextField("Address", text: $mapSearch.searchTerm)
}
Section {
ForEach(mapSearch.locationResults, id: \.self) { location in
NavigationLink(destination: Detail(locationResult: location)) {
VStack(alignment: .leading) {
Text(location.title)
Text(location.subtitle)
.font(.system(.caption))
}
}
}
}
}.navigationTitle(Text("Address search"))
}
}
}
class DetailViewModel : ObservableObject {
@Published var isLoading = true
@Published private var coordinate : CLLocationCoordinate2D?
@Published var region: MKCoordinateRegion = MKCoordinateRegion()
var coordinateForMap : CLLocationCoordinate2D {
coordinate ?? CLLocationCoordinate2D()
}
func reconcileLocation(location: MKLocalSearchCompletion) {
let searchRequest = MKLocalSearch.Request(completion: location)
let search = MKLocalSearch(request: searchRequest)
search.start { (response, error) in
if error == nil, let coordinate = response?.mapItems.first?.placemark.coordinate {
self.coordinate = coordinate
self.region = MKCoordinateRegion(center: coordinate, span: MKCoordinateSpan(latitudeDelta: 0.03, longitudeDelta: 0.03))
self.isLoading = false
}
}
}
func clear() {
isLoading = true
}
}
struct Detail : View {
var locationResult : MKLocalSearchCompletion
@StateObject private var viewModel = DetailViewModel()
struct Marker: Identifiable {
let id = UUID()
var location: MapMarker
}
var body: some View {
Group {
if viewModel.isLoading {
Text("Loading...")
} else {
Map(coordinateRegion: $viewModel.region,
annotationItems: [Marker(location: MapMarker(coordinate: viewModel.coordinateForMap))]) { (marker) in
marker.location
}
}
}.onAppear {
viewModel.reconcileLocation(location: locationResult)
}.onDisappear {
viewModel.clear()
}
.navigationTitle(Text(locationResult.title))
}
}
Update - I've created a simple example project here using Swift 3 as the original answer was written in Swift 2.
In iOS 9.3 a new class called MKLocalSearchCompleter
was introduced, this allows the creation of an autocomplete solution, you simply pass in the queryFragment as below:
var searchCompleter = MKLocalSearchCompleter()
searchCompleter.delegate = self
var searchResults = [MKLocalSearchCompletion]()
searchCompleter.queryFragment = searchField.text!
Then handle the results of the query using the MKLocalSearchCompleterDelegate
:
extension SearchViewController: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(completer: MKLocalSearchCompleter) {
searchResults = completer.results
searchResultsTableView.reloadData()
}
func completer(completer: MKLocalSearchCompleter, didFailWithError error: NSError) {
// handle error
}
}
And display the address results in an appropriate format:
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let searchResult = searchResults[indexPath.row]
let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil)
cell.textLabel?.text = searchResult.title
cell.detailTextLabel?.text = searchResult.subtitle
return cell
}
You can then use a MKLocalCompletion
object to instantiate a MKLocalSearch.Request
, thus gaining access to the MKPlacemark
and all other useful data:
let searchRequest = MKLocalSearch.Request(completion: completion!)
let search = MKLocalSearch(request: searchRequest)
search.startWithCompletionHandler { (response, error) in
if error == nil {
let coordinate = response?.mapItems[0].placemark.coordinate
}
}