I’m not sure exactly how to describe my problem, but I can describe the context in which it appears. I’m developing an iOS app with SwiftUI and I’m using MapKit to integrate an interactive map as part of some form entry. I’m new to MapKit, but from what I understand my map view component should be fairly standard. The issue arises in where this SearchableMap is used. There’s two contexts that I’ve been testing in the code below.
import SwiftUI
import MapKit
import Combine
private 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)
.debounce(for: .milliseconds(95), 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 = []
}
})
}
func getCoordinates(_ queryString: String) async throws -> CLLocationCoordinate2D? {
let searchRequest = MKLocalSearch.Request()
searchRequest.naturalLanguageQuery = queryString
let search = MKLocalSearch(request: searchRequest)
let response = try await search.start()
return response.mapItems.first?.placemark.location?.coordinate
}
}
extension LocationService: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
self.searchResults = completer.results
self.status = completer.results.isEmpty ? .noResults : .result
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
self.status = .error(error.localizedDescription)
}
}
private struct SearchableMap: View {
@State private var position = MapCameraPosition.region(
MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
span: MKCoordinateSpan(latitudeDelta: 2, longitudeDelta: 2)
)
)
@State private var selectedLocation: CLLocationCoordinate2D?
@State private var search: String = ""
@State private var turnOffOptions = false
@State private var autocompleteOptions = true
@StateObject var locationService = LocationService()
@FocusState private var bodyLocationSearchFieldFocused: Bool
var body: some View {
ZStack {
Map(position: $position) {
if selectedLocation != nil {
Marker(search, coordinate: selectedLocation!)
}
}
.mapStyle(.hybrid(elevation: .realistic))
.frame(maxWidth: .infinity, idealHeight: 300, maxHeight: 300)
VStack(spacing: 0) {
HStack {
TextField("Address", text: $locationService.queryFragment)
.padding(7)
.autocorrectionDisabled()
.focused($bodyLocationSearchFieldFocused)
}
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 5))
if !locationService.searchResults.isEmpty && autocompleteOptions {
List {
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
Button(action: {
Task {
if let coordinate = try await locationService.getCoordinates(completionResult.title + " " + completionResult.subtitle) {
position = .region(
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
)
)
locationService.queryFragment = completionResult.title + " " + completionResult.subtitle
selectedLocation = coordinate
autocompleteOptions = false
bodyLocationSearchFieldFocused = false
}
}
}, label: {
VStack(alignment: .leading, spacing: 4) {
Text(completionResult.title)
.font(.headline)
.fontDesign(.rounded)
Text(completionResult.subtitle)
}
})
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(.background)
.frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
Spacer()
}
.padding()
}
}
}
struct ContentView: View {
var body: some View {
VStack {
// Context 1, SearchableMap directly within a View
SearchableMap()
// Context 2, SearchableMap within a Form within a View
Form {
SearchableMap()
}
}
.padding()
}
}
The first case functions exactly as intended: the user types an address in the search bar, then chooses an option from the lists, which updates the value in the search bar and moves the maps location and zoom to appear over that address. The second case, however, has an odd timing issue. The user can type in an address just like normal and the addresses will appear in the list exactly the same as in the first scenario. However, when the user clicks on an address from the list, the address that was in that spot in the list at the LAST text field update will be chosen (even though the text field will display the correct address).
The specific addresses will depend on your area since it does a local search, but as an example let’s say the user types 100 Main in the search bar and the following 3 addresses come up:
- 100 Main St
- 100 Main Ave
- 100 Main Rd
Now lets say the user updates their search to be 100 Main A, and the list updates as follows:
- 100 Main Ave
- 100 Main St
- 100 Main Rd
From here lets say the user clicks on the first item in the list, which will read 100 Main Ave for them. Instead of filling 100 Main Ave for the address and updating the map to that location, the map will fill in 100 Main St and move to that location instead. If the user had clicked on the second list item, the map would go to 100 Main Ave and the third would go to 100 Main Rd. This only happens when the view component is wrapped in a Form, and I’m not sure why it’s happening.
I’ve tried 2 different implementations of the LocationService and SearchableMap. The first iteration used the onChange modifier to get updates to the address query string and I figured that had to be causing the issue so I switched to this approach. It wasn’t until I finished this that I realized that the issue wasn’t with the view modifier.
I very much suspect that there is something I should be doing/handling differently, however I’m still very new to MapKit and still fairly new to SwiftUI so I’m at somewhat of a loss. My best guess is that the issue is in my getCoordinates
function when I attempt to get the coordinates like this: response.mapItems.first?.placemark.location?.coordinate
. Though I’m not sure exactly why this would work outside of a form but not work in it. Any help would be appreciated.