MapKit Location Search with SwiftUI

Published July 2020

In case you haven’t had a chance to listen to any of the WWDC2020 talks, SwiftUI is here to stick and I’m glad I decided to get a head start with building my latest app, Snoozie, completely with SwiftUI.

In order to look up a contact’s timezone, I provide a way to search their address and get the timezone information from it, so I don’t have to ask users to figure out what timezone their contact is in. Luckily, MapKit provides a free mechanism to query locations, so you don’t have to rely on an external service and keep your user’s data private. Read on to see how I’m interfacing with MKLocalSearchCompleter via SwiftUI.

So first of all, let me say that it’s pretty amazing that this is a built-in feature in iOS and you don’t have to ask for any user permissions or even require a network connection, let alone pay for an external service.

UX Considerations

For this example, we want to support the user to find a location. In this specific case, a town, city or country. This solution should support users who know what they’re looking for and are quickly typing in a place as well as users who might not know how to spell a place properly, so they might go one character at a time. We want to display a list of results that the user can eventually tap on (not implemented here), but we’re dealing with a service that can stop working if we flood it with requests, so we have to be aware of the limitations.

User Interface

Here’s the UI for this sample project, a pretty straight forward search that lists results as the user types, making it as easy as possible to select a location. Please note that we’re not doing anything with the results at this point. A logical implementation to deal with a user selection would be to either wrap the Label into a Button or a NavigationLink.

Screenshot of the user interface of the sample project, displaying a search field with Munich typed in and a list of possible results.

The Content View

The sample content view is very straight forward, we’re displaying a Form with two Sections, one for the search bar and one for the results. The results section handles the cases of no results found as well as displaying an error, mainly to let the user know if we hit the rate limit for searches.

struct ContentView: View {
    @ObservedObject var locationService: LocationService

    var body: some View {
        VStack {
            Form {
                Section(header: Text("Location Search")) {
                    ZStack(alignment: .trailing) {
                        TextField("Search", text: $locationService.queryFragment)
                        // This is optional and simply displays an icon during an active search
                        if locationService.status == .isSearching {
                            Image(systemName: "clock")
                                .foregroundColor(Color.gray)
                        }
                    }
                }
                Section(header: Text("Results")) {
                    List {
                        // With Xcode 12, this will not be necessary as it supports switch statements.
                        Group { () -> AnyView in
                            switch locationService.status {
                            case .noResults: return AnyView(Text("No Results"))
                            case .error(let description): return AnyView(Text("Error: \(description)"))
                            default: return AnyView(EmptyView())
                            }
                        }.foregroundColor(Color.gray)

                        ForEach(locationService.searchResults, id: \.self) { completionResult in
                            // This simply lists the results, use a button in case you'd like to perform an action
                            // or use a NavigationLink to move to the next view upon selection.
                            Text(completionResult.title)
                        }
                    }
                }
            }
        }
    }
}

Note that the wrapper around the status display is quite ugly, because SwiftUI currently doesn’t support switch statements. With Xcode 12, this will change though. Another way to get rid of this construct would be to handle errors differently. I may be wrong with this assumption, but I’m thinking we will rarely see an error here, because if the user types in some random characters, the worst case is that there will be no results. But for the sake of putting the user in control, we should at the very least display the (likely gibberish) error message.

The Location Service class

This is where things get exciting. It’s a small and simple class, but we’re using MapKit and Combine, which makes this super neat.

As you can see, we’re subscribing to changes to the queryFragment String field, which is connected to the TextField in the content view. Every time the text field changes, we’re getting an update here. Because the search completer is rate limited and because we don’t need to start searching after every character has been typed, we’re using debounce. This makes sure, we’re initiating a search only after the specified delay. Imagine a user typing super fast, we can wait until we have a pretty good idea what they’re looking for. At the same time, the delay is short enough, so if a user isn’t sure how to spell a place, it looks like we’re responding right away as well.

import Foundation
import Combine
import MapKit

class LocationService: NSObject, ObservableObject {

    enum LocationStatus: Equatable {
        case idle
        case noResults
        case isSearching
        case error(String)
        case result
    }

    @Published var queryFragment: String = ""
    @Published private(set) var status: LocationStatus = .idle
    @Published private(set) var searchResults: [MKLocalSearchCompletion] = []

    private var queryCancellable: AnyCancellable?
    private let searchCompleter: MKLocalSearchCompleter!

    init(searchCompleter: MKLocalSearchCompleter = MKLocalSearchCompleter()) {
        self.searchCompleter = searchCompleter
        super.init()
        self.searchCompleter.delegate = self

        queryCancellable = $queryFragment
            .receive(on: DispatchQueue.main)
            // we're debouncing the search, because the search completer is rate limited.
            // feel free to play with the proper value here
            .debounce(for: .milliseconds(250), scheduler: RunLoop.main, options: nil)
            .sink(receiveValue: { fragment in
                self.status = .isSearching
                if !fragment.isEmpty {
                    self.searchCompleter.queryFragment = fragment
                } else {
                    self.status = .idle
                    self.searchResults = []
                }
        })
    }
}

You may have noticed this already in the sample above, but in case you haven’t, the MKLocalSearchCompleter uses the delegate pattern to return results and errors, so we have set the delegate for the search completer and implement the MKLocalSearchCompleterDelegate protocol.

extension LocationService: MKLocalSearchCompleterDelegate {
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        // Depending on what you're searching, you might need to filter differently or
        // remove the filter altogether. Filtering for an empty Subtitle seems to filter
        // out a lot of places and only shows cities and countries.
        self.searchResults = completer.results.filter({ $0.subtitle == "" })
        self.status = completer.results.isEmpty ? .noResults : .result
    }

    func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
        self.status = .error(error.localizedDescription)
    }
}

Note that for this example, we’re filtering results that don’t have a subtitle, because we don’t want to list the random Starbucks and Hotels and POIs, but rather only towns, cities and countries. You can of course modify this to match your needs.

Summary

The complete project is available here: GitHub

I think this is a pretty neat solution to quickly look up an address or a point of interest. Let me know how you’re using this and what you’re building.

Happy coding!