April 5, 2020

MKLocalSearchCompleter results in SwiftUI using Combine

I’ve been playing around with Combine in Swift, specifically trying to understand how SwiftUI leverages magic” property wrappers like @Binding and @ObservedObject to know when your data model changes.

Here’s a quick example of how to Combine-ify” location search completion results from MapKit’s MKLocalSearchCompleter so that they can be displayed in a SwiftUI view.

In a new SwiftUI project, first lay down this SwiftUI wrapper around UISearchBar, borrowed from this article. We could’ve just used a simple TextField, but y’know, it looks prettier this way.

SearchBar.swift

import SwiftUI

struct SearchBar: UIViewRepresentable {

    @Binding var text: String

    class Coordinator: NSObject, UISearchBarDelegate {
        
        @Binding var text: String
        
        init(text: Binding<String>) {
            _text = text
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            text = searchText
        }
    }

    func makeCoordinator() -> SearchBar.Coordinator {
        return Coordinator(text: $text)
    }

    func makeUIView(context: UIViewRepresentableContext<SearchBar>) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        searchBar.searchBarStyle = .minimal
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext<SearchBar>) {
        uiView.text = text
    }
}

Now we add a class to manage the interactions with MKLocalSearchCompleter:

LocationSearchService.swift

import Foundation
import SwiftUI
import MapKit
import Combine

class LocationSearchService: NSObject, ObservableObject, MKLocalSearchCompleterDelegate {
    @Published var searchQuery = ""
    var completer: MKLocalSearchCompleter
    @Published var completions: [MKLocalSearchCompletion] = []
    var cancellable: AnyCancellable?
    
    override init() {
        completer = MKLocalSearchCompleter()
        super.init()
        cancellable = $searchQuery.assign(to: \.queryFragment, on: self.completer)
        completer.delegate = self
    }
    
    func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
        self.completions = completer.results
    }
}

extension MKLocalSearchCompletion: Identifiable {}

Interesting bits:

  • By implementing the ObservableObject protocol, we’re telling downstream consumers (including SwiftUI) this object has properties that change and we’ll let you know when that happens if you subscribe.” By default any @Published properties will automatically get changes published to subscribers.
  • You might be wondering how something actually subscribes to changes, and we actually do that within this class. When we put a $ sign in front of a published property, we get a handle on the Publisher for that property. That allows us to use Combine operators like $searchQuery.assign() to assign new values to the queryFragment property on our MkLocalSearchCompleter. As well as assign(), there’s a tonne of other operators defined in the Publisher docs, for example map() which transforms incoming values into new output values, sink() which runs a closure upon receiving new values, or combineLatest() which takes two different Publishers and outputs the latest values from each.
  • We also implement MKLocalSearchCompleterDelegate to receive the completion results, and we store those in another @Published property.

Next, modify your ContentView.swift to match the snippet below:

ContentView.swift

import SwiftUI

struct ContentView: View {

    @ObservedObject var locationSearchService: LocationSearchService

    var body: some View {
        NavigationView {
            VStack {
                SearchBar(text: $locationSearchService.searchQuery)
                List(locationSearchService.completions) { completion in
                    VStack(alignment: .leading) {
                        Text(completion.title)
                        Text(completion.subtitle)
                            .font(.subheadline)
                            .foregroundColor(.gray)
                    }
                }.navigationBarTitle(Text("Search near me"))
            }
        }
    }
}

Interesting bits:

  • By making locationSearchService an @ObservedObject, SwiftUI is now going to subscribe to changes and know when to refresh our views.
  • The SearchBar we created earlier expects a Binding<String>, which is basically that view saying give me a String value that’s stored elsewhere, but I also want the ability to make changes to it”. We give it a Binding to the search query string by using the special $ sign syntax: $locationSearchService.searchQuery.

Finally, in our SceneDelegate.swift we create the LocationSearchService instance and pass this into our content view. Find the existing line that creates the ContentView and modify it like this:

SceneDelegate.swift

// Create the SwiftUI view that provides the window contents.
let locationSearchService = LocationSearchService()
let contentView = ContentView(locationSearchService: locationSearchService)

Run it

Run your project and you’ll find everything is all hooked up: when we type, we’re assigning new search queries to our MKLocalSearchCompleter, and when the results come back asynchronously using the delegate pattern, SwiftUI knows to refresh our views because we stored the results in a @Published property.

You can see how by implementing protocols like ObservableObject, we can turn existing APIs into reactive” streams in Combine which can be observed by SwiftUI.


iOS SwiftUI


Previous post
Turning webpages into EPUBs on iOS using Scriptable, Shortcuts, and EpubPress