MapKit框架详细解析(十六) —— 基于MapKit和Core Location的Routing(二)

版本记录

版本号 时间
V1.0 2020.06.20 星期六

前言

MapKit框架直接从您的应用界面显示地图或卫星图像,调出兴趣点,并确定地图坐标的地标信息。接下来几篇我们就一起看一下这个框架。感兴趣的看下面几篇文章。
1. MapKit框架详细解析(一) —— 基本概览(一)
2. MapKit框架详细解析(二) —— 基本使用简单示例(一)
3. MapKit框架详细解析(三) —— 基本使用简单示例(二)
4. MapKit框架详细解析(四) —— 一个叠加视图相关的简单示例(一)
5. MapKit框架详细解析(五) —— 一个叠加视图相关的简单示例(二)
6. MapKit框架详细解析(六) —— 添加自定义图块(一)
7. MapKit框架详细解析(七) —— 添加自定义图块(二)
8. MapKit框架详细解析(八) —— 添加自定义图块(三)
9. MapKit框架详细解析(九) —— 地图特定区域放大和创建自定义地图annotations(一)
10. MapKit框架详细解析(十) —— 地图特定区域放大和创建自定义地图annotations(二)
11. MapKit框架详细解析(十一) —— 自定义MapKit Tiles(一)
12. MapKit框架详细解析(十二) —— 自定义MapKit Tiles(二)
13. MapKit框架详细解析(十三) —— MapKit Overlay Views(一)
14. MapKit框架详细解析(十四) —— MapKit Overlay Views(二)
15. MapKit框架详细解析(十五) —— 基于MapKit和Core Location的Routing(一)

源码

1. Swift

首先看下工程组织结构

MapKit框架详细解析(十六) —— 基于MapKit和Core Location的Routing(二)_第1张图片

下面就是源码了

1. AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    window = UIWindow()
    window?.rootViewController = RouteSelectionViewController()
    window?.overrideUserInterfaceStyle = .light
    window?.makeKeyAndVisible()
    // Override point for customization after application launch.
    return true
  }
}
2. CLPlacemark+Additions.swift
import CoreLocation

extension CLPlacemark {
  var abbreviation: String {
    if let name = self.name {
      return name
    }

    if let interestingPlace = areasOfInterest?.first {
      return interestingPlace
    }

    return [subThoroughfare, thoroughfare].compactMap { $0 }.joined(separator: " ")
  }
}
3. TimeInterval+Additions.swift
import Foundation

extension TimeInterval {
  var formatted: String {
    let formatter = DateComponentsFormatter()
    formatter.unitsStyle = .full
    formatter.allowedUnits = [.hour, .minute]

    return formatter.string(from: self) ?? ""
  }
}
4. UIColor+Additions.swift
import UIKit

extension UIColor {
  static var border: UIColor {
    // swiftlint:disable:next force_unwrapping
    return UIColor(named: "ui-border")!
  }

  static var primary: UIColor {
    // swiftlint:disable:next force_unwrapping
    return UIColor(named: "rw-green")!
  }
}
5. UIButton+Additions.swift
import UIKit

extension UIButton {
  func stylize() {
    setTitleColor(.white, for: .normal)
    setBackgroundImage(.buttonBackground, for: .normal)
    titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
    contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
  }
}
6. UIImage+Additions.swift
import UIKit

extension UIImage {
  static var buttonBackground: UIImage {
    let imageSideLength: CGFloat = 8
    let halfSideLength = imageSideLength / 2
    let imageFrame = CGRect(
      x: 0,
      y: 0,
      width: imageSideLength,
      height: imageSideLength
    )

    let image = UIGraphicsImageRenderer(size: imageFrame.size).image { ctx in
      ctx.cgContext.addPath(
        UIBezierPath(
          roundedRect: imageFrame,
          cornerRadius: halfSideLength
        ).cgPath
      )
      ctx.cgContext.setFillColor(UIColor.primary.cgColor)
      ctx.cgContext.fillPath()
    }

    return image.resizableImage(
      withCapInsets: UIEdgeInsets(
        top: halfSideLength,
        left: halfSideLength,
        bottom: halfSideLength,
        right: halfSideLength
      )
    )
  }
}
7. UIView+Additions.swift
import UIKit

extension UIView {
  func addBorder() {
    layer.borderWidth = 1
    layer.cornerRadius = 3
    layer.borderColor = UIColor.border.cgColor
  }
}
8. UITextField+Additions.swift
import UIKit

extension UITextField {
  var contents: String? {
    guard
      let text = text?.trimmingCharacters(in: .whitespaces),
      !text.isEmpty
      else {
        return nil
    }

    return text
  }
}
9. RouteBuilder.swift
import MapKit

enum RouteBuilder {
  enum Segment {
    case text(String)
    case location(CLLocation)
  }

  enum RouteError: Error {
    case invalidSegment(String)
  }

  typealias PlaceCompletionBlock = (MKPlacemark?) -> Void
  typealias RouteCompletionBlock = (Result) -> Void

  private static let routeQueue = DispatchQueue(label: "com.raywenderlich.RWRouter.route-builder")

  static func buildRoute(origin: Segment, stops: [Segment], within region: MKCoordinateRegion?, completion: @escaping RouteCompletionBlock) {
    routeQueue.async {
      let group = DispatchGroup()

      var originItem: MKMapItem?
      group.enter()
      requestPlace(for: origin, within: region) { place in
        if let requestedPlace = place {
          originItem = MKMapItem(placemark: requestedPlace)
        }

        group.leave()
      }

      var stopItems = [MKMapItem](repeating: .init(), count: stops.count)
      for (index, stop) in stops.enumerated() {
        group.enter()
        requestPlace(for: stop, within: region) { place in
          if let requestedPlace = place {
            stopItems[index] = MKMapItem(placemark: requestedPlace)
          }

          group.leave()
        }
      }

      group.notify(queue: .main) {
        if let originMapItem = originItem, !stopItems.isEmpty {
          let route = Route(origin: originMapItem, stops: stopItems)
          completion(.success(route))
        } else {
          let reason = originItem == nil ? "the origin address" : "one or more of the stops"
          completion(.failure(.invalidSegment(reason)))
        }
      }
    }
  }

  private static func requestPlace(for segment: Segment, within region: MKCoordinateRegion?, completion: @escaping PlaceCompletionBlock) {
    if case .text(let value) = segment, let nearbyRegion = region {
      let request = MKLocalSearch.Request()
      request.naturalLanguageQuery = value
      request.region = nearbyRegion

      MKLocalSearch(request: request).start { response, _ in
        let place: MKPlacemark?

        if let firstItem = response?.mapItems.first {
          place = firstItem.placemark
        } else {
          place = nil
        }

        completion(place)
      }
    } else {
      CLGeocoder().geocodeSegment(segment) { places, _ in
        let place: MKPlacemark?

        if let firstPlace = places?.first {
          place = MKPlacemark(placemark: firstPlace)
        } else {
          place = nil
        }

        completion(place)
      }
    }
  }
}

private extension CLGeocoder {
  func geocodeSegment(_ segment: RouteBuilder.Segment, completionHandler: @escaping CLGeocodeCompletionHandler) {
    switch segment {
    case .text(let value):
      geocodeAddressString(value, completionHandler: completionHandler)

    case .location(let value):
      reverseGeocodeLocation(value, completionHandler: completionHandler)
    }
  }
}
10. Route.swift
import MapKit

struct Route {
  let origin: MKMapItem
  let stops: [MKMapItem]

  var annotations: [MKAnnotation] {
    var annotations: [MKAnnotation] = []

    annotations.append(
      RouteAnnotation(item: origin)
    )
    annotations.append(contentsOf: stops.map { stop in
      return RouteAnnotation(item: stop)
    })

    return annotations
  }

  var label: String {
    if let name = stops.first?.name, stops.count == 1 {
      return "Directions to \(name)"
    } else {
      let stopNames = stops.compactMap { stop in
        return stop.name
      }
      let namesString = stopNames.joined(separator: " and ")

      return "Directions between \(namesString)"
    }
  }
}
11. RouteAnnotation.swift
import MapKit

class RouteAnnotation: NSObject {
  private let item: MKMapItem

  init(item: MKMapItem) {
    self.item = item

    super.init()
  }
}

// MARK: - MKAnnotation

extension RouteAnnotation: MKAnnotation {
  var coordinate: CLLocationCoordinate2D {
    return item.placemark.coordinate
  }

  var title: String? {
    return item.name
  }
}
12. DirectionsViewController.swift
import UIKit
import MapKit

class DirectionsViewController: UIViewController {
  @IBOutlet private var mapView: MKMapView!
  @IBOutlet private var headerLabel: UILabel!
  @IBOutlet private var tableView: UITableView!
  @IBOutlet private var informationLabel: UILabel!
  @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!

  private let cellIdentifier = "DirectionsCell"
  private let distanceFormatter = MKDistanceFormatter()

  private let route: Route

  private var mapRoutes: [MKRoute] = []
  private var totalTravelTime: TimeInterval = 0
  private var totalDistance: CLLocationDistance = 0

  private var groupedRoutes: [(startItem: MKMapItem, endItem: MKMapItem)] = []

  init(route: Route) {
    self.route = route

    super.init(nibName: String(describing: DirectionsViewController.self), bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    groupAndRequestDirections()

    headerLabel.text = route.label

    tableView.dataSource = self

    mapView.delegate = self
    mapView.showAnnotations(route.annotations, animated: false)
  }

  // MARK: - Helpers

  private func groupAndRequestDirections() {
    guard let firstStop = route.stops.first else {
      return
    }

    groupedRoutes.append((route.origin, firstStop))

    if route.stops.count == 2 {
      let secondStop = route.stops[1]

      groupedRoutes.append((firstStop, secondStop))
      groupedRoutes.append((secondStop, route.origin))
    }

    fetchNextRoute()
  }

  private func fetchNextRoute() {
    guard !groupedRoutes.isEmpty else {
      activityIndicatorView.stopAnimating()
      return
    }

    let nextGroup = groupedRoutes.removeFirst()
    let request = MKDirections.Request()

    request.source = nextGroup.startItem
    request.destination = nextGroup.endItem

    let directions = MKDirections(request: request)

    directions.calculate { response, error in
      guard let mapRoute = response?.routes.first else {
        self.informationLabel.text = error?.localizedDescription
        self.activityIndicatorView.stopAnimating()
        return
      }

      self.updateView(with: mapRoute)
      self.fetchNextRoute()
    }
  }

  private func updateView(with mapRoute: MKRoute) {
    let padding: CGFloat = 8
    mapView.addOverlay(mapRoute.polyline)
    mapView.setVisibleMapRect(
      mapView.visibleMapRect.union(
        mapRoute.polyline.boundingMapRect
      ),
      edgePadding: UIEdgeInsets(
        top: 0,
        left: padding,
        bottom: padding,
        right: padding
      ),
      animated: true
    )

    totalDistance += mapRoute.distance
    totalTravelTime += mapRoute.expectedTravelTime

    let informationComponents = [
      totalTravelTime.formatted,
      "• \(distanceFormatter.string(fromDistance: totalDistance))"
    ]
    informationLabel.text = informationComponents.joined(separator: " ")

    mapRoutes.append(mapRoute)
    tableView.reloadData()
  }
}

// MARK: - UITableViewDataSource

extension DirectionsViewController: UITableViewDataSource {
  func numberOfSections(in tableView: UITableView) -> Int {
    return mapRoutes.isEmpty ? 0 : mapRoutes.count
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    let route = mapRoutes[section]
    return route.steps.count - 1
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = { () -> UITableViewCell in
      guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) else {
        let cell = UITableViewCell(style: .subtitle, reuseIdentifier: cellIdentifier)
        cell.selectionStyle = .none
        return cell
      }
      return cell
    }()

    let route = mapRoutes[indexPath.section]
    let step = route.steps[indexPath.row + 1]

    cell.textLabel?.text = "\(indexPath.row + 1): \(step.notice ?? step.instructions)"
    cell.detailTextLabel?.text = distanceFormatter.string(
      fromDistance: step.distance
    )

    return cell
  }

  func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
    let route = mapRoutes[section]
    return route.name
  }
}

// MARK: - MKMapViewDelegate

extension DirectionsViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    let renderer = MKPolylineRenderer(overlay: overlay)

    renderer.strokeColor = .systemBlue
    renderer.lineWidth = 3

    return renderer
  }
}
13. RouteSelectionViewController.swift
import UIKit
import MapKit
import CoreLocation

class RouteSelectionViewController: UIViewController {
  @IBOutlet private var inputContainerView: UIView!
  @IBOutlet private var originTextField: UITextField!
  @IBOutlet private var stopTextField: UITextField!
  @IBOutlet private var extraStopTextField: UITextField!
  @IBOutlet private var calculateButton: UIButton!
  @IBOutlet private var activityIndicatorView: UIActivityIndicatorView!
  @IBOutlet private var keyboardAvoidingConstraint: NSLayoutConstraint!

  @IBOutlet private var suggestionLabel: UILabel!
  @IBOutlet private var suggestionContainerView: UIView!
  @IBOutlet private var suggestionContainerTopConstraint: NSLayoutConstraint!

  private var editingTextField: UITextField?
  private var currentRegion: MKCoordinateRegion?
  private var currentPlace: CLPlacemark?

  private let locationManager = CLLocationManager()
  private let completer = MKLocalSearchCompleter()

  private let defaultAnimationDuration: TimeInterval = 0.25

  override func viewDidLoad() {
    super.viewDidLoad()

    suggestionContainerView.addBorder()
    inputContainerView.addBorder()
    calculateButton.stylize()

    completer.delegate = self

    beginObserving()
    configureGestures()
    configureTextFields()
    attemptLocationAccess()
    hideSuggestionView(animated: false)
  }

  // MARK: - Helpers

  private func configureGestures() {
    view.addGestureRecognizer(
      UITapGestureRecognizer(
        target: self,
        action: #selector(handleTap(_:))
      )
    )
    suggestionContainerView.addGestureRecognizer(
      UITapGestureRecognizer(
        target: self,
        action: #selector(suggestionTapped(_:))
      )
    )
  }

  private func configureTextFields() {
    originTextField.delegate = self
    stopTextField.delegate = self
    extraStopTextField.delegate = self

    originTextField.addTarget(
      self,
      action: #selector(textFieldDidChange(_:)),
      for: .editingChanged
    )
    stopTextField.addTarget(
      self,
      action: #selector(textFieldDidChange(_:)),
      for: .editingChanged
    )
    extraStopTextField.addTarget(
      self,
      action: #selector(textFieldDidChange(_:)),
      for: .editingChanged
    )
  }

  private func attemptLocationAccess() {
    guard CLLocationManager.locationServicesEnabled() else {
      return
    }

    locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
    locationManager.delegate = self

    if CLLocationManager.authorizationStatus() == .notDetermined {
      locationManager.requestWhenInUseAuthorization()
    } else {
      locationManager.requestLocation()
    }
  }

  private func hideSuggestionView(animated: Bool) {
    suggestionContainerTopConstraint.constant = -1 * (suggestionContainerView.bounds.height + 1)

    guard animated else {
      view.layoutIfNeeded()
      return
    }

    UIView.animate(withDuration: defaultAnimationDuration) {
      self.view.layoutIfNeeded()
    }
  }

  private func showSuggestion(_ suggestion: String) {
    suggestionLabel.text = suggestion
    suggestionContainerTopConstraint.constant = -4 // to hide the top corners

    UIView.animate(withDuration: defaultAnimationDuration) {
      self.view.layoutIfNeeded()
    }
  }

  private func presentAlert(message: String) {
    let alertController = UIAlertController(title: nil, message: message, preferredStyle: .alert)
    alertController.addAction(UIAlertAction(title: "Okay", style: .default, handler: nil))

    present(alertController, animated: true)
  }

  // MARK: - Actions

  @objc private func textFieldDidChange(_ field: UITextField) {
    if field == originTextField && currentPlace != nil {
      currentPlace = nil
      field.text = ""
    }

    guard let query = field.contents else {
      hideSuggestionView(animated: true)

      if completer.isSearching {
        completer.cancel()
      }
      return
    }

    completer.queryFragment = query
  }

  @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
    let gestureView = gesture.view
    let point = gesture.location(in: gestureView)

    guard
      let hitView = gestureView?.hitTest(point, with: nil),
      hitView == gestureView
      else {
        return
    }

    view.endEditing(true)
  }

  @objc private func suggestionTapped(_ gesture: UITapGestureRecognizer) {
    hideSuggestionView(animated: true)

    editingTextField?.text = suggestionLabel.text
    editingTextField = nil
  }

  @IBAction private func calculateButtonTapped() {
    view.endEditing(true)

    calculateButton.isEnabled = false
    activityIndicatorView.startAnimating()

    let segment: RouteBuilder.Segment?
    if let currentLocation = currentPlace?.location {
      segment = .location(currentLocation)
    } else if let originValue = originTextField.contents {
      segment = .text(originValue)
    } else {
      segment = nil
    }

    let stopSegments: [RouteBuilder.Segment] = [
      stopTextField.contents,
      extraStopTextField.contents
    ]
    .compactMap { contents in
      if let value = contents {
        return .text(value)
      } else {
        return nil
      }
    }

    guard
      let originSegment = segment,
      !stopSegments.isEmpty
      else {
        presentAlert(message: "Please select an origin and at least 1 stop.")
        activityIndicatorView.stopAnimating()
        calculateButton.isEnabled = true
        return
    }

    RouteBuilder.buildRoute(
      origin: originSegment,
      stops: stopSegments,
      within: currentRegion
    ) { result in
      self.calculateButton.isEnabled = true
      self.activityIndicatorView.stopAnimating()

      switch result {
      case .success(let route):
        let viewController = DirectionsViewController(route: route)
        self.present(viewController, animated: true)

      case .failure(let error):
        let errorMessage: String

        switch error {
        case .invalidSegment(let reason):
          errorMessage = "There was an error with: \(reason)."
        }

        self.presentAlert(message: errorMessage)
      }
    }
  }

  // MARK: - Notifications

  private func beginObserving() {
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(handleKeyboardFrameChange(_:)),
      name: UIResponder.keyboardWillChangeFrameNotification,
      object: nil
    )
  }

  @objc private func handleKeyboardFrameChange(_ notification: Notification) {
    guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
      return
    }

    let viewHeight = view.bounds.height - view.safeAreaInsets.bottom
    let visibleHeight = viewHeight - frame.origin.y
    keyboardAvoidingConstraint.constant = visibleHeight + 32

    UIView.animate(withDuration: defaultAnimationDuration) {
      self.view.layoutIfNeeded()
    }
  }
}

// MARK: - UITextFieldDelegate

extension RouteSelectionViewController: UITextFieldDelegate {
  func textFieldDidBeginEditing(_ textField: UITextField) {
    hideSuggestionView(animated: true)

    if completer.isSearching {
      completer.cancel()
    }

    editingTextField = textField
  }
}

// MARK: - CLLocationManagerDelegate

extension RouteSelectionViewController: CLLocationManagerDelegate {
  func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
    guard status == .authorizedWhenInUse else {
      return
    }

    manager.requestLocation()
  }

  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    guard let firstLocation = locations.first else {
      return
    }

    let commonDelta: CLLocationDegrees = 25 / 111 // 1/111 = 1 latitude km
    let span = MKCoordinateSpan(latitudeDelta: commonDelta, longitudeDelta: commonDelta)
    let region = MKCoordinateRegion(center: firstLocation.coordinate, span: span)

    currentRegion = region
    completer.region = region

    CLGeocoder().reverseGeocodeLocation(firstLocation) { places, _ in
      guard let firstPlace = places?.first, self.originTextField.contents == nil else {
        return
      }

      self.currentPlace = firstPlace
      self.originTextField.text = firstPlace.abbreviation
    }
  }

  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
    print("Error requesting location: \(error.localizedDescription)")
  }
}

// MARK: - MKLocalSearchCompleterDelegate

extension RouteSelectionViewController: MKLocalSearchCompleterDelegate {
  func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
    guard let firstResult = completer.results.first else {
      return
    }

    showSuggestion(firstResult.title)
  }

  func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
    print("Error suggesting a location: \(error.localizedDescription)")
  }
}

后记

本篇主要讲述了基于MapKitCore LocationRouting,感兴趣的给个赞或者关注~~~

MapKit框架详细解析(十六) —— 基于MapKit和Core Location的Routing(二)_第2张图片

你可能感兴趣的:(MapKit框架详细解析(十六) —— 基于MapKit和Core Location的Routing(二))