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 thePublisher
for that property. That allows us to use Combine operators like$searchQuery.assign()
to assign new values to thequeryFragment
property on ourMkLocalSearchCompleter
. As well asassign()
, there’s a tonne of other operators defined in thePublisher
docs, for examplemap()
which transforms incoming values into new output values,sink()
which runs a closure upon receiving new values, orcombineLatest()
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 aBinding<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 aBinding
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.