iOS-如何开发一款类 Runkeeper 的跑步应用 (上)

翻译自:https://www.raywenderlich.com/155772/make-app-like-runkeeper-part-1-2

更新提醒:
本教程已由 Richard Critz 更新到 iOS 11 Beta 1, Xcode 9 和 Swift 4。原作者为Matt Luedke。

How To Make an App Like Runkeeper: Part 1

跑步激励追踪应用 Runkeeper 目前有4000万用户 ! 本教程将教您开发一款类Runkeeper应用,您将会学到以下内容:

  • 使用  Core Location 追踪路线.
  • 跑步过程中显示一个地图并不断的更新位置.
  • 当您跑步时记录下您的平均速度.
  • 不同距离授予不同的徽章. 无论你的跑步起始点在哪里,每个徽章都由银色和金色两种组成,用于表示个人进度.
  • 通过跟踪到下一级徽章的剩余距离来激励你.
  • 当跑步结束后显示一个路线地图. 不同颜色的线段表示不同的速度.

成果是什么? 开发一款app — MoonRunner — 徽章系统基于太阳系中的行星和卫星!

开始本教程之前, 你应该熟悉 Storyboards Core Data. 如果您绝得需要复习下知识,请查阅链接教程.

本教程同时也使用了 iOS 10中新增加的  Measurement MeasurementFormatter 功能. 更多了解请观看视频.

鉴于内容众多,本教程将分为两部分. 第一部分重点讲解 记录跑步数据和地图路线展示. 第二部分介绍了徽章系统.

开始

下载 项目模板. 其中包括要完成本教程的所有文件和资源.

花费几分钟熟悉下项目. Main.storyboard 已经包含了 所有UI界面. 将AppDelegate中关于Core Data的模板代码移到CoreDataStack.swift中.Assets.xcassets 中包含了将要使用的图片和声音文件.

模型: Runs 和 Locations

MoonRunner 使用 Core Data 相对简单, 仅仅使用了两个实体: RunLocation.

打开 MoonRunner.xcdatamodeld 同时创建两个实体: RunLocation. 在Run 中添加如下属性:

app like runkeeper - Run properties

 Run 有三个属性: distance, duration timestamp. 其中有一个关联, locations, 关联到 Location 实体.

注意: 在下一步之前你不能设置 Inverse 关联. 这将会引起一个警告. 不要惊慌!

接着,  给Location 添加如下属性:

Location properties

Location 也有三个属性: latitude, longitude timestamp及一个关联, run.

选择关联实体同时验证 locations 关联的 Inverse 属性 已经变为 “run”.

app like runkeeper - Run properties completed

选择 locations 关联, 设置 Type类型To Many, 同时在Data Model Inspector’s Relationship的面板 选中 Ordered .

locations data model inspector

最后, 在Data Model Inspector面板中分别验证 RunLocation 实体的Codegen 属性 设置为Class Definition  (这是默认设置).

app like runkeeper Codegen properties

编译项目让Xcode 生成Core Data 模型对应的Swift 代码.

完成基本的应用流程

打开 RunDetailsViewController.swift ,在  viewDidLoad()之前添加如下代码:

var run: Run!

接着, 在 viewDidLoad() 之后添加方法:

private func configureView() {
}

最后, 在 viewDidLoad()super.viewDidLoad()之后添加 configureView().

configureView()

这个设置是app完成导航的最低要求.

打开NewRunViewController.swift 并在 viewDidLoad()之前添加:

private var run: Run?

接着, 添加如下新方法:

private func startRun() {
  launchPromptStackView.isHidden = true
  dataStackView.isHidden = false
  startButton.isHidden = true
  stopButton.isHidden = false
}
  
private func stopRun() {
  launchPromptStackView.isHidden = false
  dataStackView.isHidden = true
  startButton.isHidden = false
  stopButton.isHidden = true
}

停止按钮和 UIStackView 在storyboard中默认为隐藏状态 . 这些实例用于在 跑步状态和非跑步状态进行切换.

startTapped()中添加对 startRun()的调用.

startRun()

在文件的底部, 大括号之后, 添加如下扩展:

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
    }
  }
}

大家都知道,storyboard 的 segue 是"字符串类型".  segue 标识符是一个字符串 并且没有错误检查. 在StoryboardSupport.swift文件中,使用协议和枚举及一点点魔法, 你就能避免使用 "字符串类型"带来的不便.

接着, 在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.performSegue(withIdentifier: .details, sender: nil)
})
alertController.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
  self.stopRun()
  _ = self.navigationController?.popToRootViewController(animated: true)
})
    
present(alertController, animated: true)

当用户按下停止按钮, 你需要让他们决定是 保存,放弃还是继续. 你可以使用一个 UIAlertController 弹框来让用户做出抉择.

编译并运行. 按下 "New Run" 按钮接着再按"Start" 按钮. 验证 UI界面已经变为了 “跑步模式”:

Running mode

按下 Stop 按钮 同时 按下Save ,您将进入详细页面.

app like runkeeper Details screen

注意: 在控制台, 你将会看到类似如下的一些错误信息:
MoonRunner[5400:226999] [VKDefault] /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

这是正常的,对于你而言这并不代表一个错误.

单位 和 格式化

iOS 10 引入了新功能,使其更容易使用和显示度量单位. 跑步者度量进度往往采用速度(单位距离消耗的时间),它是速度(单位时间的距离)的倒数.你必须扩展UnitSpeed来实现这种计算方式.

项目中添加一个文件: UnitExtensions.swift. 在 import 语句后添加:

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
  }
}

在你扩展 UnitSpeed 的速度转换功能之前, 你必须创建 UnitConverter 用于数学计算. UnitConverter 子类需要实现baseUnitValue(fromValue:)value(fromBaseUnitValue:).

现在, 在文件末尾添加如下代码

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))
  }
}

UnitSpeed 是Foundation中 Units下的一个类 . UnitSpeed的默认 单位为 “米/秒”. 你的扩展中可以让速度 按照 分/千米分/米来表示.

你需要统一的方式来显示这些定量信息如距离, 时间, 速度和日期. MeasurementFormatterDateFormatter 使得这些变得简单.

添加一个Swift 文件并命名为 FormatDisplay.swiftimport 语句后添加以下代码:

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)
  }
}

这些简单的函数功能不需要过多的解释. 在 pace(distance:seconds:outputUnit:)方法中,  你必须将MeasurementFormatterunitOptions设置为.providedUnits 避免它显示本地化的速度测量单位 (例如 mph 或 kph).

启动一个跑步任务

基本上可以开始跑步了. 但是首先, app需要知道它在哪里. 为此, 你将会使用 Core Location. 重要的是,在你的app中只能有一个CLLocationManager实例,它不能被无意中删除.

为此, 添加一个 Swift 文件,命名为 LocationManager.swift. 将其内容替换为:

import CoreLocation

class LocationManager {
  static let shared = CLLocationManager()
  
  private init() { }
}

在开始追踪用户位置之前,你必须做几个项目级别的修改.

首先, 在项目导航栏顶部点击项目.

app like runkeeper - select project options

选择 Capabilities 栏开启 Background Modes . 选中 Location updates.

app like runkeeper - Enable background location updates

接着, 打开 Info.plist. 点击紧挨着 Information Property List 的 +. 从下拉列表中选择Privacy – Location When In Use Usage Description 同时 设置其值为 “MoonRunner needs access to your location in order to record and track your run!”

Info.plist

注意: 这个 Info.plist key 是非常重要的. 如果没有它, 你的用户将不会为你的app来授权访问位置服务.

在你的app使用位置信息之前, 设备必须从用户那获得授权. 打开 AppDelegate.swiftapplication(_:didFinishLaunchingWithOptions:)中添加如下代码,在return true 之前即可:

let locationManager = LocationManager.shared
locationManager.requestWhenInUseAuthorization()

打开 NewRunViewController.swift 并且导入 CoreLocation:

import CoreLocation

接着, 在 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] = []

逐行解释下:

  • locationManager 是一个对象用户开启和关闭位置服务.
  • seconds 追踪跑步的时长, 以秒计算.
  • timer 每秒触发一次并相应的更新UI.
  • distance 存储累计跑步距离.
  • locationList 是一个数组,用于保存跑步期间所有的 CLLocation 对象.

viewDidLoad()之后添加以下方法:

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  timer?.invalidate()
  locationManager.stopUpdatingLocation()
}

当用户离开跑步页面时,这确保了timer和带来大耗电量位置更新的停止.

添加以下两个方法:

func eachSecond() {
  seconds += 1
  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)"
}

eachSecond() 会被每秒执行一次.

updateDisplay() 使用FormatDisplay.swift中实现的格式化功能来更新UI.

Core Location 通过CLLocationManagerDelegate 记录位置更新 . 在文件末尾添加扩展:

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)
      }

      locationList.append(newLocation)
    }
  }
}

每次 Core Location 更新用户位置时这个代理方法就会被调用, 参数中有一个存储 CLLocation 对象的数组. 通常这个数组只包含一个对象, 但是如果有多个, 他们会按照位置更新时间来排序.

CLLocation 包含了一些重要信息, 包括经度,维度和时间戳.

在采纳读数之前, 检查数据的准确性是值得的. 如果设备不能确定该读数是用户实际位置20米范围内的, 那么最好将其从数据库中删除. 确保数据是最近的也很重要.

注意: 当设备开始缩小用户区域时,这种检查在跑步的开始时尤为重要. 在那个阶段,它可能会更新一些头几个不准确的位置数据.

如果 此时的CLLocation 数据通过了检查, 那么其与最新记录点之间的距离与当前跑步距离进行累加, 距离以米为单位.

最后, 位置对象添加到不断增长的位置数组里.

回到NewRunViewController中添加如下方法 (不是扩展中):

private func startLocationUpdates() {
  locationManager.delegate = self
  locationManager.activityType = .fitness
  locationManager.distanceFilter = 10
  locationManager.startUpdatingLocation()
}

你需要实现这个代理,这样你能够接收和处理位置更新.

跑步类应用中activityType 参数应该这样设置. 这样可以帮助设备在用户跑步过程中节省电量, 比如他们在交叉路口停下来.

最后, 设置distanceFilter 为 10 米. 而不像activityType, 这个参数不会影响电量消耗. 

在跑步测试后,您将看到,位置读数可能会偏离直线. distanceFilter 值设置的过高可以减少上下波动,因此可以更加准确的展示路线. 不幸的是, 值设置的太高了会是读数像素化. 这就是为什么10米是一个折中值.

最后, 启动 Core Location 进行位置信息更新!

要想开始跑步任务, 在startRun()方法末尾添加如下代码 :

seconds = 0
distance = Measurement(value: 0, unit: UnitLength.meters)
locationList.removeAll()
updateDisplay()
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
  self.eachSecond()
}
startLocationUpdates()

在跑步状态或初始状态,这个将会重置更新的数据, 启动 Timer 用于每秒更新一次并收集位置更新.

保存跑步数据

某一时刻, 你的用户感觉累了并停止跑步. UI界面已经有让用户保存数据的功能, 但是你同样需要自动保存跑步数据,否则您的用户就会因为未保存数据所有的努力白费而不高兴.

NewRunViewController 中添加如下方法:

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
}

如果你使用过Swift3 之前版本的Core Data ,  你将会发现iOS 10中对Core Data支持的强大功能和简洁性. 创建一个 newRun 实例并初始化. 接着为每个记录的CLLocation创建一个Location 实例, 只保存相关的数据. 最后, 使用自动生成的方法addToLocations(_:)将每个Location 添加到newRun.

当用户结束跑步, 你需要停止位置追踪.  stopRun()方法末尾添加如下代码:

locationManager.stopUpdatingLocation()

最后, 在 stopTapped() 方法中定位到 UIAlertAction 标题为  "Save" 的位置,然后添加方法调用self.saveRun(),添加后的代码是这个样子的:

alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
  self.stopRun()
  self.saveRun() // ADD THIS LINE!
  self.performSegue(withIdentifier: .details, sender: nil)
})

Send the Simulator On a Run模拟器上模拟跑步

应用发布前,你应该在真机上进行测试, 但每次你想测试MoonRunner时,不必出去跑步.

编译并运行模拟器. 在按下 “New Run”按钮之前, 从模拟器菜单中选择 Debug\Location\City Run .

Simulate a city run

现在, 按下 New Run, 接着按下 Start ,模拟器已经开始模拟跑步.

app like runkeeper - First run by the simulator

地图展示

上述工作完成后, 我们需要展示用户的目的地和完成情况.

打开 RunDetailsViewController.swift 同时将configureView() 中替换为:

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)"
}

格式化跑步详细信息并显示.

在地图上显示跑步信息有些工作量. 需三步完成:

  1. 设置地图显示区域,仅仅显示跑步的区域而不是整个世界地图.
  2. 提供 一个描述覆盖图层的代理方法.
  3. 创建一个 MKOverlay 用于描述画线.

添加如下方法:

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)
}

MKCoordinateRegion 表示地图显示区域. 通过提供中心点和垂直,水平范围即可确定地图显示区域.

在文件末尾,大括号之后添加如下扩展:

extension RunDetailsViewController: 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 = .black
    renderer.lineWidth = 3
    return renderer
  }
}

 MapKit每次只能显示一个覆盖层. 现在, 如果 覆盖层 是一个MKPolyine (线段的集合), 返回配置为黑色的 MapKit的MKPolylineRenderer. 接下来将会彩色化这些线段.

最后, 你需要创建一个 overlay.  RunDetailsViewController中添加如下方法 (不是扩展中):

private func polyLine() -> MKPolyline {
  guard let locations = run.locations else {
    return MKPolyline()
  }
 
  let coords: [CLLocationCoordinate2D] = locations.map { location in
    let location = location as! Location
    return CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
  }
  return MKPolyline(coordinates: coords, count: coords.count)
}

这里, 你需要将跑步位置记录转换成MKPolyline需求的 CLLocationCoordinate2D 类型

现在将这些整合到一起. 添加如下方法:

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.add(polyLine())
}

这里,设置地图区域并且添加覆盖层.

现在, configureView()方法结尾添加如下调用.

loadMap()

编译并运行. 当你保存完成的跑步, 你将会看到跑步足迹地图!

app like runkeeper - First completed run map

注意: 在控制台, 你将会看到类似以下的错误信息:
ERROR /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1230.34.9.30.27/GeoGL/GeoGL/GLCoreContext.cpp 1763: InfoLog SolidRibbonShader:
ERROR /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1230.34.9.30.27/GeoGL/GeoGL/GLCoreContext.cpp 1764: WARNING: Output of vertex shader 'v_gradient' not read by fragment shader
/BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

模拟器上这很正常. 这些信息来自 MapKit ,对你来说这并不代表错误.

引入颜色

这个应用程序已经相当不错了,但是如果你用颜色来区别速度的差异,地图可能会更好。

增加一个Cocoa Touch 类文件, 将其命名为 MulticolorPolyline作为 MKPolyline的子类.

打开 MulticolorPolyline.swift 导入 MapKit:

import MapKit

类中添加 color 属性:

var color = UIColor.black

哇,就是如此简单! :] 现在, 难度来了, 打开 RunDetailsViewController.swift 添加如下方法:

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)
}

这里, 我们定义了三个基本颜色:红色,黄色和绿色. 接着你就可以根据从最慢到最快的速度范围生成混合颜色.

22_color_codes

polyLine() 中的代码替换为

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
}

以下是新版本的内容:

  1. polyline 由线段组成, 每段由端点标记. 收集用于描述每段的坐标对及每段的速度.
  2. 将端点转换成 CLLocation 对象并成对保存.
  3. 计算每段的速度. 注意, Core Location 偶尔会返回时间戳相同的多个更新,所以要防止除以0的错误问题. 保存速度并且更新最大和最小速度.
  4. 计算整个里程的平均速度.
  5. 使用之前计算好的坐标对生成新的MulticolorPolyline,并设置颜色.

loadMap()中的 mapView.add(polyLine()) 行, 你将会提示编译错误. 使用下面的代码替换:

mapView.addOverlays(polyLine())

MKMapViewDelegate 扩展中使用如下代码替换 mapView(_:rendererFor:) :

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
}

这同之前的版本非常相似.每个覆盖图层都是一个MulticolorPolyline并且使用内含的颜色渲染线段.

编译并运行! 让模拟器启动慢跑任务,最后看看彩色地图!

app like runkeeper - color-coded map

如何实时导航?

跑后的地图是惊人的, 但是如何在跑步期间也展示一个地图呢?

在 storyboard 中 使用 UIStackViews 即可方便添加一个!

首先, 打开 NewRunViewController.swift并导入 MapKit:

import MapKit

接着, 打开 Main.storyboard 并找到 New Run View Controller Scene. 确保大纲视图可见. 如果不可见, 点击红框标注的按钮:

document outline

向其中拖拽一个UIView并将其放到Top Stack ViewButton Stack View之间. 确保其实在他们的之间而不是在任何一个之中. 双击它并将其命名为Map Container View.

app like runkeeper - Add map container view

在 Attributes Inspector中, 选中Drawing下的 Hidden  .

set map container view hidden

在大纲视图中, Control+拖拽 从 Map Container ViewTop Stack View 同时在弹框中选择 Equal Widths .

Equal widths constraint

拖拽一个 MKMapView 添加到 Map Container View. 按下”Add New Constraints“ 按钮 (又名"钛战机按钮") 同时设置4个约束为 0. 确保 ”Constrain to margins“ 非选中状态. 点击Add 4 Constraints.

add mapview constraints

大纲视图中选中 Map View, 打开 Size Inspector (View\Utilities\Show Size Inspector). 在 constraint区域双击Bottom Space to: Superview.

Bottom constraint

改变优先次序为 High (750).

priority 750

在大纲视图, Control+拖拽 从 Map ViewNew Run View Controller 同时选中 delegate.

connect the delegate

打开 Assistant Editor, 确保是在  NewRunViewController.swift 并且 从 Map View Control+拖拽  创建一个名为 mapView 的 outlet. 从 Map Container ViewControl+拖拽 创建一个名为mapContainerView的outlet.

connect map outlets

关闭 Assistant Editor 并打开 NewRunViewController.swift.

startRun()顶部添加如下代码:

mapContainerView.isHidden = false
mapView.removeOverlays(mapView.overlays)

在 stopRun() 顶部 添加如下代码:

mapContainerView.isHidden = true

现在, 你需要一个MKMapViewDelegate 来进行线段的渲染. 在文件的末尾添加如下扩展:

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
  }
}

除了线是蓝色,这个同RunDetailsViewController.swift中的代理很像.

最后, 你只需要添加线段图层并更新地图区域,以使地图显示区域为当前跑步区域. 在 locationManager(_:didUpdateLocations:) 方法中的 代码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)

编译并运行同时启动一个跑步任务. 你将会看到实时更新的地图!

in-run map

下一步

yeti holding videos
想学得更快吗? 观看视频以节省时间

点击这里 下载 截止目前完成功能的项目代码.

你可能已经注意到用户的速度显示为"min/mi", 因为本地配置为以米显示距离 (或者千米).  通过调用 FormatDisplay.pace(distance:seconds:outputUnit:)可以在.minutesPerMile或.minutesPerKilometer进行选择显示方式.

继续第二部分 :如何开发一款类 Runkeeper 的跑步应用之引入徽章成就系统.

一如既往, 期待您的意见和问题! :]

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值