How to Implement Auto-Complete for Address Using Apple Map Kit

How to implement auto-complete for address using Apple Map Kit

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
}
}

SwiftUI Using MapKit for Address Auto Complete

Since no one has responded, I, and my friend Tolstoy, spent a lot of time figuring out the solution and I thought I would post it for anyone else who might be interested. Tolstoy wrote a version for the Mac, while I wrote the iOS version shown here.

Seeing as how Google is charging for usage of their API and Apple is not, this solution gives you address auto-complete for forms. Bear in mind it won't always be perfect because we are beholden to Apple and their maps. Likewise, you have to turn the address into coordinates, which you then turn into a placemark, which means there will be some addresses that may change when tapped from the completion list. Odds are this won't be an issue for 99.9% of users, but thought I would mention it.

At the time of this writing, I am using XCode 13.2.1 and SwiftUI for iOS 15.

I organized it with two Swift files. One to hold the class/struct (AddrStruct.swift) and the other which is the actual view in the app.

AddrStruct.swift

import SwiftUI
import Combine
import MapKit
import CoreLocation

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
searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])

$searchTerm
.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ (currentSearchTerm) in
self.searchTermToResults(searchTerm: currentSearchTerm)
})
.sink(receiveCompletion: { (completion) in
//handle error
}, receiveValue: { (results) in
self.locationResults = results.filter { $0.subtitle.contains("United States") } // This parses the subtitle to show only results that have United States as the country. You could change this text to be Germany or Brazil and only show results from those countries.
})
.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))
}
}

struct ReversedGeoLocation {
let streetNumber: String // eg. 1
let streetName: String // eg. Infinite Loop
let city: String // eg. Cupertino
let state: String // eg. CA
let zipCode: String // eg. 95014
let country: String // eg. United States
let isoCountryCode: String // eg. US

var formattedAddress: String {
return """
\(streetNumber) \(streetName),
\(city), \(state) \(zipCode)
\(country)
"""
}

// Handle optionals as needed
init(with placemark: CLPlacemark) {
self.streetName = placemark.thoroughfare ?? ""
self.streetNumber = placemark.subThoroughfare ?? ""
self.city = placemark.locality ?? ""
self.state = placemark.administrativeArea ?? ""
self.zipCode = placemark.postalCode ?? ""
self.country = placemark.country ?? ""
self.isoCountryCode = placemark.isoCountryCode ?? ""
}
}

For testing purposes, I called my main view file Test.swift. Here's a stripped down version for reference.

Test.swift

import SwiftUI
import Combine
import CoreLocation
import MapKit

struct Test: View {
@StateObject private var mapSearch = MapSearch()

func reverseGeo(location: MKLocalSearchCompletion) {
let searchRequest = MKLocalSearch.Request(completion: location)
let search = MKLocalSearch(request: searchRequest)
var coordinateK : CLLocationCoordinate2D?
search.start { (response, error) in
if error == nil, let coordinate = response?.mapItems.first?.placemark.coordinate {
coordinateK = coordinate
}

if let c = coordinateK {
let location = CLLocation(latitude: c.latitude, longitude: c.longitude)
CLGeocoder().reverseGeocodeLocation(location) { placemarks, error in

guard let placemark = placemarks?.first else {
let errorString = error?.localizedDescription ?? "Unexpected Error"
print("Unable to reverse geocode the given location. Error: \(errorString)")
return
}

let reversedGeoLocation = ReversedGeoLocation(with: placemark)

address = "\(reversedGeoLocation.streetNumber) \(reversedGeoLocation.streetName)"
city = "\(reversedGeoLocation.city)"
state = "\(reversedGeoLocation.state)"
zip = "\(reversedGeoLocation.zipCode)"
mapSearch.searchTerm = address
isFocused = false

}
}
}
}

// Form Variables

@FocusState private var isFocused: Bool

@State private var btnHover = false
@State private var isBtnActive = false

@State private var address = ""
@State private var city = ""
@State private var state = ""
@State private var zip = ""

// Main UI

var body: some View {

VStack {
List {
Section {
Text("Start typing your street address and you will see a list of possible matches.")
} // End Section

Section {
TextField("Address", text: $mapSearch.searchTerm)

// Show auto-complete results
if address != mapSearch.searchTerm && isFocused == false {
ForEach(mapSearch.locationResults, id: \.self) { location in
Button {
reverseGeo(location: location)
} label: {
VStack(alignment: .leading) {
Text(location.title)
.foregroundColor(Color.white)
Text(location.subtitle)
.font(.system(.caption))
.foregroundColor(Color.white)
}
} // End Label
} // End ForEach
} // End if
// End show auto-complete results

TextField("City", text: $city)
TextField("State", text: $state)
TextField("Zip", text: $zip)

} // End Section
.listRowSeparator(.visible)

} // End List

} // End Main VStack

} // End Var Body

} // End Struct

struct Test_Previews: PreviewProvider {
static var previews: some View {
Test()
}
}

How to create a autocomplete lookup in MapKit JS similar to Google's Place Autocomplete

Ok, I've now figured it out and sharing the answer for the benefit of others.

With MapKit JS you create a new search object, and then call autocomplete on that object; so:

let search = new mapkit.Search({ region: map.region });
search.autocomplete('searchterm', (error, data) => {
if (error) {
return;
}
// handle the results
});
});

MapKit JS send back the results as an object in data.results including:

coordinate.latitude
coordinate.longitude
displayLines ([0] contains the place name and [1] is the address)

So you just loop through the results and build a list.

And pulling all this together:

First so CSS to make the autocomplete tidy:

<style>
.mapSearchWrapper {
position: relative;
}
.mapSearchInput {
width: 100%;
padding: 4px;
}
.mapSearchResults {
display: none;
position: absolute;
top: 20px;
left: 0px;
z-index:9999;
background: #FFFFFF;
border: 1px #CCCCCC solid;
font-family: sans-serif;
}
.mapSearchResultsItem {
padding: 4px;
border-bottom: 1px #CCCCCC solid;
}
.mapSearchResultsItem:hover {
background: #EEEEEE;
}
</style>

Then the HTML which will hold the input box, result results and actual map.

<div class="mapSearchWrapper">
<input type="text" id="mapLookup" class="mapSearchInput">
<div id="results" class="mapSearchResults">
</div>
</div>

<div id="map"></div>

And then the actual JavaScript that will make the magic happen. Note I have JQuery loaded, so you'll need that library if you use this code.

<script type="text/javascript">
// Initialise MapKit
mapkit.init({
authorizationCallback: function(done) {
done('[REPLACE THIS WITH YOUR OWN TOKEN]');
}
});
// Create an initial region. This also weights the search area
var myRegion = new mapkit.CoordinateRegion(
new mapkit.Coordinate(55.9496320, -3.1866360),
new mapkit.CoordinateSpan(0.05, 0.05)
);
// Create map on the id 'map'
var map = new mapkit.Map("map");
map.region = myRegion;
// Listen for keyup in the input field
$('#mapLookup').keyup(function(){
// Make sure it's not a zero length string
if($('#mapLookup').length>0) {
let search = new mapkit.Search({ region: map.region });
search.autocomplete($('#mapLookup').val(), (error, data) => {
if (error) {
return;
}
// Unhide the result box
$('#results').show();
var results = "";
// Loop through the results a build
data.results.forEach(function(result) {
if(result.coordinate) {
// Builds the HTML it'll display in the results. This includes the data in the attributes so it can be used later
results = results + '<div class="mapSearchResultsItem" data-title="' +result.displayLines[0] + '" data-latitude="'+result.coordinate.latitude+'" data-longitude="'+result.coordinate.longitude+'" data-address="'+result.displayLines[1]+'"><b>' + result.displayLines[0] + '</b> ' + result.displayLines[1] + '</div>';
}
});
// Display the results
$('#results').html(results);
// List for a click on an item we've just displayed
$('.mapSearchResultsItem').click(function() {
// Get all the data - you might want to write this into form fields on your page to capture the data if this map is part of a form.
var latitude = $(this).data('latitude');
var longitude = $(this).data('longitude');
var title = $(this).data('title');
var address = $(this).data('address');
// Calc the new region
var myRegion = new mapkit.CoordinateRegion(
new mapkit.Coordinate(latitude, longitude),
new mapkit.CoordinateSpan(0.01, 0.01)
);
// Clean up the map of old searches
map.removeAnnotations(map.annotations);
map.region = myRegion;
// Add the new annotation
var myAnnotation = new mapkit.MarkerAnnotation(new mapkit.Coordinate(latitude, longitude), {
color: "#9b6bcc",
title: title,
subtitle: address
});
map.addAnnotation(myAnnotation);
// Hide the results box
$('#results').hide();
});
});
} else {
$('#results').hide();
}
});
</script>

Swift MapKit Autocomplete

You left out the underscore for first parameter of the cellForRowAtIndexPath declaration, which is required under swift 3.0:

func tableView(_ tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
[etc.]

As a result, you don't have a required function matching the expected signature.

iOS8: Best way to autocomplete address searching?

SPGooglePlacesAutocomplete is a simple objective-c wrapper around the Google Places Autocomplete API.

Look at this API from github which might be helpful- https://github.com/spoletto/SPGooglePlacesAutocomplete

Usage shown as per link. Adding the .h file you can have access to the functions which implement the Google places API from within the function. You can set parameters like partial address string, radius, language your app uses, your location (lat,long)

#import "SPGooglePlacesAutocompleteQuery.h"

...

SPGooglePlacesAutocompleteQuery *query = [SPGooglePlacesAutocompleteQuery query];
query.input = @"185 berry str";
query.radius = 100.0;
query.language = @"en";
query.types = SPPlaceTypeGeocode; // Only return geocoding (address) results.
query.location = CLLocationCoordinate2DMake(37.76999, -122.44696)

Then, call -fetchPlaces to ping Google's API and fetch results. The resulting array will return objects of the SPGooglePlacesAutocompletePlace class.

[query fetchPlaces:^(NSArray *places, NSError *error) {
NSLog(@"Places returned %@", places);
}];

It also has a example project which can be used.

Google Maps autocomplete Functionality from Apple Maps for Objective C?

Check this out - Yahoo YQL console.

There you can put queries like this - it gives you all places starting with San:

select * from geo.places where text="San%"

The resulting JSON / XML can act as data source to what you are trying to display for autocomplete results.

As I can see they allow up to some number of free queries for non-commercial use, and beyond that they charge. You may check it out here and here.

Google Maps autocomplete Functionality from Apple Maps for Objective C?

Check this out - Yahoo YQL console.

There you can put queries like this - it gives you all places starting with San:

select * from geo.places where text="San%"

The resulting JSON / XML can act as data source to what you are trying to display for autocomplete results.

As I can see they allow up to some number of free queries for non-commercial use, and beyond that they charge. You may check it out here and here.



Related Topics



Leave a reply



Submit