Маршрутизация с MapKit и Core Location

Если вы нашли опечатку в тексте, выделите ее и нажмите CTRL + ENTER.

Apple внесла основные обновления для фреймворков MapKit и CoreLocation на iOS 9, а именно, более подробные карты, новые возможности транзитной маршрутизации и упрощенная система поиска локализации. Стремление Apple обогнать своих конкурентов (а именно Google Maps) должно быть достаточным стимулом присоединиться к стремительно развивающимся Apple Maps, если вы еще этого не сделали! 

В этом уроке вы создадите приложение с именем Отмщение Прокрастинатора, которое поможет вам найти самый быстрый маршрут "туда-обратно" от начальной точки до двух точек назначения и обратно. Приложение получает данные, используя CoreLocation, и затем находит самый быстрый маршрут между этими адресами, используя MapKit.

Приступаем

Загрузите стартовый проект и откройте ProcrastinatorsRevenge.xcodeproj в Xcode. Запустите его, и чтобы понять, как оно работает, пощелкайте внутри приложения.

Первый экран приложения имеет три текстовых поля - одно для исходного / конечного адреса и два поля для промежуточных остановок. Нажмите на Route It!, и приложение перейдет на второй экран с картой.

Использование MapKit с CoreLocation

Как именно MapKit относится к CoreLocation?

В документации Apple просто говорится  "Фреймворк Core Location позволяет определить текущее местоположение" и, хотя вы будете использовать эту особенность CoreLocation для предварительного заполнения отправной точки пользователя, возможность CoreLocation изменять координаты и частично адреса в удобные адресные отображения объектов на карте, используя свой класс CLGeocoder, будет иметь огромное значение для завершения первой части этого урока.

Во второй части урока, вы будете конвертировать CLPlacemark, возвращенный из CLGeocoder в MKPlacemark, и, в свою очередь, конвертировать этот MKPlacemark в MKMapItem. Затем Вы сможете использовать MKMapItem для запуска MKDirectionsRequest который, наконец, возвратить данные MKRoute от Apple.

Таким образом, резюмируем: CLGeocoder > CLPlacemark > MKPlacemark > MKMapItem > MKDirectionsRequest > MKRoute.

Это может показаться сложным, но, к счастью для вас, ваша давно потерянная Сумасшедшая тетя Люси дала вам отличную мотивацию, чтобы начать!

Охота на миллионы тети Люси

После того, как вы целую жизнь верили, что тетя Люси это больше, чем миф, который ваши родители придумали, чтобы оградить вас от возможных последствий чрезмерного употребления наркотиков, неожиданно появляется письмо по почте от самой легенды:

Перевод: Простите великодушно за мое долгое отсутствие. Я много путешествовала по суше, морю и пространству, собирая сокровища мира- в том числе и зуб мудрости Лохнесского чудовища и большую коллекцию ногтей инопланетян. Я накопила $ 500000000, ошибочно приняв 10 тонн золотых самородков за экскременты эльфов… впрочем последние даже лучше продаются на сегодняшний день…

Вас сердечно приглашают на 105 Any Way, озеро Jackson, Texas для того, чтобы принять участие в соревновании за долю моего наследства. Вы будете соревноваться в короткой гонке по уборке мусора в лабиринтах нашего города и кто придет первый с трофеями, тот и получит долю моего наследства.

Искренне ваша  (до подписания силы не имеет)

Тетушка Люси

Ой-ой. Ты то известен, как вечно опаздывающий среди своих неприятно пунктуальных двоюродных братьев. Но это приложение станет вашем шансом, чтобы отомстить и забрать миллионы тети Люси себе! 

Перво-наперво: вы упростите задачу для пользователей и сделаете возможным заполнение поля начальной / конечной позиции текущим адресом пользователя.

Получение текущего адреса с помощью CoreLocation

В ViewController.swift, добавьте следующий код, заменив существующий viewDidLoad, для настройки и создания экземпляра объекта CLLocationManager:

// 1
let locationManager = CLLocationManager()
 
override func viewDidLoad() {
  super.viewDidLoad()
  originalTopMargin = topMarginConstraint.constant
  // 2
  locationManager.delegate = self
  locationManager.requestWhenInUseAuthorization()
  // 3
  if CLLocationManager.locationServicesEnabled() {
    locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    locationManager.requestLocation()
  }
}

Рассмотрим все пронумерованные секции по порядку:

  1. Вы объявляете locationManager как глобальный экземпляр, чтобы поддерживать сильную ссылку. 
  2. В viewDidLoad, после того как вы установили делегата location менеджера, то вы явно просите разрешения на доступ местоположение пользователя, когда приложение используется. Этот сигнал больше не будет отображаться при последующем запуске приложения, как только пользователь выберет ответ. 
  3. После активации системы определения местоположения, установите нужную точность определения местонахождения CLLocationManager, затем запросите текущее местоположение, используя .requestLocation() (представлена в iOS 9).

 Запустите ваше приложение. Получили уведомление с запросом на авторизацию? Нет?

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

Откройте Supporting Files > info.plist и выполните следующие действия: 

  1. Добавьте "NSLocationWhenInUseUsageDescription" в качестве ключа в Information Property List
  2. Оставьте Type как String
  3. Установите Value сообщение, для того, чтобы объяснить пользователю, почему вы просите их разрешить определить его позицию: "Позвольте нам получить доступ к вашему текущему местоположению, чтобы мы могли автоматически заполнить ваш исходный / конечный пункт назначения".

Заметка

NSLocationWhenInUseUsageDescription | .requestWhenInUseAuthorization() позволяет приложению иметь доступ к местоположению пользователя в то время как используется приложение.
NSLocationAlwaysUsageDescription | .requestAlwaysAuthorization()  позволяет приложению получить доступ к местоположению пользователя, даже в то время как приложение в фоновом режиме.

Запустите приложение снова. Теперь уведомление должно появиться, как и ожидалось:

Нажмите Allow (Разрешить). Теперь менеджер позиции (location manager) знает ваше местоположение в настоящее время. 

Далее создайте объект CLGeocoder для reverse geocode (обратного геокода) текущего CLLocationManager's CLLocation. Обратное геокодирование- процесс превращения координат location в читабельный адрес. 

Прокрутите вниз ViewController.swift и добавьте следующий код для locationManager(_:didUpdateLocations:locations:):

CLGeocoder().reverseGeocodeLocation(locations.last!,
  completionHandler: {(placemarks:[CLPlacemark]?, error:NSError?) -> Void in
  if let placemarks = placemarks {
    let placemark = placemarks[0]
  }
})

reverseGeocodeLocation(_:completionHandler:) возвращает массив меток (placemarks) в его обработчик по завершению. Для большинства результатов геокодирования, этот массив будет содержать только один элемент; в редких случаях, одно location может возвратить несколько близлежащих местоположений. В этом случае, любого из этих местоположений, возможно placemarks[0], должно быть достаточно. Вы также можете остановить обновление местоположения, если уже нашли подходящую метку. 

Теперь, когда вы нашли CLPlacemark, отображающую текущий адрес пользователя, вам нужно связать данные о местоположении с соответствующим текстовым полем. Чтобы сделать это, используйте структуру кортежа для группировки нескольких значений в одно составное значение. 

Прямо над viewDidLoad добавьте следующую глобальную переменную для связи каждого UITextField с ему соответствующим MKMapItem:

var locationTuples: [(textField: UITextField!, mapItem: MKMapItem?)]!

Вы будете хранить MKMapItems (не CLPlacemarks) для местоположения пользователя, так как это тип объекта, который вы в конечном итоге будете использовать для инициализации MKDirectionsRequest необходимого для расчета маршрута. В viewDidLoad добавьте:

locationTuples = [(sourceField, nil), (destinationField1, nil), (destinationField2, nil)]

Здесь вы предварительно заполняете массив кортежами, каждый из которых содержит текстовое поле и nil значение в MKMapItem, что в конечном итоге может быть связано с этим текстовым полем.

 Этот массив является структурой данных location, которая будет служить вам верой и правдой до конца этого урока.

Прокрутите вниз до locationManager(_:didUpdateLocations:location:) и добавьте следующий фрагмент после объявления placemark внутри обработчика по завершению reverseGeocodeLocation(_:completionHandler:):

self.locationTuples[0].mapItem = MKMapItem(placemark:
  MKPlacemark(coordinate: placemark.location!.coordinate,
  addressDictionary: placemark.addressDictionary as! [String:AnyObject]?))

Это добавит отображение MKMapItem текущего местоположения пользователя в первый кортеж locationTuples

Затем добавьте следующую функцию основному классу ViewController (не расширение), чтобы преобразовать данные местоположения в читаемый адрес:

func formatAddressFromPlacemark(placemark: CLPlacemark) -> String {
  return (placemark.addressDictionary!["FormattedAddressLines"] as! 
    [String]).joinWithSeparator(", ")
}

formatAddressFromPlacemark(_:) принимает строку за строкой массив адреса, хранимый в ключе "FormattedAddressLines" адресного словаря CLPlacemark, а затем объединяет содержание с запятыми между каждым элементом. 

Вернитесь к locationManager(_:didUpdateLocations:locations:) после добавленной инициализации self.locationTuples[0].mapItem и добавьте после нее: 

self.sourceField.text = self.formatAddressFromPlacemark(placemark)

У UITextField  появляется новый адрес.

Внутри того же блока if let, добавьте следующее:

self.enterButtonArray.filter{$0.tag == 1}.first!.selected = true

В начальном проекте, выделенный текст кнопок уже предварительно установлен, теги полей и теги кнопок назначены в цифровой последовательности и кнопки Enter все связаны с IBOutletCollection enterButtonArray помощью Interface Builder. Приведенный выше код находит и выбирает кнопку Enter с тегом 1, то есть кнопку Enter следующую от источника UITextField также с тегом 1, так что текст кнопки изменяется на ✓, чтобы отразить его выбранное состояние. 

Запустите ваше приложение на симуляторе. Предположим, что Apple HQ будет дефолтным текущим местоположением, то ваше текстовое поле должно содержать “Apple Inc., 2 Infinite Loop, Cupertino, CA 95014-2083, United States”:

Пришло время внести изменения в адрес Сумасшедшей тети Люси! 

Щелкните в любом месте в пределах симулятора для раскрытия панели меню, выберите Debug > Location > Custom location…:

Введите координаты тети Люси:

Запустите еще раз приложение. Теперь должно появится следующее: "105 Any Way St, Lake Jackson, TX, 77566-4198, United States" в поле "исходный/конечный" адрес

Далее, нам нужно скорректировать вводимую пользователем информацию для того, чтобы адрес отображался полностью и точно и создания MKMapItems этих адресов, чтобы связать их с соответствующими текстовыми полями. 

Обработка ввода значений пользователем через CoreLocation

Продолжаем в ViewController.swift, обновим addressEntered(_:)  следующим образом:

@IBAction func addressEntered(sender: UIButton) {
  view.endEditing(true)
  // 1
  let currentTextField = locationTuples[sender.tag-1].textField
  // 2
  CLGeocoder().geocodeAddressString(currentTextField.text!,
    completionHandler: {(placemarks: [CLPlacemark]?, error: NSError?) -> Void in
    if let placemarks = placemarks {
 
    } else {
 
    }
  })
}

Вот то, что вы добавили: 

  1. В Interface Builder каждой кнопке Enter был дан тег, соответствующий ее порядковому номеру сверху вниз: 1, 2 и 3, соответственно. Вы можете использовать sender.tag для того, чтобы найти соответствующее текстовое поле. 
  2. Передаем geocode адрес, используя метод CLGeocoder geocodeAddressString(_:completionHandler:)

В отличие от reverseGeocodeLocation(_:completionHandler:), geocodeAddressString(_:completionHandler:) может часто возвращать более одного CLPlacemark поскольку вводимый текст часто не дает точного совпадения в одним location. К счастью, мы уже создали подкласс UITableView, для того чтобы позволить пользователю выбрать адрес из возвращенных CLPlacemarks.

Взгляните на AddressTableView.swift. Как должно быть ясно из tableView(_:numberOfRowsInSection:) и tableView(_:cellForRowAtIndexPath:) вы будете использовать массив addresses, объявленный в верхней части класса в глобальной переменной для заполнения таблицы. Добавьте следующую функцию в класс ViewController:

func showAddressTable(addresses: [String]) {
  let addressTableView = AddressTableView(frame: UIScreen.mainScreen().bounds,
    style: UITableViewStyle.Plain)
  addressTableView.addresses = addresses
  addressTableView.delegate = addressTableView
  addressTableView.dataSource = addressTableView
  view.addSubview(addressTableView)
}

Здесь вы создаете AddressTable и установливаете его массив addresses с помощью CLPlacemarks, возвращенный geocodeAddressString(_:completionHandler:).

Вернемся к addressEntered(_:).Внутри блока if let placemarks = placemarks в обработчике по завершению geocodeAddressString(_:completionHandler:) добавляем:

var addresses = [String]()
for placemark in placemarks {
  addresses.append(self.formatAddressFromPlacemark(placemark))
}
self.showAddressTable(addresses)

Создаем маршрут, соединяя placemarks, заполняем новый массив адресов Strings, а затем передаем их вместе в showAddressTable(_:).

 Запускаем приложение. Взгляните на подсказку тети Люси Clue # 1, чтобы выяснить, какой адрес ввести в поле “Stop # 1”:

Перевод: Ключ к разгадке #1. Между этой дорогой, той дорогой и какой-либо другой дорогой. Ехала обезьяна с хорьком. Обезьяна захотела на завтрак булочку с кунжутом. “Я тоже, ХЛОП!” сказал хорек.

Хм ... Эта дорога, та дорога, и какая-либо другая - так можно сказать о всех улицах в Лейк-Джексоне (что достаточно смущает). Вы ищете сендвич на завтрак в ресторанчике, проезжая где-то между. Эта подсказка звучит очень похоже на песню "Pop! Идет Хорек " (Pop! Goes the Weasel). "Pop! Goes the Weasel " эта мелодия часто бывает в коробочках с выпрыгивающими попрыгунчиками (примеч. jack-in-the-box)  ... вот оно что! Location # 1 Закусочная “Jack in the Box” по адресу 165 Oyster Creek Dr.

Печатаем "165 Oyster Creek Dr, Lake Jackson, TX"; появляется таблица и полный адрес в качестве опции:

Что произойдет, когда вы выберите адрес? Ничего особенного! :] Таблица исчезнет, но адрес остается прежним. Пришло время это изменить. 

При выборе строки, содержащей адрес, вы хотите чтобы автоматически установленное соответствующее текстовое поле, содержало выбранный адрес, обновите массив locations, чтобы он содержал соответствующий MKMapItem и сделайте соответствующую кнопку Enter выделенной. 

Обновите showAddressTable(_:)  в ViewController.swift таким образом:

func showAddressTable(addresses: [String], textField: UITextField,
  placemarks: [CLPlacemark], sender: UIButton) {
 
  let addressTableView = AddressTableView(frame: UIScreen.mainScreen().bounds, style: UITableViewStyle.Plain)
  addressTableView.addresses = addresses
  addressTableView.currentTextField = textField
  addressTableView.placemarkArray = placemarks
  addressTableView.mainViewController = self
  addressTableView.sender = sender
  addressTableView.delegate = addressTableView
  addressTableView.dataSource = addressTableView
  view.addSubview(addressTableView)
}

Здесь вы переносите AddressTableView текущее поле текст, метки, указатель на текущий экземпляр ViewController.swift так что вы можете легко изменить его массив locationTuples и кнопки Enter

Внутри geocodeAddressString(_:completionHandler:), обновите вызов showAddressTable(_:), чтобы он прошел соответствующие параметры:

self.showAddressTable(addresses, textField: currentTextField,
    placemarks: placemarks, sender: sender)

Затем в блоке else, который следует непосредственно после вызова showAddressTable: добавьте уведомление:

self.showAlert("Address not found.")

 Если geocodeAddressString(_:completionHandler:)  не возвращает никаких placemarks, выйдет ошибка.

Затем добавьте следующее в tableView(_:didSelectRowAtIndexPath:) в AddressTable.swift’s:

// 1
if addresses.count > indexPath.row {
  // 2
  currentTextField.text = addresses[indexPath.row]
  // 3
  let mapItem = MKMapItem(placemark:
    MKPlacemark(coordinate: placemarkArray[indexPath.row].location!.coordinate,
    addressDictionary: placemarkArray[indexPath.row].addressDictionary
    as! [String:AnyObject]?))
  mainViewController.locationTuples[currentTextField.tag-1].mapItem = mapItem
  // 4
  sender.selected = true
}

Давайте рассмотрим, что происходит по порядку:

  1. Поскольку последняя строка в таблице это "Ни один из вышеперечисленных,” вам только нужно обновить текстовое поле и связанный с ним объект на карте, когда строка меньше, чем длина массива addresses
  2. Обновите текущее текстовое поле, чтобы оно содержало выбранный адрес. 
  3. Создайте MKMapItem с placemark, соответствующей выбранной строке и связывающей MKMapItem с текущим текстовым полем в массиве mainViewController locationTuples
  4. Выберите текущую кнопку Enter.

Запустите приложение. Введите адрес в поле Stop # 1 и нажмите Enter, затем выберите правильный адрес в таблице. Текстовое поле, массив кортежа, и кнопка Enter должна обновиться соответсвенно:

Итак с ViewController.swift еще не закончили! Осталось еще несколько нерешенных задач.

Обновите textField(_:shouldChangeCharactersInRange:replacementString:):

func textField(textField: UITextField,
  shouldChangeCharactersInRange range: NSRange,
  replacementString string: String) -> Bool {
 
  enterButtonArray.filter{$0.tag == textField.tag}.first!.selected = false
  locationTuples[textField.tag-1].mapItem = nil
  return true
}

Когда пользователь редактирует поле, вы аннулируете MKMapItem, поскольку он не может больше применяться и вам нужно отменить соответствующую кнопку Enter, чтобы пользователь знал, что он должен будет повторно выбрать правильный адрес. 

Затем, обновите swapFields(_:) следующим образом:

@IBAction func swapFields(sender: AnyObject) {
  swap(&destinationField1.text, &destinationField2.text)
  swap(&locationTuples[1].mapItem, &locationTuples[2].mapItem)
  swap(&self.enterButtonArray.filter{$0.tag == 2}.first!.selected, &self.enterButtonArray.filter{$0.tag == 3}.first!.selected)
}

Когда пользователь нажимает "↑ ↓", вы должны поменять текст, который MKMapItems содержит в индексах 1 и 2 locationTuples и выбранные состояния 2 и 3 кнопок Enter.

Наконец, нужно подготовить приложение для перехода в DirectionsViewController, где вы будете рассчитывать маршрут с помощью переопределения нескольких методов класса NSSeguePerforming.

В основном классе ViewController (как вариант, под методом getDirections(_:)) запишите метод shouldPerformSegueWithIdentifier(_:sender:)

override func shouldPerformSegueWithIdentifier(identifier: String, sender: AnyObject?) -> Bool {
  if locationTuples[0].mapItem == nil ||
    (locationTuples[1].mapItem == nil && locationTuples[2].mapItem == nil) {
    showAlert("Please enter a valid starting point and at least one destination.")
    return false
  } else {
    return true
  }
}

Условные выражения if-else предотвращает переход, если исходное положение и хотя бы один из пунктов назначения не установлены. 

Подготовьте массив locationTuples для следующего вида, добавив следующее только-для-чтения, вычисляемое свойство над viewDidLoad:

var locationsArray: [(textField: UITextField!, mapItem: MKMapItem?)] {
  var filtered = locationTuples.filter({ $0.mapItem != nil })
  filtered += [filtered.first!]
  return filtered
}

locationsArray отфильтровывает индексы locationTuples, содержащие nil MKMapItems, и так как приложение будет выдавать маршрут туда-обратно, то filtered += [filtered.first!]  копирует кортеж на первом индексе до конца массива. Под shouldPerformSegueWithIdentifier(_:sender:), переопределите prepareForSegue(_:sender:):

override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
  var directionsViewController = segue.destinationViewController as! DirectionsViewController
  directionsViewController.locationArray = locationsArray
}

Это перенесет locationsArray на следующий view controller.

Ну вот и все в ViewController.swift!  Теперь переключитесь на DirectionsViewController.swift и начнем прокладывать маршрут.

Рассчитываем маршрут с MapKit

Подсказка от тетушки Люси #2

Перевод: После ночи с Радербергером (название пива) и Рамштаймом. Мне нужно болеутоляющее, чтобы убить мою жажду. Вы найдете и того и другого либо на этом пути, либо на том. Не в лучшем доме, а в _________.

Немецкие флюиды ...  обезболивающее ... опять упоминание о “этот путь и то ... "Не в лучшем доме, а в ___" ... Хуже? Это, кажется, подходит ... И вот! На углу есть немецкая пивнушка, которая называется Wurst Haus, где подают напиток под названием Обездоливающее! Это по адресу 102 This Way Lake Jackson, TX.

Теперь вы знаете все адреса, и вам нужно создать объект MKDirections, а затем вызвать его calculateDirectionsWithCompletionHandler(_:) для источника и направления каждого сегмента для того, чтобы рассчитать маршрут. 

Добавьте следующее в DirectionsViewController:

func calculateSegmentDirections(index: Int) {
  // 1
  let request: MKDirectionsRequest = MKDirectionsRequest()
  request.source = locationArray[index].mapItem
  request.destination = locationArray[index+1].mapItem
  // 2
  request.requestsAlternateRoutes = true
  // 3
  request.transportType = .Automobile
  // 4
  let directions = MKDirections(request: request)
  directions.calculateDirectionsWithCompletionHandler ({
    (response: MKDirectionsResponse?, error: NSError?) in
    if let routeResponse = response?.routes {
 
    } else if let _ = error {
 
    }
  })
}

Вот то, что мы сделали: 

  1. Создали MKDirectionsRequest, установив MKMapItem в данный индекс locationArray в качестве источника и установки MKMapItem на следующий индекс в качестве пункта назначения. 
  2. Установили requestsAlternateRoutes как true, чтобы извлечь все подходящие маршруты от источника к месту назначения.
  3. Установили тип транспорта для .Automobile для данного сценария. (.Walking (пешком) И .Any также доступные MKDirectionsTransportTypes.) 
  4. Инициализировали объект MKDirections с MKDirectionsRequest, а затем вызвали calculateDirectionsWithCompletionHandler(_:), чтобы получить MKDirectionsResponse, содержащий массив MKRoutes.

Если calculateDirectionsWithCompletionHandler(_:) не возвращает никаких маршрутов, а вместо этого возвращает ошибку, то будет выполнен блок else if let _ = error. Добавьте следующее в логический блок else if:

let alert = UIAlertController(title: nil,
  message: "Directions not available.", preferredStyle: .Alert)
let okButton = UIAlertAction(title: "OK",
  style: .Cancel) { (alert) -> Void in
  self.navigationController?.popViewControllerAnimated(true)
}
alert.addAction(okButton)
self.presentViewController(alert, animated: true,
  completion: nil)

Здесь происходит вывод сообщения об ошибке и возврат пользователя к предыдущему view controller. 

Предполагаемые MKRoutes найдены, первое выражение if let внутри calculateDirectionsWithCompletionHandler(_:)  будет выполнено как true. В этом блоке if let добавьте:

let quickestRouteForSegment: MKRoute =
  routeResponse.sort({$0.expectedTravelTime <
  $1.expectedTravelTime})[0]

Здесь вы сортируете маршруты от наименьшего к наибольшему ожидаемому времени путешествия, затем вытащите первый индекс, т.е. индекс с наименьшим ожидаемым временем путешествия. Это даст вам самый быстрый маршрут между двумя точками. Вы на пути к победе!

Но вам все еще нужно рассчитать несколько маршрутов между несколькими точками. Вы можете сделать это рекурсивно. Во-первых, обновите параметры calculateSegmentDirections(_:):

func calculateSegmentDirections(index: Int,
  var time: NSTimeInterval, var routes: [MKRoute]) {

calculateSegmentDirections(_:time:routes:) теперь принимает изменяемые массивы сегменты маршрутов и изменяемые переменные времени.

Затем внутри первого блока if let  и после объявления quickestRouteForSegment добавьте:

// 1
routes.append(quickestRouteForSegment)
// 2
time += quickestRouteForSegment.expectedTravelTime
// 3
if index+2 < self.locationArray.count {
  self.calculateSegmentDirections(index+1, time: time, routes: routes)
} else {
 
}

Здесь вы:

  1. Добавили самый быстрый маршрут для этого текущего сегмента массива routes, переданного в качестве параметра.
  2. Добавили предполагаемое время в пути в параметр time
  3. Так как вы пока не достигли двух окончательных значений массива location, рекурсивный вызов calculateSegmentDirections(_:time:routes:)  с увеличенным индексом и обновленными значениями времени и маршрута. 

Теперь вернемся к viewDidLoad и добавим следующее:

addActivityIndicator()
calculateSegmentDirections(0, time: 0, routes: [])

Этот код добавляет индикатор активности, в момент когда рассчитывается маршрут, затем вызывается calculateSegmentDirections(_:time:routes:)  для расчета маршрута, начиная с индекса 0 для locationArray, с начальным общим временем 0 и первоначально пустым массивом маршрута.

Вернемся к calculateDirectionsWithCompletionHandler(_:). Внутри блока else, следующего непосредственно за if index+2 < self.locationArray.count, добавьте:

self.hideActivityIndicator()

Это скроет индикатор активности внутри этого блока else, когда вы достигли конца рекурсии. 

В том же блоке else, вы должны будете построить маршрут на карте и показать направления в таблице. Но вам нужны некоторые вспомогательные функции для этого.

Добавляем MKRoutes на MKMapView

Для построения каждого сегмента MKRoute на MKMapView, добавьте следующее в основной класс DirectionsViewController:

func plotPolyline(route: MKRoute) {
  // 1
  mapView.addOverlay(route.polyline)
  // 2
  if mapView.overlays.count == 1 {
    mapView.setVisibleMapRect(route.polyline.boundingMapRect,
      edgePadding: UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0),
      animated: false)
  }
  // 3
  else {
    let polylineBoundingRect =  MKMapRectUnion(mapView.visibleMapRect,
      route.polyline.boundingMapRect)
    mapView.setVisibleMapRect(polylineBoundingRect,
      edgePadding: UIEdgeInsetsMake(10.0, 10.0, 10.0, 10.0),
      animated: false)
  }
}

plotPolyline(_:) используется для следующего:

  1. Добавляет полилинию (линия, соединяющая несколько точек не по прямой, а в виде ломанной например) MKRoute на карту в виде отдельного слоя.
  2. Если график маршрута является первым слоем, устанавливает видимую область карты так, чтобы она была достаточно большой, чтобы соответствовать накладываемому слою с 10 дополнительными точками.
  3. Если график маршрута не является первым слоем, установите видимую область карты на объединенные новые и старые видимые области карты +  10 дополнительных точек.

Затем, обновите mapView(_:rendererForOverlay:) в расширении MKMapViewDelegate:

func mapView(mapView: MKMapView,
  rendererForOverlay overlay: MKOverlay) -> MKOverlayRenderer! {
 
  let polylineRenderer = MKPolylineRenderer(overlay: overlay)
  if (overlay is MKPolyline) {
    if mapView.overlays.count == 1 {
      polylineRenderer.strokeColor =
        UIColor.blueColor().colorWithAlphaComponent(0.75)
    } else if mapView.overlays.count == 2 {
      polylineRenderer.strokeColor =
        UIColor.greenColor().colorWithAlphaComponent(0.75)
    } else if mapView.overlays.count == 3 {
      polylineRenderer.strokeColor =
        UIColor.redColor().colorWithAlphaComponent(0.75)
    }
    polylineRenderer.lineWidth = 5
  }
  return polylineRenderer
}

Это даст каждому сегменту маршрута свой отличный от других цвет.

Под calculateSegmentDirections(_:time:routes:), добавьте:

func showRoute(routes: [MKRoute]) {
  for i in 0..<routes.count {
    plotPolyline(routes[i])
  }
}

Эта функция перебирает каждый MKRoute и добавляет его полилинию на карту. 

Внутри calculateDirectionsWithCompletionHandler(_:) вызовите showRoute(_:) в блоке else, в котором вы вызывали self.hideActivityIndicator():

self.showRoute(routes)

Запустите приложение; введите адрес и нажмите Route It! (Проложить маршрут!) Маршрут появится на карте.

Выглядит отлично! Теперь вам нужно задать направления в DirectionsTable.

Вбиваем направления MKRoute

DirectionsTable.swift содержит глобальный directionsArray массив кортежей типа (String, String, MKRoute). Два String в каждом кортеже будут содержать начальный и конечный адреса сегмента, принадлежащего MKRoute, хранящегося в третьем индексе кортежа.

Прокрутите до расширения UITableViewDataSource, как указано в возвращаемом значении в numberOfSectionsInTableView(_:), таблица будет содержать раздел для каждого маршрута, хранящегося в directionsArray, таким образом, каждый раздел вашего DirectionsTable будет представлять собой отдельный сегмент маршрута.

Возвращаемое значение tableView(_:numberOfRowsInSection:) - это число MKRouteSteps в вашем MKRoute, которые соответствуют текущей секции, т.е. directionsArray[section].route.

Заполните tableView(_:cellForRowAtIndexPath:), добавив следующее непосредственно под cell.userInteractionEnabled = false:

// 1
let steps = directionsArray[indexPath.section].route.steps
// 2
let step = steps[indexPath.row]
// 3
let instructions = step.instructions
// 4
let distance = step.distance.miles()
// 5
cell.textLabel?.text = "\(indexPath.row+1). \(instructions) - \(distance) miles"

В каждой строке, вы будете печатать соответствующее MKRouteStep и расстояние следующим образом: 

  1. Получите массив MKRouteSteps, принадлежащий MKRoute, соответствующему текущей секции. 
  2. Из этого массива steps, получите доступ к объекту MKRouteStep при индексе, соответствующему текущему ряду. 
  3. Получите инструкции по маршруту step.
  4. Получите расстояние step и преобразуйте его из метров в мили с помощью miles() расширения CLLocationDistance, включенного в DirectionsTableView.swift
  5. Установите метку для ячейки, чтобы показать инструкции маршрута шагов и расстояния.

Теперь вы можете использовать методы в расширении UITableViewDelegate для заполнения заголовка каждого раздела с информацией о начальной точке отправления и сноски (footer) с информацией о конечной точке и общем маршруте. 

Добавьте эту строку в tableView(_:viewForHeaderInSection:) прямо перед возвращаемым значением:

label.text = "SEGMENT #\(section+1)\n\nStarting point: \(directionsArray[section].startingAddress)\n"

Заголовок теперь содержит начальный адрес. В tableView(_:viewForFooterInSection:) добавьте следующий код прямо перед возвращаемым значением:

// 1
let route = directionsArray[section].route
// 2
let time = route.expectedTravelTime.formatted()
// 3
let miles = route.distance.miles()
//4
label.text = "Ending point: \(directionsArray[section].endingAddress)\n\nDistance: \(miles) miles\n\nExpected Travel Time: \(time)"

Теперь по шагам:

  1. Получите маршрут текущего раздела. 
  2. Форматируйте expectedTravelTime, используя formatted() расширения NSTimeInterval. formatted() использует NSDateComponentsFormatter для преобразования NSTimeInterval в формат часы-минуты-секунды. 
  3. Форматируйте расстояние, используя метод “миль” расширения CLLocationDistance
  4. Установите метку ячейки, для того, чтобы показать конечный адрес, расстояние, и ожидаемое время в пути. 

Теперь вернемся к DirectionsViewController. Добавьте следующий метод класса:

func displayDirections(directionsArray: [(startingAddress: String, 
  endingAddress: String, route: MKRoute)]) {
  directionsTableView.directionsArray = directionsArray
  directionsTableView.delegate = directionsTableView
  directionsTableView.dataSource = directionsTableView
  directionsTableView.reloadData()
}

Теперь вы перешли от directionsArray к DirectionsTableView.

Затем обновите showRoute(_:):

func showRoute(routes: [MKRoute]) {
  var directionsArray = [(startingAddress: String, endingAddress: String, route: MKRoute)]()
  for i in 0..<routes.count {
    plotPolyline(routes[i])
    directionsArray += [(locationArray[i].textField.text!,
      endAddress: locationArray[i+1].textField.text!, route: routes[i])]
  }
  displayDirections(directionsArray)
}

Для каждого маршрута, добавьте начальный адрес, конечный адрес и MKRoute к directionsArray  до того, как он перейдет в displayDirections(_:).

Далее, вам нужно обновить totalTimeLabel, чтобы была возможность отображать общее предполагаемое время поездки, рассчитываемое с использованием изменяемого параметра времени в calculateSegmentDirections(_:time:routes:).

Обновите объявление функции showRoute(_:) для того чтобы включить параметр NSTimeInterval:

func showRoute(routes: [MKRoute], time: NSTimeInterval) {

Внутри calculateDirectionsWithCompletionHandler(_:) добавьте параметр time для вызова showRoute(_:time:):

self.showRoute(routes, time: time)

Добавьте также следующую функцию классу DirectionsViewController:

func printTimeToLabel(time: NSTimeInterval) {
  var timeString = time.formatted()
  totalTimeLabel.text = "Total Time: \(timeString)"
}

printTimeToLabel(_:) печатает отформатированный NSTimeInterval в totalTimeLabel.

Наконец, вызовите printTimeToLabel(_:) в конце showRoute(_:):

printTimeToLabel(time)

Здесь вы проводите time в качестве параметра для того, чтобы вывести его в totalTimeLabel

Запустите приложение; введите адреса, нажмите Route It! и маршрут и направления должны появиться:

Но есть еще один нюанс, если вы хотите получить завтрак тети Люси и обезболивающее быстрее, чем ваши конкуренты. Обратите внимание на общее время в нижней части экрана "Procrastinator's Route"; нажмите кнопку Back на панели навигации, а затем нажмите кнопку ↑ ↓, чтобы поменять порядок назначения. Теперь нажмите еще раз Route it!. Если не принимать во внимание закрытые дороги или пробки, обратный маршрут должны быть быстрее!

Теперь, у вас есть преимущество среди ваших конкурентов и можете полностью минимизировать потери, и месть (ох, и $ 500,000,000 ...) остается за вами. Хахахахахха!

Конечный проект здесь

Для того, чтобы улучшить приложение в дальнейшем, попробуйте использовать различные типы транспорта и несколько текстовых полей для назначений; вместо того, чтобы вручную менять поля, выбирая лучший маршрут, попробуйте автоматическую перестановку мест и авто-расчет самого быстрого маршрута.

Что дальше?

Дальше, вы можете продолжить изучать наши туториалы по мере их появления, а также, параллельно читать перевод официальной книги по языку программирования Swift. И, для более подробного изучения языка, вы можете пройти наши курсы!

Урок подготовил: Иван Акулов

Источник урока