YandexMapKit and SwiftUI: reverse geocoding
Given
Development of a mobile application for IOS for vehicle monitoring
Minimum iOS version – 15.5, SwiftUI
Using YandexMapKit to display vehicle locations on the map
We receive data via API using GRPC
Target
It is necessary to provide information about the travel history showing the start and end addresses
Reverse geocoding (obtaining an address using known geographic coordinates) must be performed on the client side, due to the licensing features of YandexMapKit
Note
I will not describe in this article how to connect YandexMapKit to the project. Firstly, it is not difficult, and secondly, there are plenty of descriptions of this process on the Internet, including on the website YandexMapKit SDK.
However, I want to “complain” that it was solely because of Yandex’s mapping that the project started using CocoaPods, because the vendor (as of March 2024) did not condescend to develop a package that can be connected to the project bypassing Pods.
Solution
To achieve our goal, we will define the GeoCoder class and its instance to eliminate duplicates of objects of our class:
import SwiftUI
import Combine
import YandexMapsMobile
final class GeoCoder: ObservableObject {
static let shared = GeoCoder()
// MARK: - Variables
private lazy var searchManager = YMKSearch.sharedInstance().createSearchManager(with: .online)
private var searchSession: YMKSearchSession?
private var searchSessions: [YMKSearchSession] = []
...
}
The number of geocoding requests is limited by the YandexMapKit license, so we will cache geocoding results on the device so that each time the method is called, it first checks the cache. You can choose the caching method yourself. Due to the relatively small cache size, I chose the simplest one – @AppStorage
// Хранилище кэша
@AppStorage("geoCache") var geoCache: [String: String] = [:]
However, for work Dictionary[String: String]
extension required:
extension Dictionary: RawRepresentable where Key == String, Value == String {
public init?(rawValue: String) {
guard let data = rawValue.data(using: .utf8),
let result = try? JSONDecoder().decode([String:String].self, from: data)
else {
return nil
}
self = result
}
public var rawValue: String {
guard let data = try? JSONEncoder().encode(self),
let result = String(data: data, encoding: .utf8)
else {
return "{}" // пустой Dictionary представляем как String
}
return result
}
}
Now we define the method itself.
Please note that for logging I use the function writeLog (_ logText: String, logLevel: OSLogType = .default)
, whose work I will not demonstrate. It uses OSLog and Crashlytics from Google. You can use logging methods for your project.
// MARK: - Methods
func submitSearch(with point: YMKPoint, completion: @escaping () -> Void) {
//проверяем кэш
if let address = self.geoCache["\(point.latitude)_\(point.longitude)"] {
writeLog("GeoCoder->submitSearch(): Already cached \(address)")
completion()
} else {
//в кэше точку не нашли, отправляем запрос на обратное геокодирование
let searchSession = searchManager.submit(
with: point,
zoom: 16,
searchOptions: YMKSearchOptions(),
responseHandler: { response, error in
// обработка ошибок
if let error = error {
writeLog("GeoCoder->submitSearch(): ERROR \(error.localizedDescription)", logLevel: .error)
completion()
}
//обработка результата
guard let response = response else {
return completion()
}
if let obj = response.collection.children.first?.obj?.metadataContainer.getItemOf(YMKSearchToponymObjectMetadata.self) {
let object = obj as! YMKSearchToponymObjectMetadata
// Сохраняем даные в кэше.
self.geoCache["\(point.latitude)_\(point.longitude)"] = object.address.formattedAddress
writeLog("GeoCoder->submitSearch(): SUCCESS \(object.address.formattedAddress)")
completion()
}
}
)
searchSessions.append(searchSession)
}
}
Usage
Don't forget about defining a class object
@ObservedObject var geoCoder = GeoCoder.shared
And about “connecting” it to Struct, which is responsible for displaying a specific trip:
import SwiftUI
import Kingfisher
import SwiftProtobuf
import YandexMapsMobile
struct TrackView: View {
@StateObject var geoCoder = GeoCoder.shared
@State var track: API_V1_TrackInfo
@State var startAddress: String
@State var endAddress: String
init(track: API_V1_TrackInfo) {
self.track = track
self.startAddress = "\( String(track.beginPoint.latitude)), \(String(track.beginPoint.longitude))"
self.endAddress = "\( String(track.endPoint.latitude)), \(String(track.endPoint.longitude))"
}
var body: some View {
...
LazyVStack {
VStack(alignment: .leading){
Text("\(track.begin.toLocaTimetring()) - \(startAddress)")
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(TextAlignment.leading)
Spacer()
Text("\(track.end.toLocaTimetring()) - \(endAddress)")
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(TextAlignment.leading)
}
.onAppear{
let beginPoint = YMKPoint(latitude: track.beginPoint.latitude, longitude: track.beginPoint.longitude)
let endPoint = YMKPoint(latitude: track.endPoint.latitude, longitude: track.endPoint.longitude)
geoCoder.submitSearch(with: beginPoint , completion: {
if let address = geoCoder.geoCache["\(beginPoint.latitude)_\(beginPoint.longitude)"] {
self.startAddress = address
}
})
geoCoder.submitSearch(with: endPoint , completion: {
if let address = geoCoder.geoCache["\(endPoint.latitude)_\(endPoint.longitude)"] {
self.endAddress = address
}
})
}
.frame(maxWidth: .infinity)
.padding(0)
}.padding(0)
...
}
}
Result
As a result, we see the following entries in the log:
Please note that addresses from Yandex are sent by default in the language used by the user's device.