под ред. Череповой Дарьи

Всем привет! Меня зовут Серёжа, и я занимаюсь веб-разработкой и разработкой на iOS вот уже 5 лет. На одном из проектов мне поставили задачу: внедрить Яндекс Карты в приложение. Однако я столкнулся с тем, что в открытом доступе мало нужной и полезной информации об этой теме. Эта статья - мои способы решения задач на Swift с использованием Yandex MapKit. Делюсь с вами своим опытом!

В статье я рассказываю о том, как:

  • Показать точку на карте по координатам или адресу;

  • Выбрать определённую точку и вывести её данные;

  • Отобразить большое количество точек по адресу.

Подготовка

???? Для создания интерфейса я использовал программный метод (UIKit)

Для работы с Yandex MapKit на iOS необходимы:

  • MacOS Catalina и выше

  • Xcode (желательно 12.1 и выше)

  • Homebrew

  • Ruby

  • CocoaPods

  • Ключ для доступа к API Яндекса (можно запросить бесплатно)

  • Целевая версия iOS 12 и выше (iOS 13+ для Apple Silicon)

    Учтите, что требования могут измениться. Статья написана в июне 2023 года.

Работа на Apple Silicon

Во время работы на процессорах Apple Silicon, на версии MapKit SDK ниже 4.3.0 возможны вылеты приложения во время открытия карты. Особенно во время отладки. Пример ошибки:

Пример ошибки на MacBook с процессором M1
Пример ошибки на MacBook с процессором M1

С версией MapKit SDK 4.3.0 (2 марта 2023) появилось следующее обновление:

Для эмуляторов с процессором M1 карта автоматически переключается на использование Metal API.
Источник: Версии MapKit – Яндекс MapKit. Руководство разработчика

После обновления до последней версии вылеты без причины прекратились. На реальном устройстве проблем тоже не возникло.

Демо-приложение Яндекса

Демо-приложение MapKit позволяет использовать возможности Яндекс.Карт в мобильных приложениях для iOS и Android. Здесь есть разделы:

  • Показ карты;

  • Добавление объектов и пинов;

  • Месторасположение пользователя;

  • Пробки;

  • Панорама;

  • Поиск и подсказки;

  • Выбор объекта по клику.

Скриншоты Yandex MapKit Demo Application
Скриншоты Yandex MapKit Demo Application

На всякий случай, оставлю ссылки на руководство для MapKit от разработчика и демо-версию приложения:

Как начать работу с MapKit для iOS – Яндекс MapKit. Руководство разработчика

Yandex MapKit Demo Application – GitHub

Работа с картой

Добавление карт в проект

Для начала необходимо добавить зависимость в CocoaPods, чтобы загрузить библиотеку.

???? Если в проекте не подключён CocoaPods, то нужно инициализировать его. Для этого в терминале в корневой директории прописываем pod init.

После инициализации, проект необходимо открывать с помощью нового файла проекта с расширением .xcworkspace (пример: MapKitDemo.xcworkspace)

pod 'YandexMapsMobile', '4.3.1-full’

Нужно учесть, что есть две версии библиотеки: lite и full. Lite-версия позволяет работать с онлайн и оффлайн картами, показывает локацию и пробки. Full-версия предоставляет маршруты автомобилей, велосипедов, пешеходов, общественного транспорта; поиск; подсказки; геокодирование; отображение панорам.

После добавления зависимостей, появляется возможность сделать import YandexMapsMobile . Добавляем следующие строчки в главный метод AppDelegate:

/* imports */
import YandexMapsMobile

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

	func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
			/* code */
	    
	    /* Init YandexMaps MapKit */
	    YMKMapKit.setApiKey("your-api-key")
	    YMKMapKit.setLocale("ru_RU")
	    YMKMapKit.sharedInstance()
	    
			/* code */
	    return true
	} 

}

Создаём базовый UIView для удобства использования в других модулях:

import UIKit
import YandexMapsMobile

class YBaseMapView: UIView {

    @objc public var mapView: YMKMapView!

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    private func setup() {
        // OpenGl is deprecated under M1 simulator, we should use Vulkan
        mapView = YMKMapView(frame: bounds, vulkanPreferred: YBaseMapView.isM1Simulator())
        mapView.mapWindow.map.mapType = .map
    }

    static func isM1Simulator() -> Bool {
        return (TARGET_IPHONE_SIMULATOR & TARGET_CPU_ARM64) != 0
    }
}

Готово!

Создание карты и добавление точки

Добавляем заранее созданный view в новый компонент, чтобы отобразить карту:

final class YandexMapSampleViewController: UIViewController {
	// 1. Создать элемент
	lazy var mapView: YMKMapView = YBaseMapView().mapView
	
	
	override func viewDidLoad() {
		super.viewDidLoad()
	  // 2. Добавить в родительский view во viewDidLoad()
		view.addSubview(mapView)
	
		// 3. Настроить constraints. Приведён пример со SnapKit
		mapView.snp.makeConstraints {
		    $0.leading.trailing.top.equalToSuperview()
		    $0.bottom.equalTo(view.safeAreaLayoutGuide)
		}
	
	  // 4. Вызов функции добавления точки на карту
		self.addPlacemarkOnMap()
	}

}

Теперь создаём отдельную функцию для добавления точки на карту:

func addPlacemarkOnMap() {
   // Задание координат точки
	let point = YMKPoint(latitude: 47.228836, longitude: 39.715875)
	let viewPlacemark: YMKPlacemarkMapObject = mapView.mapWindow.map.mapObjects.addPlacemark(with: point)
	
  // Настройка и добавление иконки
	viewPlacemark.setIconWith(
	    UIImage(named: "map_search_result_primary")!, // Убедитесь, что у вас есть иконка для точки
	    style: YMKIconStyle(
	        anchor: CGPoint(x: 0.5, y: 0.5) as NSValue,
	        rotationType: YMKRotationType.rotate.rawValue as NSNumber,
	        zIndex: 0,
	        flat: true,
	        visible: true,
	        scale: 1.5,
	        tappableArea: nil
	    )
	)
}

Результат:

Добавление точки на карту
Добавление точки на карту

Интерактивные точки на карте

Чтобы сделать точку кликабельной, необходимо реализовать интерфейс YMKMapObjectTapListener и указать его при создании точки. Я использую расширение классов Swift, которое упрощает понимание кода и не загромождает основной класс.

В реализации прослушивателя событий нам предлагают создать имплементацию для одной функции:

extension YandexMapSampleViewController: YMKMapObjectTapListener {
    func onMapObjectTap(with mapObject: YMKMapObject, point: YMKPoint) -> Bool {
        // your code here
    }
}

Пример:

extension YandexMapSampleViewController: YMKMapObjectTapListener {
    func onMapObjectTap(with mapObject: YMKMapObject, point: YMKPoint) -> Bool {
        guard let placemark = mapObject as? YMKPlacemarkMapObject else {
            // Сценарий на случай ошибки
            return false
        }
        // Сценарий на случай успеха. Бизнес-логику добавляют сюда.
				// Пример
				self.focusOnPlacemark(placemark)
        return true
    }

		func focusOnPlacemark(_ placemark: YMKPlacemarkMapObject) {
			// Поменять расположение камеры, чтобы сфокусироваться на точке
			mapView.mapWindow.map.move(
            with: YMKCameraPosition(target: placemark.geometry, zoom: 18, azimuth: 0, tilt: 0),
            animationType: YMKAnimation(type: YMKAnimationType.smooth, duration: duration),
            cameraCallback: nil
      )
		}
}

Чтобы прослушиватель событий работал, нужно указать его при создании точки:

// Создание переменной точки не показано (см. пример выше)
viewPlacemark.addTapListener(with: self)

После этого любой клик по точке будет следовать через созданную функцию.

Пользовательские данные точки

Задача: приблизить карту и отобразить данные на экране с помощью нажатия на точку на карте. Решение: при создании каждой точки нужно заполнить переменную YMKPlacemarkMapObject.userData нужными данными. Это может быть строка с названием/адресом, или же другой объект любого типа.

let viewPlacemark: YMKPlacemarkMapObject = mapView.mapWindow.map.mapObjects.addPlacemark(with: point)
/* Здесь будет код, добавляющий иконку к точке */
viewPlacemark.userData = "Точка на карте" 

Чтобы использовать переданные точки, нужно обратиться к ним и, при необходимости, привести к желаемому типу данных.

Пример функции фокусировки камеры на точке:

func focusOnPlacemark(placemark: YMKPlacemarkMapObject) {
	// Поменять расположение камеры, чтобы сфокусироваться на точке
	mapView.mapWindow.map.move(
	      with: YMKCameraPosition(target: placemark.geometry, zoom: 18, azimuth: 0, tilt: 0),
	      animationType: YMKAnimation(type: YMKAnimationType.smooth, duration: duration),
	      cameraCallback: nil // Опциональный callback по завершению работы камеры
	)

	if let placemarkName: String = placemark.userData as? String {
		// Пример
		self.displaySelectedPlacemarkName(placemarkName)
	} else {
		// do nothing
	}
}

func displaySelectedPlacemarkName(_ placemarkName: String) {
	// your code here
}

Пример заранее созданной панели для отображения данных точки:

Пример действия при нажатии на точку
Пример действия при нажатии на точку

Поиск по адресу

Для поиска по адресу (прямое геокодирование), используйте full версию MapKit (lite не подойдёт).

Делаем так: отправляем адрес и получаем в ответ координаты, которые можно показать на карте.

Пример класса с поиском:

final class YandexMapsAddressSearchInteractor {
	lazy var searchManager: YMKSearchManager? = YMKSearch.sharedInstance().createSearchManager(with: .combined)
  var searchSession: YMKSearchSession?
  
	// Окно поиска
  let BOUNDING_BOX = YMKBoundingBox(
      southWest: YMKPoint(latitude: 55.55, longitude: 37.42),
      northEast: YMKPoint(latitude: 55.95, longitude: 37.82)
  )


	func searchAddress(_ address: String?, completion: @escaping(YMapsSearchVoid)) {
	  guard let address = address else {
	      return
	  }
	
	  searchManager = YMKSearch.sharedInstance().createSearchManager(with: .combined)
	  
		// Callback функция, которая выполняется по завершению поиска
	  let responseHandler = { (searchResponse: YMKSearchResponse?, error: Error?) -> Void in
	      if let response = searchResponse {
						// Передаваемая callback функция. Обрабатывать результат нужно здесь
	          completion(response)
	      } else {
	          let searchError = (error! as NSError).userInfo[YRTUnderlyingErrorKey] as! YRTError
	          var errorMessage = L10n.ymapsUnknownError
	          if searchError.isKind(of: YRTNetworkError.self) || searchError.isKind(of: YMKSearchCacheUnavailableError.self) {
	              errorMessage = L10n.ymapsNetworkError
	          } else if searchError.isKind(of: YRTRemoteError.self) {
	              errorMessage = L10n.ymapsServerError
	          }
	          // showErrorMessage(errorMessage)
	      }
	  }
	  
	  searchSession = searchManager!.submit(
	      withText: address,
	      geometry: YMKGeometry(boundingBox: BOUNDING_BOX),
	      searchOptions: YMKSearchOptions(),
	      responseHandler: responseHandler
	  )
	}
}

Пример использования поиска:

func showAddressOnMap(_ address: String) {
	interactor?.searchAddress(address) { [weak self] response in
		// Обработка только первого результата из ответа
    if response.collection.children.count > 0 {
        let searchResults: [YMKGeoObjectCollectionItem] = response.collection.children

        if let mapObject = searchResults[0].obj {
            if let point = mapObject.geometry.first?.point {
                self?.view?.addPlacemarkOnMap(point)
            }
        }
    } else {
		  // self?.showSearchError("No results found")
    }
	}
}

Для более детального изучения поиска Яндекс Карт советую ознакомиться с этой статьёй. В ней описана работа поиска и его модулей с примерами. Учтите, что большинство примеров приведены для работы с Android, но они актуальны и для iOS.

Вот, как описывается параметр geometry в этой статье:

Параметр geometry чуть более хитрый. В зависимости от того, какая именно геометрия передана, поиск будет вести себя по-разному:

Если передать точку, то поиск будет производиться в небольшом окне рядом с этой точкой. Если передать прямоугольное окно (BoundingBox) или полигон из четырёх точек, то оно будет использовано как окно поиска. Простой пример такого окна – видимая область карты. Наконец, если передать полилинию, то описывающее её окно будет использовано как окно поиска, а ранжирование будет производиться с учётом этой полилинии.

Работа со множеством точек

Если нужно показать множество точек за один раз, добавляем несколько сотен (или тысяч) точек на карту по примеру, описанному выше. Но как большое количество точек будет выглядеть на карте? И что делать, если есть только список адресов?

Кластеризация

Чтобы визуально не нагружать карту, применим кластеризацию точек.

вверху – без кластеризации; внизу – с кластеризацией
вверху – без кластеризации; внизу – с кластеризацией

Реализация похожа на обычное добавление точки на карту. Разница в том, что вместо mapView.mapWindow.map.mapObjects.addPlacemark() используется clusteredColletion.addPlacemark(), создаваемая с помощью mapView.mapWindow.map.mapObjects.addClusterizedPlacemarkCollection(). После добавления точек в кластер(-ы), вызываем функцию clusteredColletion?.clusterPlacemarks() для правильного отображения кластеров на карте.

final class YandexMapClusterSampleViewController: UIViewController {
	// Переменная коллекции-кластера
	private var clusteredColletion: YMKClusterizedPlacemarkCollection? = nil
	
	func viewDidLoad() {
		super.viewDidLoad()
		// Инициализация коллекции-кластера
		clusteredColletion = mapView.mapWindow.map.mapObjects.addClusterizedPlacemarkCollection(with: self)	
		renderClusters()
	}

	// Фукнция для добавлении точки в кластер
	func addPlacemarkToCluster(point: YMKPoint) {
		guard let clusteredColletion = clusteredColletion else {
        return
    }
		// Добавление точки в кластер
		let viewPlacemark: YMKPlacemarkMapObject = clusteredColletion.addPlacemark(with: point)
		
	  // Настройка и добавление иконки
		viewPlacemark.setIconWith(
		    UIImage(named: "map_search_result_primary")!, // Убедитесь, что у вас есть иконка для точки
		    style: YMKIconStyle(
		        anchor: CGPoint(x: 0.5, y: 0.5) as NSValue,
		        rotationType: YMKRotationType.rotate.rawValue as NSNumber,
		        zIndex: 0,
		        flat: true,
		        visible: true,
		        scale: 1.5,
		        tappableArea: nil
		    )
		)
	}
	
		// Фукнция для отображения кластера
	private func renderClusters() {
	  self.clusteredColletion?.clusterPlacemarks(withClusterRadius: 60, minZoom: UInt(OutletMapView.DEFAULT_CAMERA_ZOOM))
  }

}

Чтобы кастомизировать визуальную часть (к примеру, как на скриншоте), нужно реализовать функцию, которая динамически создаёт новое изображение кластера в зависимости от количества точек, сгруппированных в нём. Это делается следующим образом:

extension YandexMapClusterSampleViewController: YMKClusterListener {
		func onClusterAdded(with cluster: YMKCluster) {
        cluster.appearance.setIconWith(clusterImage(cluster.size))
        cluster.addClusterTapListener(with: self)
    }    

    func clusterImage(_ clusterSize: UInt) -> UIImage {
        let scale = UIScreen.main.scale
        let text = (clusterSize as NSNumber).stringValue
        let font = UIFont.systemFont(ofSize: FONT_SIZE * scale)
        let size = text.size(withAttributes: [NSAttributedString.Key.font: font])
        let textRadius = sqrt(size.height * size.height + size.width * size.width) / 2
        let internalRadius = textRadius + MARGIN_SIZE * scale
        let externalRadius = internalRadius + STROKE_SIZE * scale
        let iconSize = CGSize(width: externalRadius * 2, height: externalRadius * 2)

        UIGraphicsBeginImageContext(iconSize)
        let ctx = UIGraphicsGetCurrentContext()!

        ctx.setFillColor(Asset.green.color.cgColor)
        ctx.fillEllipse(in: CGRect(
            origin: .zero,
            size: CGSize(width: 2 * externalRadius, height: 2 * externalRadius)));

        ctx.setFillColor(UIColor.white.cgColor)
        ctx.fillEllipse(in: CGRect(
            origin: CGPoint(x: externalRadius - internalRadius, y: externalRadius - internalRadius),
            size: CGSize(width: 2 * internalRadius, height: 2 * internalRadius)));

        (text as NSString).draw(
            in: CGRect(
                origin: CGPoint(x: externalRadius - size.width / 2, y: externalRadius - size.height / 2),
                size: size),
            withAttributes: [
                NSAttributedString.Key.font: font,
                NSAttributedString.Key.foregroundColor: UIColor.black])
        let image = UIGraphicsGetImageFromCurrentImageContext()!
        return image
    }
}

Пример кода, который приближает камеру при нажатии на кластер:

extension YandexMapClusterSampleViewController: YMKClusterTapListener {
	func onClusterTap(with cluster: YMKCluster) -> Bool {
        mapView.mapWindow.map.move(
            with: YMKCameraPosition(target: cluster.appearance.geometry, zoom: 20, azimuth: 0, tilt: 0),
            animationType: YMKAnimation(type: YMKAnimationType.smooth, duration: 0,4),
            cameraCallback: completion
        )
        return true
  }
}

???? Рекомендую дополнительно реализовать интерфейс YMKMapCameraListener с его методом onCameraPositionChanged и хранить значение cameraPosition.zoom, чтобы можно было вычислять необходимый зум камеры и менять его без резких скачков.

Вы супер! Теперь карта визуально не перегружена множеством точек!

Множественное геокодирование

Если у точек нет заранее сохранённых координат, нужно искать их по адресу. В случае работы с одной-двумя точками запрашивать API Яндекса с клиента вполне обычное дело, но когда речь идёт о сотнях – или даже тысячах точек – разумным решением будет использовать множественное геокодирование на стороне сервера. Выполнять тысячи запросов с мобильного устройства невыгодно с точки зрения производительности и лимита запросов согласно лицензии.

Множественное геокодирование работает так: сервер получает список адресов, и по каждому делает запрос к HTTP Геокодеру Яндекса.

На момент написания статьи, Яндекс опубликовал всего один пост на эту тему (ещё в 2014 году). К сожалению, в своей документации Яндекс прилагает всего один пример и библиотеку, написанную для Node.js. Для применения множественного геокодирования в приложениях на других языках программирования придётся реализовывать вызовы к API вручную.

Поиск по карте – Множественное геокодирование. Яндекс Документация

node-multi-geocoder – npm

node-multi-geocoder – GitHub

???? Для работы с HTTP Геокодером потребуется ключ разработчика для доступа к API JavaScript и Геокодеру.

Ниже приведён код, использующий библиотеку node-multi-geocoder.

Пример из GitHub не работал из-за параметра apikey, который был описан как key. Я проверил.

let geocoder = new MultiGeocoder({ provider: 'yandex', coordorder: 'latlong'});
let provider = geocoder.getProvider();

let getRequestParams = provider.getRequestParams;
provider.getRequestParams = function() {
    let result = getRequestParams.apply(provider, arguments);
    result.apikey = "your-key-here";
    return result;
}

let geoResponse = await geocoder.geocode([
    "address 1",
    "address 2",
    "address 3",
    "address 4"
]);
Аналоги библиотеки

Нашёл репозиторий с аналогичным функционалом, написанном на C#:

YandexGeocoder – GitHub

И php:

yandex-geocoder – GitHub

Нюансы пользовательского соглашения

Пользоваться API Яндекс Карт можно бесплатно, но в таком случае запрещается сохранять результат работы геокодера. Другими словами, если искать координаты по адресу, то координаты сохранять к себе в базу данных запрещено.

Коммерческая лицензия позволяет снять это ограничение:

Коммерческая версия API Яндекс.Карт – Яндекс Документация

???? Коммерческая лицензия делится на стандартную (от 120 тыс. рублей в год) и расширенную (от 620 тыс. рублей в год). В стандартной лицензии запрещено сохранять или изменять данные, полученные с помощью API. Источник

В качестве компромисса, Яндекс предлагает кэшировать результат максимум на 30 дней. Советую обращаться в поддержку Яндекса с вашим конкретным случаем и уточнять юридические вопросы.

Заключение

MapKit SDK Яндекс Карт для iOS открывает полезные возможности, которые важно уметь применять iOS разработчику в России. Документация Яндекса недавно обновилась, и теперь там можно найти описание классов и методов на Swift, но понятных примеров не так много. В своей статье я раскрыл основные подходы работы с MapKit от Яндекса и поделился личном опытом. Надеюсь, статья вам поможет!

Полезные ссылки

Как начать работу с MapKit для iOS – Яндекс MapKit. Руководство разработчика

Yandex MapKit Demo Application – GitHub

Сказ о том как я Yandex MapKit на iOS обновлял или карты, деньги, 2 мапкита

Яндекс.Карты: Зашел на контроллер карт — сразу получил позицию пользователя (окей, ну а теперь серьезно)

Поиск в MapKit: Tips & Tricks

Комментарии (0)