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

When opening, the coordinates are indicated, which are replaced by addresses as they load

When opening, the coordinates are indicated, which are replaced by addresses as they load

As a result, we see the following entries in the log:

Some addresses are already in the cache, the rest are geocoded

Some addresses are already in the cache, the rest are geocoded

Please note that addresses from Yandex are sent by default in the language used by the user's device.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *