CoreLocation框架详细解析(十五) —— 仿Runkeeper的简单实现(四)

版本记录

版本号 时间
V1.0 2018.10.18 星期四

前言

很多的app都有定位功能,比如说滴滴,美团等,他们都需要获取客户所在的位置,并且根据位置推送不同的模块数据以及服务,可以说,定位方便了我们的生活,接下来这几篇我们就说一下定位框架CoreLocation。感兴趣的可以看我写的上面几篇。
1. CoreLocation框架详细解析 —— 基本概览(一)
2. CoreLocation框架详细解析 —— 选择定位服务的授权级别(二)
3. CoreLocation框架详细解析 —— 确定定位服务的可用性(三)
4. CoreLocation框架详细解析 —— 获取用户位置(四)
5. CoreLocation框架详细解析 —— 监控用户与地理区域的距离(五)
6. CoreLocation框架详细解析 —— 确定接近iBeacon(六)
7. CoreLocation框架详细解析 —— 将iOS设备转换为iBeacon(七)
8. CoreLocation框架详细解析 —— 获取指向和路线信息(八)
9. CoreLocation框架详细解析 —— 在坐标和用户友好的地名之间转换(九)
10. CoreLocation框架详细解析(十) —— 跟踪访问位置简单示例(一)
11. CoreLocation框架详细解析(十一) —— 跟踪访问位置简单示例(二)
12. CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)
13. CoreLocation框架详细解析(十三) —— 仿Runkeeper的简单实现(二)
14. CoreLocation框架详细解析(十四) —— 仿Runkeeper的简单实现(三)

Everything Is Better When it Has a "Space Mode"

跑步结束后,为您的用户提供查看他们获得的最后一个徽章的能力会很不错。

打开Main.storyboard并找到Run Details View Controller Scene。 在地图视图的顶部拖动UIImageView。 按住control键从图像视图控制拖动到地图视图。 在弹出窗口中,按住Shift键并选择Top, Bottom, Leading and Trailing。 单击Add Constraints以将Image View的边缘固定到Map View的边缘。

Xcode将添加约束,每个约束的值为0,这正是您想要的。 但是,目前,图像视图并未完全覆盖地图视图,因此您可以看到橙色警告线。 单击Update Frames按钮(下面的红色部分)以调整图像视图的大小。

UIButton拖到Image View的顶部。 删除Button的Title并将其Image值设置为info

按住Ctrl键并从按钮拖动到Image View。 在弹出窗口中,按住Shift键并选择Bottom and Trailing。 单击Add Constraints将按钮固定到图像视图的右下角。

Size Inspector中,Edit每个约束并将其值设置为-8

再次单击Update Frames按钮以固定Button的大小和位置。

选择Image View并将其Content Mode设置为Aspect Fit,将其Alpha设置为0。

选择Button并将其Alpha设置为0。

注意:您正在使用Alpha属性而不是Hidden属性隐藏这些视图,因为您要将它们动画到视图中以获得更流畅的用户体验。

UISwitchUILabel拖动到视图的右下角。

选择Switch并按Add New Contraints按钮(“Tie Fighter”按钮)。 使用值8为Right,Bottom和Left添加约束。确保Left约束相对于Label。 选择Add 3 Constraints

将Switch的Value设置为Off。

按住ctrl键从Switch到Label。 在弹出的结果中,选择Center Vertically

选择Label,将其Title设置为SPACE MODE,将其Color设置为White Color

在文档大纲中,按住Control键从Switch到Stack View。 从弹出的结果中选择Vertical Spacing

在Switch的Size Inspector中,编辑约束Top Space to: Stack View。 将其关系设置为,将其值设置为8

在完成所有布局工作后,您应该获得徽章!

Assistant Editor中打开RunDetailsViewController.swift,并按如下方式连接Image ViewInfo Button的outlets :

@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoButton: UIButton!

为Switch添加以下action并连接它:

@IBAction func displayModeToggled(_ sender: UISwitch) {
  UIView.animate(withDuration: 0.2) {
    self.badgeImageView.alpha = sender.isOn ? 1 : 0
    self.badgeInfoButton.alpha = sender.isOn ? 1 : 0
    self.mapView.alpha = sender.isOn ? 0 : 1
  }
}

当切换值更改时,您可以通过更改其Alpha值来动画设置Image View, Info ButtonMap View的可见性。

现在为Info Button添加action并连接它:

@IBAction func infoButtonTapped() {
  let badge = Badge.best(for: run.distance)
  let alert = UIAlertController(title: badge.name,
                                message: badge.information,
                                preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel))
  present(alert, animated: true)
}

这与您在BadgeDetailsViewController.swift中实现的按钮处理程序完全相同。

最后一步是将以下内容添加到configureView()的末尾:

let badge = Badge.best(for: run.distance)
badgeImageView.image = UIImage(named: badge.imageName)

您可以找到用户在跑步中获得的最后一个徽章并将其设置为显示。

Build并运行。 在跑步中发送模拟器,保存详细信息并尝试新的“Space Mode”


Mapping the Solar System In Your Town

跑步后的地图已经帮助您记住您的路线,甚至可以确定速度较低的特定区域。 现在,您将添加一项功能,准确显示每个徽章的获取位置。

MapKit使用annotations来显示诸如此类的点数据。 要创建annotations,您需要:

  • 遵循MKAnnotation的类,它提供描述annotations位置的坐标。
  • MKAnnotationView的子类,显示与annotations关联的信息。

要实现这一点,您将:

  • 创建遵循MKAnnotation的类BadgeAnnotation
  • 创建一个BadgeAnnotation对象数组并将它们添加到地图中。
  • 实现mapView(_:viewFor :)以创建MKAnnotationViews

将新的Swift文件添加到项目中并将其命名为BadgeAnnotation.swift。 将其内容替换为:

import MapKit

class BadgeAnnotation: MKPointAnnotation {
  let imageName: String
  
  init(imageName: String) {
    self.imageName = imageName
    super.init()
  }
}

MKPointAnnotation符合MKAnnotation,因此您只需要将图像名称传递给渲染系统。

打开RunDetailsViewController.swift并添加这个新方法:

private func annotations() -> [BadgeAnnotation] {
  var annotations: [BadgeAnnotation] = []
  let badgesEarned = Badge.allBadges.filter { $0.distance < run.distance }
  var badgeIterator = badgesEarned.makeIterator()
  var nextBadge = badgeIterator.next()
  let locations = run.locations?.array as! [Location]
  var distance = 0.0
  
  for (first, second) in zip(locations, locations.dropFirst()) {
    guard let badge = nextBadge else { break }
    let start = CLLocation(latitude: first.latitude, longitude: first.longitude)
    let end = CLLocation(latitude: second.latitude, longitude: second.longitude)
    distance += end.distance(from: start)
    if distance >= badge.distance {
      let badgeAnnotation = BadgeAnnotation(imageName: badge.imageName)
      badgeAnnotation.coordinate = end.coordinate
      badgeAnnotation.title = badge.name
      badgeAnnotation.subtitle = FormatDisplay.distance(badge.distance)
      annotations.append(badgeAnnotation)
      nextBadge = badgeIterator.next()
    }
  }
  
  return annotations
}

这将创建一个BadgeAnnotation对象数组,每个对象用于跑步时获得的每个徽章。

loadMap()的末尾添加以下内容:

mapView.addAnnotations(annotations())

这会将annotations放在地图上。

最后,将此方法添加到MKMapViewDelegate扩展:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
  guard let annotation = annotation as? BadgeAnnotation else { return nil }
  let reuseID = "checkpoint"
  var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseID)
  if annotationView == nil {
    annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID)
    annotationView?.image = #imageLiteral(resourceName: "mapPin")
    annotationView?.canShowCallout = true
  }
  annotationView?.annotation = annotation
        
  let badgeImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
  badgeImageView.image = UIImage(named: annotation.imageName)
  badgeImageView.contentMode = .scaleAspectFit
  annotationView?.leftCalloutAccessoryView = badgeImageView
        
  return annotationView
}

在这里,您为每个annotation创建一个MKAnnotationView,并将其配置为显示徽章的图像。

Build并运行。 在跑步中发送模拟器并在结束时保存跑步。 现在,地图将为每个获得的徽章添加annotation。 单击一个,您可以看到它的名称,图片和距离。


源码

1. Swift

首先看一下组织结构

看一下sb的内容

下面看一下源代码

1. AppDelegate.swift
import UIKit

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  
  var window: UIWindow?
  
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    UINavigationBar.appearance().tintColor = .white
    UINavigationBar.appearance().barTintColor = .black
    let locationManager = LocationManager.shared
    locationManager.requestWhenInUseAuthorization()
    return true
  }
  
  func applicationDidEnterBackground(_ application: UIApplication) {
    CoreDataStack.saveContext()
  }
  
  func applicationWillTerminate(_ application: UIApplication) {
    CoreDataStack.saveContext()
  }
  
}
2. HomeViewController.swift
import UIKit

class HomeViewController: UIViewController {
  
  override func viewDidLoad() {
    super.viewDidLoad()
  }
  
}
3. NewRunViewController.swift
import UIKit
import CoreLocation
import MapKit
import AVFoundation

class NewRunViewController: UIViewController {
  
  @IBOutlet weak var launchPromptStackView: UIStackView!
  @IBOutlet weak var dataStackView: UIStackView!
  @IBOutlet weak var startButton: UIButton!
  @IBOutlet weak var stopButton: UIButton!
  @IBOutlet weak var distanceLabel: UILabel!
  @IBOutlet weak var timeLabel: UILabel!
  @IBOutlet weak var paceLabel: UILabel!
  @IBOutlet weak var mapContainerView: UIView!
  @IBOutlet weak var mapView: MKMapView!
  @IBOutlet weak var badgeStackView: UIStackView!
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var badgeInfoLabel: UILabel!
  
  private var run: Run?
  private let locationManager = LocationManager.shared
  private var seconds = 0
  private var timer: Timer?
  private var distance = Measurement(value: 0, unit: UnitLength.meters)
  private var locationList: [CLLocation] = []
  private var upcomingBadge: Badge!
  private let successSound: AVAudioPlayer = {
    guard let successSound = NSDataAsset(name: "success") else {
      return AVAudioPlayer()
    }
    return try! AVAudioPlayer(data: successSound.data)
  }()

  override func viewDidLoad() {
    super.viewDidLoad()
    dataStackView.isHidden = true // required to work around behavior change in Xcode 9 beta 1
    badgeStackView.isHidden = true // required to work around behavior change in Xcode 9 beta 1
  }
  override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    timer?.invalidate()
    locationManager.stopUpdatingLocation()
  }
  
  @IBAction func startTapped() {
    startRun()
  }
  
  @IBAction func stopTapped() {
    let alertController = UIAlertController(title: "End run?",
                                            message: "Do you wish to end your run?",
                                            preferredStyle: .actionSheet)
    alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
      self.stopRun()
      self.saveRun()
      self.performSegue(withIdentifier: .details, sender: nil)
    })
    alertController.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
      self.stopRun()
      _ = self.navigationController?.popToRootViewController(animated: true)
    })
    
    present(alertController, animated: true)
  }
  
  private func startRun() {
    launchPromptStackView.isHidden = true
    dataStackView.isHidden = false
    startButton.isHidden = true
    stopButton.isHidden = false
    mapContainerView.isHidden = false
    mapView.removeOverlays(mapView.overlays)
    
    seconds = 0
    distance = Measurement(value: 0, unit: UnitLength.meters)
    locationList.removeAll()
    badgeStackView.isHidden = false
    upcomingBadge = Badge.next(for: 0)
    badgeImageView.image = UIImage(named: upcomingBadge.imageName)
    updateDisplay()
    timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
      self.eachSecond()
    }
    startLocationUpdates()
  }
  
  private func stopRun() {
    launchPromptStackView.isHidden = false
    dataStackView.isHidden = true
    startButton.isHidden = false
    stopButton.isHidden = true
    mapContainerView.isHidden = true
    badgeStackView.isHidden = true
    
    locationManager.stopUpdatingLocation()
  }
  
  func eachSecond() {
    seconds += 1
    checkNextBadge()
    updateDisplay()
  }
  
  private func updateDisplay() {
    let formattedDistance = FormatDisplay.distance(distance)
    let formattedTime = FormatDisplay.time(seconds)
    let formattedPace = FormatDisplay.pace(distance: distance,
                                           seconds: seconds,
                                           outputUnit: UnitSpeed.minutesPerMile)
    
    distanceLabel.text = "Distance:  \(formattedDistance)"
    timeLabel.text = "Time:  \(formattedTime)"
    paceLabel.text = "Pace:  \(formattedPace)"
    
    let distanceRemaining = upcomingBadge.distance - distance.value
    let formattedDistanceRemaining = FormatDisplay.distance(distanceRemaining)
    badgeInfoLabel.text = "\(formattedDistanceRemaining) until \(upcomingBadge.name)"
  }
  
  private func startLocationUpdates() {
    locationManager.delegate = self
    locationManager.activityType = .fitness
    locationManager.distanceFilter = 10
    locationManager.startUpdatingLocation()
  }
  
  private func saveRun() {
    let newRun = Run(context: CoreDataStack.context)
    newRun.distance = distance.value
    newRun.duration = Int16(seconds)
    newRun.timestamp = Date()
    
    for location in locationList {
      let locationObject = Location(context: CoreDataStack.context)
      locationObject.timestamp = location.timestamp
      locationObject.latitude = location.coordinate.latitude
      locationObject.longitude = location.coordinate.longitude
      newRun.addToLocations(locationObject)
    }
    
    CoreDataStack.saveContext()
    
    run = newRun
  }
  
  private func checkNextBadge() {
    let nextBadge = Badge.next(for: distance.value)
    if upcomingBadge != nextBadge {
      badgeImageView.image = UIImage(named: nextBadge.imageName)
      upcomingBadge = nextBadge
      successSound.play()
      AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
    }
  }
}

// MARK: - Navigation

extension NewRunViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "RunDetailsViewController"
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! RunDetailsViewController
      destination.run = run
    }
  }
}

// MARK: - Location Manager Delegate

extension NewRunViewController: CLLocationManagerDelegate {
  
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    for newLocation in locations {
      let howRecent = newLocation.timestamp.timeIntervalSinceNow
      guard newLocation.horizontalAccuracy < 20 && abs(howRecent) < 10 else { continue }
      
      if let lastLocation = locationList.last {
        let delta = newLocation.distance(from: lastLocation)
        distance = distance + Measurement(value: delta, unit: UnitLength.meters)
        let coordinates = [lastLocation.coordinate, newLocation.coordinate]
        mapView.add(MKPolyline(coordinates: coordinates, count: 2))
        let region = MKCoordinateRegionMakeWithDistance(newLocation.coordinate, 500, 500)
        mapView.setRegion(region, animated: true)
      }
      
      locationList.append(newLocation)
    }
  }
}

// MARK: - Map View Delegate

extension NewRunViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    guard let polyline = overlay as? MKPolyline else {
      return MKOverlayRenderer(overlay: overlay)
    }
    let renderer = MKPolylineRenderer(polyline: polyline)
    renderer.strokeColor = .blue
    renderer.lineWidth = 3
    return renderer
  }
}
4. RunDetailsViewController.swift
import UIKit
import MapKit

class RunDetailsViewController: UIViewController {
  
  @IBOutlet weak var mapView: MKMapView!
  @IBOutlet weak var distanceLabel: UILabel!
  @IBOutlet weak var dateLabel: UILabel!
  @IBOutlet weak var timeLabel: UILabel!
  @IBOutlet weak var paceLabel: UILabel!
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var badgeInfoButton: UIButton!
  
  var run: Run!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    configureView()
  }
  
  @IBAction func displayModeToggled(_ sender: UISwitch) {
    UIView.animate(withDuration: 0.2) {
      self.badgeImageView.alpha = sender.isOn ? 1 : 0
      self.badgeInfoButton.alpha = sender.isOn ? 1 : 0
      self.mapView.alpha = sender.isOn ? 0 : 1
    }
  }
  
  @IBAction func infoButtonTapped() {
    let badge = Badge.best(for: run.distance)
    let alert = UIAlertController(title: badge.name,
                                  message: badge.information,
                                  preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .cancel))
    present(alert, animated: true)
  }

  private func configureView() {
    let distance = Measurement(value: run.distance, unit: UnitLength.meters)
    let seconds = Int(run.duration)
    let formattedDistance = FormatDisplay.distance(distance)
    let formattedDate = FormatDisplay.date(run.timestamp)
    let formattedTime = FormatDisplay.time(seconds)
    let formattedPace = FormatDisplay.pace(distance: distance,
                                           seconds: seconds,
                                           outputUnit: UnitSpeed.minutesPerMile)
    
    distanceLabel.text = "Distance:  \(formattedDistance)"
    dateLabel.text = formattedDate
    timeLabel.text = "Time:  \(formattedTime)"
    paceLabel.text = "Pace:  \(formattedPace)"
    
    loadMap()
    
    let badge = Badge.best(for: run.distance)
    badgeImageView.image = UIImage(named: badge.imageName)
  }
  
  private func mapRegion() -> MKCoordinateRegion? {
    guard
      let locations = run.locations,
      locations.count > 0
    else {
      return nil
    }
    
    let latitudes = locations.map { location -> Double in
      let location = location as! Location
      return location.latitude
    }
    
    let longitudes = locations.map { location -> Double in
      let location = location as! Location
      return location.longitude
    }
    
    let maxLat = latitudes.max()!
    let minLat = latitudes.min()!
    let maxLong = longitudes.max()!
    let minLong = longitudes.min()!
    
    let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2,
                                        longitude: (minLong + maxLong) / 2)
    let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * 1.3,
                                longitudeDelta: (maxLong - minLong) * 1.3)
    return MKCoordinateRegion(center: center, span: span)
  }
  
  private func polyLine() -> [MulticolorPolyline] {
    
    // 1
    let locations = run.locations?.array as! [Location]
    var coordinates: [(CLLocation, CLLocation)] = []
    var speeds: [Double] = []
    var minSpeed = Double.greatestFiniteMagnitude
    var maxSpeed = 0.0
    
    // 2
    for (first, second) in zip(locations, locations.dropFirst()) {
      let start = CLLocation(latitude: first.latitude, longitude: first.longitude)
      let end = CLLocation(latitude: second.latitude, longitude: second.longitude)
      coordinates.append((start, end))
      
      //3
      let distance = end.distance(from: start)
      let time = second.timestamp!.timeIntervalSince(first.timestamp! as Date)
      let speed = time > 0 ? distance / time : 0
      speeds.append(speed)
      minSpeed = min(minSpeed, speed)
      maxSpeed = max(maxSpeed, speed)
    }
    
    //4
    let midSpeed = speeds.reduce(0, +) / Double(speeds.count)
    
    //5
    var segments: [MulticolorPolyline] = []
    for ((start, end), speed) in zip(coordinates, speeds) {
      let coords = [start.coordinate, end.coordinate]
      let segment = MulticolorPolyline(coordinates: coords, count: 2)
      segment.color = segmentColor(speed: speed,
                                   midSpeed: midSpeed,
                                   slowestSpeed: minSpeed,
                                   fastestSpeed: maxSpeed)
      segments.append(segment)
    }
    return segments
  }
  
  private func loadMap() {
    guard
      let locations = run.locations,
      locations.count > 0,
      let region = mapRegion()
    else {
        let alert = UIAlertController(title: "Error",
                                      message: "Sorry, this run has no locations saved",
                                      preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .cancel))
        present(alert, animated: true)
        return
    }
    
    mapView.setRegion(region, animated: true)
    mapView.addOverlays(polyLine())
    mapView.addAnnotations(annotations())
  }
  
  private func segmentColor(speed: Double, midSpeed: Double, slowestSpeed: Double, fastestSpeed: Double) -> UIColor {
    enum BaseColors {
      static let r_red: CGFloat = 1
      static let r_green: CGFloat = 20 / 255
      static let r_blue: CGFloat = 44 / 255
      
      static let y_red: CGFloat = 1
      static let y_green: CGFloat = 215 / 255
      static let y_blue: CGFloat = 0
      
      static let g_red: CGFloat = 0
      static let g_green: CGFloat = 146 / 255
      static let g_blue: CGFloat = 78 / 255
    }
    
    let red, green, blue: CGFloat
    
    if speed < midSpeed {
      let ratio = CGFloat((speed - slowestSpeed) / (midSpeed - slowestSpeed))
      red = BaseColors.r_red + ratio * (BaseColors.y_red - BaseColors.r_red)
      green = BaseColors.r_green + ratio * (BaseColors.y_green - BaseColors.r_green)
      blue = BaseColors.r_blue + ratio * (BaseColors.y_blue - BaseColors.r_blue)
    } else {
      let ratio = CGFloat((speed - midSpeed) / (fastestSpeed - midSpeed))
      red = BaseColors.y_red + ratio * (BaseColors.g_red - BaseColors.y_red)
      green = BaseColors.y_green + ratio * (BaseColors.g_green - BaseColors.y_green)
      blue = BaseColors.y_blue + ratio * (BaseColors.g_blue - BaseColors.y_blue)
    }
    
    return UIColor(red: red, green: green, blue: blue, alpha: 1)
  }
  
  private func annotations() -> [BadgeAnnotation] {
    var annotations: [BadgeAnnotation] = []
    let badgesEarned = Badge.allBadges.filter { $0.distance < run.distance }
    var badgeIterator = badgesEarned.makeIterator()
    var nextBadge = badgeIterator.next()
    let locations = run.locations?.array as! [Location]
    var distance = 0.0
    
    for (first, second) in zip(locations, locations.dropFirst()) {
      guard let badge = nextBadge else { break }
      let start = CLLocation(latitude: first.latitude, longitude: first.longitude)
      let end = CLLocation(latitude: second.latitude, longitude: second.longitude)
      distance += end.distance(from: start)
      if distance >= badge.distance {
        let badgeAnnotation = BadgeAnnotation(imageName: badge.imageName)
        badgeAnnotation.coordinate = end.coordinate
        badgeAnnotation.title = badge.name
        badgeAnnotation.subtitle = FormatDisplay.distance(badge.distance)
        annotations.append(badgeAnnotation)
        nextBadge = badgeIterator.next()
      }
    }
    
    return annotations
  }
}

// MARK: - Map View Delegate

extension RunDetailsViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    guard let polyline = overlay as? MulticolorPolyline else {
      return MKOverlayRenderer(overlay: overlay)
    }
    let renderer = MKPolylineRenderer(polyline: polyline)
    renderer.strokeColor = polyline.color
    renderer.lineWidth = 3
    return renderer
  }
  
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
    guard let annotation = annotation as? BadgeAnnotation else { return nil }
    let reuseID = "checkpoint"
    var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseID)
    if annotationView == nil {
      annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID)
      annotationView?.image = #imageLiteral(resourceName: "mapPin")
      annotationView?.canShowCallout = true
    }
    annotationView?.annotation = annotation
    
    let badgeImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
    badgeImageView.image = UIImage(named: annotation.imageName)
    badgeImageView.contentMode = .scaleAspectFit
    annotationView?.leftCalloutAccessoryView = badgeImageView
    
    return annotationView
  }
}
5. BadgesTableViewController.swift
import UIKit
import CoreData

class BadgesTableViewController: UITableViewController {
  
  var statusList: [BadgeStatus]!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    statusList = BadgeStatus.badgesEarned(runs: getRuns())
  }
  
  private func getRuns() -> [Run] {
    let fetchRequest: NSFetchRequest<Run> = Run.fetchRequest()
    let sortDescriptor = NSSortDescriptor(key: #keyPath(Run.timestamp), ascending: true)
    fetchRequest.sortDescriptors = [sortDescriptor]
    do {
      return try CoreDataStack.context.fetch(fetchRequest)
    } catch {
      return []
    }
  }
}

// MARK: - Table View Data Source

extension BadgesTableViewController {
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return statusList.count
  }
  
  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell: BadgeCell = tableView.dequeueReusableCell(for: indexPath)
    cell.status = statusList[indexPath.row]
    return cell
  }
}

// MARK: - Navigation

extension BadgesTableViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "BadgeDetailsViewController"
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! BadgeDetailsViewController
      let indexPath = tableView.indexPathForSelectedRow!
      destination.status = statusList[indexPath.row]
    }
  }
  
  override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
    guard let segue = SegueIdentifier(rawValue: identifier) else { return false }
    switch segue {
    case .details:
      guard let cell = sender as? UITableViewCell else { return false }
      return cell.accessoryType == .disclosureIndicator
    }
  }
}
6. BadgeDetailsViewController.swift
import UIKit

class BadgeDetailsViewController: UIViewController {
  
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var distanceLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!
  @IBOutlet weak var bestLabel: UILabel!
  @IBOutlet weak var silverLabel: UILabel!
  @IBOutlet weak var goldLabel: UILabel!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
  
  var status: BadgeStatus!
  
  override func viewDidLoad() {
    super.viewDidLoad()
    let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)
    
    badgeImageView.image = UIImage(named: status.badge.imageName)
    nameLabel.text = status.badge.name
    distanceLabel.text = FormatDisplay.distance(status.badge.distance)
    let earnedDate = FormatDisplay.date(status.earned?.timestamp)
    earnedLabel.text = "Reached on \(earnedDate)"
    
    let bestDistance = Measurement(value: status.best!.distance, unit: UnitLength.meters)
    let bestPace = FormatDisplay.pace(distance: bestDistance,
                                      seconds: Int(status.best!.duration),
                                      outputUnit: UnitSpeed.minutesPerMile)
    let bestDate = FormatDisplay.date(status.earned?.timestamp)
    bestLabel.text = "Best: \(bestPace), \(bestDate)"
    
    let earnedDistance = Measurement(value: status.earned!.distance, unit: UnitLength.meters)
    let earnedDuration = Int(status.earned!.duration)
    
    if let silver = status.silver {
      silverImageView.transform = badgeRotation
      silverImageView.alpha = 1
      let silverDate = FormatDisplay.date(silver.timestamp)
      silverLabel.text = "Earned on \(silverDate)"
    } else {
      silverImageView.alpha = 0
      let silverDistance = earnedDistance * BadgeStatus.silverMultiplier
      let pace = FormatDisplay.pace(distance: silverDistance,
                                    seconds: earnedDuration,
                                    outputUnit: UnitSpeed.minutesPerMile)
      silverLabel.text = "Pace < \(pace) for silver!"
    }
    
    if let gold = status.gold {
      goldImageView.transform = badgeRotation
      goldImageView.alpha = 1
      let goldDate = FormatDisplay.date(gold.timestamp)
      goldLabel.text = "Earned on \(goldDate)"
    } else {
      goldImageView.alpha = 0
      let goldDistance = earnedDistance * BadgeStatus.goldMultiplier
      let pace = FormatDisplay.pace(distance: goldDistance,
                                    seconds: earnedDuration,
                                    outputUnit: UnitSpeed.minutesPerMile)
      goldLabel.text = "Pace < \(pace) for gold!"
    }
  }
  
  @IBAction func infoButtonTapped() {
    let alert = UIAlertController(title: status.badge.name,
                                  message: status.badge.information,
                                  preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "OK", style: .cancel))
    present(alert, animated: true)
  }
}
7. CoreDataStack.swift
import CoreData

class CoreDataStack {
  
  static let persistentContainer: NSPersistentContainer = {
    let container = NSPersistentContainer(name: "MoonRunner")
    container.loadPersistentStores { (_, error) in
      if let error = error as NSError? {
        fatalError("Unresolved error \(error), \(error.userInfo)")
      }
    }
    return container
  }()
  
  static var context: NSManagedObjectContext { return persistentContainer.viewContext }
  
  class func saveContext () {
    let context = persistentContainer.viewContext
    
    guard context.hasChanges else {
      return
    }
    
    do {
      try context.save()
    } catch {
      let nserror = error as NSError
      fatalError("Unresolved error \(nserror), \(nserror.userInfo)")
    }
  }
}
8. Badge.swift
import Foundation

struct Badge {
  let name: String
  let imageName: String
  let information: String
  let distance: Double
  
  init?(from dictionary: [String: String]) {
    guard
      let name = dictionary["name"],
      let imageName = dictionary["imageName"],
      let information = dictionary["information"],
      let distanceString = dictionary["distance"],
      let distance = Double(distanceString)
    else {
        return nil
    }
    self.name = name
    self.imageName = imageName
    self.information = information
    self.distance = distance
  }
  
  static let allBadges: [Badge] = {
    guard let fileURL = Bundle.main.url(forResource: "badges", withExtension: "txt") else {
      fatalError("No badges.txt file found")
    }
    do {
      let jsonData = try Data(contentsOf: fileURL, options: .mappedIfSafe)
      let jsonResult = try JSONSerialization.jsonObject(with: jsonData) as! [[String: String]]
      return jsonResult.flatMap(Badge.init)
    } catch {
      fatalError("Cannot decode badges.txt")
    }
  }()
  
  static func best(for distance: Double) -> Badge {
    return allBadges.filter { $0.distance < distance }.last ?? allBadges.first!
  }
  
  static func next(for distance: Double) -> Badge {
    return allBadges.filter { distance < $0.distance }.first ?? allBadges.last!
  }
}

// MARK: - Equatable

extension Badge: Equatable {
  static func ==(lhs: Badge, rhs: Badge) -> Bool {
    return lhs.name == rhs.name
  }
}
9. BadgeStatus.swift
import Foundation

struct BadgeStatus {
  let badge: Badge
  let earned: Run?
  let silver: Run?
  let gold: Run?
  let best: Run?
  
  static let silverMultiplier = 1.05
  static let goldMultiplier = 1.1
  
  static func badgesEarned(runs: [Run]) -> [BadgeStatus] {
    return Badge.allBadges.map { badge in
      var earned: Run?
      var silver: Run?
      var gold: Run?
      var best: Run?
      
      for run in runs where run.distance > badge.distance {
         if earned == nil {
          earned = run
        }
        
        let earnedSpeed = earned!.distance / Double(earned!.duration)
        let runSpeed = run.distance / Double(run.duration)
        
        if silver == nil && runSpeed > earnedSpeed * silverMultiplier {
          silver = run
        }
        
        if gold == nil && runSpeed > earnedSpeed * goldMultiplier {
          gold = run
        }
        
        if let existingBest = best {
          let bestSpeed = existingBest.distance / Double(existingBest.duration)
          if runSpeed > bestSpeed {
            best = run
          }
        } else {
          best = run
        }
      }
      
      return BadgeStatus(badge: badge, earned: earned, silver: silver, gold: gold, best: best)
    }
  }
}
10. BadgeCell.swift
import UIKit

class BadgeCell: UITableViewCell {
  
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!
  
  var status: BadgeStatus! {
    didSet {
      configure()
    }
  }
  
  private let redLabel = #colorLiteral(red: 1, green: 0.07843137255, blue: 0.1725490196, alpha: 1)
  private let greenLabel = #colorLiteral(red: 0, green: 0.5725490196, blue: 0.3058823529, alpha: 1)
  private let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)
  
  private func configure() {
    silverImageView.isHidden = status.silver == nil
    goldImageView.isHidden = status.gold == nil
    if let earned = status.earned {
      nameLabel.text = status.badge.name
      nameLabel.textColor = greenLabel
      let dateEarned = FormatDisplay.date(earned.timestamp)
      earnedLabel.text = "Earned: \(dateEarned)"
      earnedLabel.textColor = greenLabel
      badgeImageView.image = UIImage(named: status.badge.imageName)
      silverImageView.transform = badgeRotation
      goldImageView.transform = badgeRotation
      isUserInteractionEnabled = true
      accessoryType = .disclosureIndicator
    } else {
      nameLabel.text = "?????"
      nameLabel.textColor = redLabel
      let formattedDistance = FormatDisplay.distance(status.badge.distance)
      earnedLabel.text = "Run \(formattedDistance) to earn"
      earnedLabel.textColor = redLabel
      badgeImageView.image = nil
      isUserInteractionEnabled = false
      accessoryType = .none
      selectionStyle = .none
    }
  }
}
11. BadgeAnnotation.swift
import MapKit

class BadgeAnnotation: MKPointAnnotation {
  let imageName: String
  
  init(imageName: String) {
    self.imageName = imageName
    super.init()
  }
}
12. StoryboardSupport.swift
import UIKit

protocol StoryboardIdentifiable {
  static var storyboardIdentifier: String { get }
}

extension StoryboardIdentifiable where Self: UIViewController {
  static var storyboardIdentifier: String {
    return String(describing: self)
  }
}

extension StoryboardIdentifiable where Self: UITableViewCell {
  static var storyboardIdentifier: String {
    return String(describing: self)
  }
}



extension UIViewController: StoryboardIdentifiable { }
extension UITableViewCell: StoryboardIdentifiable { }


extension UITableView {
  func dequeueReusableCell<T: UITableViewCell>(for indexPath: IndexPath) -> T {
    guard let cell = dequeueReusableCell(withIdentifier: T.storyboardIdentifier, for: indexPath) as? T else {
      fatalError("Could not find table view cell with identifier \(T.storyboardIdentifier)")
    }
    return cell
  }
  
  func cellForRow<T: UITableViewCell>(at indexPath: IndexPath) -> T {
    guard let cell = cellForRow(at: indexPath) as? T else {
      fatalError("Could not get cell as type \(T.self)")
    }
    return cell
  }
}



/// Use in view controllers:
///
/// 1) Have view controller conform to SegueHandlerType
/// 2) Add `enum SegueIdentifier: String { }` to conformance
/// 3) Manual segues are trigged by `performSegue(with:sender:)`
/// 4) `prepare(for:sender:)` does a `switch segueIdentifier(for: segue)` to select the appropriate segue case

protocol SegueHandlerType {
  associatedtype SegueIdentifier: RawRepresentable
}

extension SegueHandlerType where Self: UIViewController, SegueIdentifier.RawValue == String {
  
  func performSegue(withIdentifier identifier: SegueIdentifier, sender: Any?) {
    performSegue(withIdentifier: identifier.rawValue, sender: sender)
  }
  
  func segueIdentifier(for segue: UIStoryboardSegue) -> SegueIdentifier {
    guard
      let identifier = segue.identifier,
      let segueIdentifier = SegueIdentifier(rawValue: identifier)
      else {
        fatalError("Invalid segue identifier: \(String(describing: segue.identifier))")
    }
    
    return segueIdentifier
  }
  
}
13. UnitExtensions.swift
import Foundation

class UnitConverterPace: UnitConverter {
  private let coefficient: Double
  
  init(coefficient: Double) {
    self.coefficient = coefficient
  }
  
  override func baseUnitValue(fromValue value: Double) -> Double {
    return reciprocal(value * coefficient)
  }
  
  override func value(fromBaseUnitValue baseUnitValue: Double) -> Double {
    return reciprocal(baseUnitValue * coefficient)
  }
  
  private func reciprocal(_ value: Double) -> Double {
    guard value != 0 else { return 0 }
    return 1.0 / value
  }
}

extension UnitSpeed {
  class var secondsPerMeter: UnitSpeed {
    return UnitSpeed(symbol: "sec/m", converter: UnitConverterPace(coefficient: 1))
  }
  
  class var minutesPerKilometer: UnitSpeed {
    return UnitSpeed(symbol: "min/km", converter: UnitConverterPace(coefficient: 60.0 / 1000.0))
  }
  
  class var minutesPerMile: UnitSpeed {
    return UnitSpeed(symbol: "min/mi", converter: UnitConverterPace(coefficient: 60.0 / 1609.34))
  }
}
14. FormatDisplay.swift
import Foundation

struct FormatDisplay {
  static func distance(_ distance: Double) -> String {
    let distanceMeasurement = Measurement(value: distance, unit: UnitLength.meters)
    return FormatDisplay.distance(distanceMeasurement)
  }
  
  static func distance(_ distance: Measurement<UnitLength>) -> String {
    let formatter = MeasurementFormatter()
    return formatter.string(from: distance)
  }
  
  static func time(_ seconds: Int) -> String {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.hour, .minute, .second]
    formatter.unitsStyle = .positional
    formatter.zeroFormattingBehavior = .pad
    return formatter.string(from: TimeInterval(seconds))!
  }
  
  static func pace(distance: Measurement<UnitLength>, seconds: Int, outputUnit: UnitSpeed) -> String {
    let formatter = MeasurementFormatter()
    formatter.unitOptions = [.providedUnit] // 1
    let speedMagnitude = seconds != 0 ? distance.value / Double(seconds) : 0
    let speed = Measurement(value: speedMagnitude, unit: UnitSpeed.metersPerSecond)
    return formatter.string(from: speed.converted(to: outputUnit))
  }
  
  static func date(_ timestamp: Date?) -> String {
    guard let timestamp = timestamp as Date? else { return "" }
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    return formatter.string(from: timestamp)
  }
}
15. LocationManager.swift
import CoreLocation

class LocationManager {
  static let shared = CLLocationManager()
  
  private init() { }
}
16. MulticolorPolyline.swift
import UIKit
import MapKit

class MulticolorPolyline: MKPolyline {
  var color = UIColor.black
}

看一个简单的效果

后记

本篇主要讲述了仿Runkeeper的简单实现,感兴趣的给个赞或者关注~~~

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,240评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,328评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,182评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,121评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,135评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,093评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,013评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,854评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,295评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,513评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,398评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,989评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,636评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,657评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容