Search code examples
swiftuimapkitmkcoordinateregionmklocalsearch

SwiftUI Using MapKit for Address Auto Complete


I have a form where the user enters their address. While they can always enter it manually, I also wanted to provide them with an easy solution with auto complete so that they could just start typing their address and then tap on the correct one from the list and have it auto populate the various fields.

I started by working off of jnpdx's Swift5 solution - https://stackoverflow.com/a/67131376/11053343

However, there are two issues that I cannot seem to solve:

  1. I need the results to be limited to the United States only (not just the continental US, but the entire United States including Alaska, Hawaii, and Puerto Rico). I am aware of how MKCoordinateRegion works with the center point and then the zoom spread, but it doesn't seem to work on the results of the address search.

  2. The return of the results provides only a title and subtitle, where I need to actually extract all the individual address information and populate my variables (i.e. address, city, state, zip, and zip ext). If the user has an apt or suite number, they would then fill that in themselves. My thought was to create a function that would run when the button is tapped, so that the variables are assigned based off of the user's selection, but I have no idea how to extract the various information required. Apple's docs are terrible as usual and I haven't found any tutorials explaining how to do this.

This is for the latest SwiftUI and XCode (ios15+).

I created a dummy form for testing. Here's what I have:

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
        searchCompleter.region = MKCoordinateRegion()
        searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
        
        $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) {
        //currentPromise?(.failure(error))
    }
}

struct MapKit_Interface: View {

        @StateObject private var mapSearch = MapSearch()
        @State private var address = ""
        @State private var addrNum = ""
        @State private var city = ""
        @State private var state = ""
        @State private var zip = ""
        @State private var zipExt = ""
        
        var body: some View {

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

                        ForEach(mapSearch.locationResults, id: \.self) { location in
                            Button {
                                // Function code goes here
                            } label: {
                                VStack(alignment: .leading) {
                                    Text(location.title)
                                        .foregroundColor(Color.white)
                                    Text(location.subtitle)
                                        .font(.system(.caption))
                                        .foregroundColor(Color.white)
                                }
                        } // End Label
                        } // End ForEach
                        } // End Section

                        Section {
                        
                        TextField("Address", text: $address)
                        TextField("Apt/Suite", text: $addrNum)
                        TextField("City", text: $city)
                        TextField("State", text: $state)
                        TextField("Zip", text: $zip)
                        TextField("Zip-Ext", text: $zipExt)
                        
                    } // End Section
                } // End List

        } // End var Body
    } // End Struct

Solution

  • 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()
        }
    }