原文:How To Make an App Like Runkeeper: Part 1
作者:Richard Critz
译者:kmyhy

更新说明:本教程由 Matt Luedke 升级到 iOS 11 beta 1、Xcode 9 和 Swift 4。原文作者 Matt Luedke。

运动类记步 app Runkeeper 拥有超过 4 千万的用户!本教程教你如何编写 Runkeeper 这样的 app,包括:

  • 用 Core Location 记录你的路线。
  • 在跑步过程中显示地图,并用连续线段标出你的路线。
  • 报告跑步过程中的平均速度。
  • 对于不同距离的路程,授予不同的奖章。用各种银奖和金奖来表明个人的进步,无论你的起点有多低。
  • 用距离下一徽章剩余的里程数来进行激励。
  • 当你完成时,显示路线地图。线段用不同颜色标记你的速度。

最终成果是:你的新 app——MoonRunner——用太阳系中的行星和月亮来作为徽章。

在继续后面的教程之前,你应当熟悉 Storyboard 和 Core Data。如果你需要复习这些内容,请点击相应链接。

本教程使用了 iOS 10 的新的 Measurement 和 MeasurementFormatter 特性。要了解它们的更多细节,请点击相应屏幕录像的链接。

转入正题,本教程分为两部分。第一部分的内容主要是记录跑步数据和颜色标注地图的渲染。第二部分内容是徽章系统。

开始

下载开始项目。其中包含了所有本教程中需要用到的项目文件和图形资源。

来看一下项目结构。Main.storyboard 包含了 UI。CoreDataStack.swift 将苹果的 Core Data 模板代码从 AppDelegate 中移到了单独的类中。Assets.xcassets 包含了声音和图片。

模型:Run 和 Location

MoonRunner 用到的 Core Data 代码十分简单,只用到了两个实体:Run 和 Location。

打开 MoonRunner.xcdatamodeld ,创建两个实体: Run 和 Location。Run 属性包括:

Run 类包含了 3 个属性:distance、duration 和 timestamp。它只有一个关系:locations,连接了 Location 实体。

注意:只有在下一步完成后,你才能设置 Inverse 关系。这会出现一个警告,别理它!

然后,为 Location 添加如下属性:

Location 类也有 3 个属性:latitude、longitude 和 timestamp,以及一个关系:run。

选中 Run 实体,查看它的 locations 关系的 Inverse 属性,现在它变成了 run。

选中 location 关系,将 Type 设置为 To Many,在数据模型检视器的 Relation 面板中,勾选 Ordered 选项。

最后,在数据模型检视器的 Entity 面板中,看一眼 Run 和 Location 实体的 Codegen 属性,是不是被设置为 Class DeFinition(默认值)。

编译项目,让 Xcode 为 Core Data 模型生成对应的 Swift 定义。

实现 App 基本流程

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

stop 按钮和隐藏的、用于描述跑步的 UIStackView。这两个方法会在“正在跑”和“跑步中”来回切换 UI。

在 statTapped() 方法中,调用 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
    }
  }
}

一提到苹果的 segue ,我们就会想到“string 类型转换”。segue identifier 是一个字符串,不需要进行错误检查。利用 Swift 协议和枚举的功能,以及 StoryboardSupport.swift 一点小花招,我们就可以避免编写大量类似于“string 类型转换”的代码。

然后,在 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)

当用户点击 stop 按钮,你应当允许他保存、放弃或继续当前的这次跑步。我们用一个 alert 提示用户并获取用户的选择。

Build & run。点击 New Run 按钮,然后点 Start 按钮。你会看到 UI 变成了“跑步模式”:

点击 Stop 按钮,然后点 Save 按钮,你会进入详情页面。

注意:在控制台中,你可能会看到一些错误信息:

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

这是正常的,不表示代码有错。

Unit 和 Formatting

iOS 10 出现了一个新的能力,使得测量单位更容易被使用和显示。跑步爱好者习惯于在跑步中使用 pace 一词(单位距离内的时间),它是速度(单位时间内的距离)的倒数。

新建 Swift 文件 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 进行 pace 单位转换之前,我们需要创建一个 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 中提供的众多单位的一种。

UnitSpeed 的默认单位是“米/秒”。我们的扩展能够让速度以“分钟/km”或“分钟/英里”进行表达。

在整个 MoonRunner app 中,我们需要用一种规范的形式来显示距离、时间、pace 和日期。MeasurementFormatter 和 DateFormatter 让这个工作变得简单。

新建 Swift 文件 Formatdisplay.swift。在 import 之后添加代码:

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:) 方法中,你必须将 MeasurementFormatter 的 unitOptions 设置为 .providedUnits,以免它被显示成本地化的速度单位(比如 mph 或 kph)。

开始跑步

马上开始跑步了。但首先,app 必须知道当前位置。而要做到这个,你必须使用 Core Location。注意在你的 app 中只有一个 CLLocationMananger,不要在疏忽大意之下删除它。

为了实现这点,新建 Swift 文件 LocationManager.swift。编辑内容为:

import CoreLocation

class LocationManager {
  static let shared = CLLocationManager()

  private init() { }
}

这种开始记录用户位置之前,你还需要修改几处项目设置。

首先,在项目导航器中选中位于顶层的项目。

选择 Capabilities tab 将 Background Modes 设置为 ON。勾选 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 的这个 key 非常关键。如果不设置这个键,用户可能永远无法授权你的 app 使用 location 服务。

在 app 使用 location 服务之前,它必须被用户授权。打开 AppDelegate.swift,在 application(_:didFinishLaunchingWithOptions:) 方法的 return 之前加入:

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] = []

分别说明如下:

  1. locationManager 是用于启动和停止 location 服务的对象。
  2. seconds 用于记录跑步的时间,单位秒。
  3. timer 用于每秒触发一次方法调用,并刷新 UI。
  4. distance 用于保存跑步的累计长度。
  5. locationList 是一个数组,用于保存所有在跑步期间采集到的 CLLocation 数据。

在 viewDidLoad 之后新增方法:

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

因为位置刷新会增加电量消耗,因此当用户从该视图离开时,位置刷新和定时器会被停止。

新增两个方法:

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 Locatoin 刷新到用户位置时调用,它会返回一个 CLLocation 对象数组的参数。通常这个数组只会有一个对象,但多个对象时,它们会按照位置更新时间进行排序。

一个 CLLocation 包含大量信息,包括经纬度和采集时间。

在毫无条件地接受数据之前,需要检查一下数据的精度。如果设备没有拿到用户真实位置 20 米范围内的数据,则这个数据应当被抛弃。同样重要的还有一点,就是保持数据足够新。

注意:这个检查非常有必要,尤其对于刚一开始跑的时候,那个时候设备开始将用户的位置逐渐从大概范围定位到精确位置。在这个阶段,开始的几步可能会刷新到一些不精确的数据。

如果 CLLocation 检查通过,它和最近保存的位置之间的距离会被累加到这次跑步的累计距离中。distance(from:) 方法非常有用,它参考了地球曲率来进行复杂计算,返回一个以米为单位的长度。

最后,location 对象被添加到 locations 数组。

然后,将这个方法添加到 NewRunViewController类(不要加在扩展中):

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

你将这个类设置为 Core Location 的 delegate,这样就可以接收到位置刷新通知了。

activityType 参数是专门针对这一类型的 app 的。它有助于在用户跑步的过程中让设备进入智能节电模式,比如在横穿马路时停止位置刷新。

最后,将 distanceFilter 设置为 10 米。和 activityType 相反,这个参数不会对电池寿命有任何影响。activityType 会用于数据的采集,而 distanceFilter 只用于数据的通知。

在后面的测试中你会发现,位置数据会从直线上跑偏。将 distanceFilter 值提高有助于减少 z 形或锯齿状数据的出现,从而形成一条更精准的线条。

但太高的 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()

这里重置了所有在跑步中会改变的变量为初始状态,启动每秒定时器,开始收集位置刷新数据。

保存跑步练习

有时候,用户会感到疲倦然后停止练习。你有专门的 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
}

如果你使用过 Swift 3 之前的 Core Data,你会发现 iOS 10 的 Core Data 变得更加简单和功能强大了。我们新建了一个新的 Run 对象,填充它的属性。然后针对我们保存的 CLLocation 对象创建每个 Location 对象,填入对应的数据。最后,将所有新 Location 对象用自动生成的 addToLocations() 方法保存到 Run 中。

当用户终止练习,你需要停止记录位置。在 stopRun() 方法的末尾添加这一句:

locationManager.stopUpdatingLocation()

最后,在 stopTapped() 方法中找到 title 为 Save 的 UIAlertAction,在其中调用 self.saveRun(),变成这个样子:

alertController.addAction(UIAlertAction(title: "Save",style: .default) { _ in
  self.stopRun()
  self.saveRun() // 添加这句!!!
  self.performSegue(withIdentifier: .details,sender: nil)
})

发送到模拟器

虽然你可以在发布之前一直坚持在真机上测试 app,但没有必要每次测试 MoonRunner 时都做一次跑步练习。

在模拟器中 Build & run。在点击 New Run 按钮之前,从模拟器菜单中,选择 Debug\Location\City Run 。

然后,点 New Run,然后点 Start,观察模拟器是否正常工作。

绘制地图

干完这些重活儿,我们就可以将用户跑过的地方以及他们的成绩显示出来。

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

将本次联系的数据格式化并显示。

在地图上绘制出跑步练习的事情还真不少。需要 3 个步骤:

  1. 设置地图的 region,以便只显示跑过的区域,而不是世界地图。
  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
  }
}

每当地图准备绘制某个覆盖物时,它会询问委托对象绘制这个覆盖物需要用到的东西。这里,我们需要的覆盖物是一个 MKpolyine(线段集合),所以我们会返回 MapKit 中的 MKpolylineRenderer 对象,我们用这个对象来指定绘制的颜色为黑色。稍后我们会使用更多的颜色。

最后是创建覆盖物。在 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())
}

这里,首先保证要绘制的东西还在。然后设置地图的 region 并添加覆盖物。

在 configureView() 方法中加入:

loadMap()

Build & run。当你保存所完成的联系时,你会看到根据这次练习所画出来的地图!

注意:在控制台中,你会看到几个错误信息,比如:

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,并不是因为你的代码有什么错误。

添加颜色

这个 app 挺不错的,但如果能够用不同的颜色将线段根据不同的步速标记出来就更好了。

新建 Cocoa Touch Class 文件,命名为 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)
}

这里,你用红、黄、绿色值定义了几个常量。

然后检查指定速度在最慢到最快之间的分布来合成一个颜色。

修改 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. 一条折线由多条线段组成,每个线段由 2 个端点组成。准备构成每条线段端点坐标对的数组,以及这段线段的步速数组。
  2. 将每个端点转换成 CLLocation 对象,然后以两两配对的形式进行保存。
  3. 计算这段线段的步速。注意 Core Location 偶尔会在同一个时间戳返回不止一个位置刷新信息,因此需要做一个保护,防止 0 除错误。保存步速,及时更新最大速度和最小速度。
  4. 计算本次计步的平均速度。
  5. 用之前准备好的坐标对创建 multicolorPloyline 对象,设置其颜色。

在 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 对象,用该对象的 color 颜色来渲染线段。

Build & run。用模拟器跑出一小段距离,观察地图上的多彩线段。

一点改进

计步结束后的地图固然不错,但在跑步的过程中显示地图不是更好?

在故事板中用 UIStackView 添加一个地图是很容易的。

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

import MapKit

现在,打开 Main.storyboard,找到 New Run View Controller 场景。确保打开 Document Outline 窗口。如果没有打开,请点击下图中用红色圈住的按钮:

拖一个 UIView 到 Document Outline 的 Top Stack View 和 Button Stack View 之间。确保将它放在二者之间而不是某一个之内。双击它,重命名为 Map Container View。

在属性面板中,在 Drawing 下面勾选 Hidden。

在 Document Outline 窗口,右键,从 Map Container View 拖到 Top Stack View 并选择弹出菜单中的 Equal Widths。

拖一个 MKMapView 到 Map Container View。点击 Add New Constraints 按钮(你也可以叫它“钛战机按钮”),然后将 4 边约束都设置为 0。确保 Constrain to margins 为未选中。然后点击 Add 4 Constraints。

保持 Map View 的选中状态,打开 Size 面板(View\Utilities\Show Size Inspector)。双击约束 Bottom Space to: Superview。

设置 priority 为高(750)。

在 Document Outline 中,右键,从 Map View 拖到 New Run View Controller 然后选择 delegate。

打开助手编辑器,确保 NewRunViewController.swift 文件打开,然后右键,从 Map View 拖到源文件中,创建一个出口,命名为 mapView。右键,从 Map Container View 拖一个新出口名为 mapContainerView。

关闭助手编辑器,打开 NewRunViewController.swift 文件。

在 startRun 方法头部添加:

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

在 stopRun() 方法头部添加:

mapContainerView.isHidden = true

然后要实现 MKMapViewDelegate 以便为线段的绘制提供 renderer。在文件最后新增一个扩展:

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 中写的差不多,除了线段颜色是蓝色的以外。

最后,只需要添加覆盖物和设置地图 region 以便使地图居中显示你所跑过的区域。在 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)

Build & run,重新开始计步。你会发现新地图会实时进行刷新!

结尾

点击此处下载到此进度的项目。

你可能发现用户的步速始终是 min/mi,哪怕你本地化的距离单位是米(或km)。要显示本地化的距离,可以在调用 Formatdisplay.pace(distance:second:outputUnit:) 时选择 .minutesPerMile 或 .minutesPerKilometer。

在第二部分教程中,你将继续学习如何添加一个成就徽章系统。

期待你的评论和提问!:]

如何编写 Runkeeper 一样的 app(1)的更多相关文章

  1. ios – didUpdateLocations从未调用过

    我正在尝试获取用户的位置.为此,我在info.plist中设置了以下属性:我还在viewDidLoad方法中添加了以下代码以及下面的函数.问题是locationManager(manager,didUpdate…

  2. ios – 重命名并重写为Swift后对象解码崩溃

    由于我们已经重命名了(Bestemming–>Place)类并将其从Objective-c重写为Swift,因此一些用户会遇到崩溃.我们正在尝试使用NSCoding原则从NSUserDefaults加载对象.碰撞:班级:从NSUserDefaults阅读:崩溃日志说它在第0行崩溃,这是注释所以我认为它在init方法中崩溃,我认为它与一个null为空但不能为null的对象有关.我尝试过的:>尝试在S

  3. 适用于iOS的Google Maps SDK不断增加内存使用量

    我已经构建了一个在地图上显示标记的简单应用程序,我从服务器的JSON文件加载其x,y,标记是可点击的,所以一旦你在任何标记上它将你带到另一个UIViewController(我们将它命名为BViewController).我已经监视了内存使用情况,所以每次我从BViewController返回到MapViewController(里面的地图)时,它只是内存使用量的两倍我尝试将其设置为nill或从s

  4. ios – 未提示在应用程序中启用位置服务

    更新:这不是重复.我已经在info.plist中添加了所需的密钥,如我原始问题中所述,问题仍然存在.我已经尝试了各种组合的所有三个键.在任何人感到不安之前,我已阅读了许多AppleDev论坛帖子和堆栈溢出帖子,无法弄清楚为什么我的应用程序拒绝提示用户允许使用时授权.我已将以下密钥添加到我的Info.plist文件中,并附带一个String值:然后我写了(在Swift和Obj-C中)应该提示用户的代

  5. ios – 在UIViewController显示为3DTouch预览时检测UITouches

    是否有可能从UIViewController检测触摸并获取触摸的位置,UIViewController当前用作3DTouch的previewingContext视图控制器?

  6. ios – Google地图折线不完美呈现

    我正在使用最新的GoogleMapsAPIforiOS绘制折线.我正在逐点构造折线,但是当我缩小折线从地图中消失(不是字面上的术语)时,它不能正常渲染,当我放大时,它只会显示线条.这是放大时折线的显示方式这是缩小时的显示方式这里是我绘制折线的功能我有覆盖init:为RCpolyline是这样的东西和drawpolylineFromPoint:toPoint:这样做解决方法我发现这个故障,我正在制作

  7. ios – CLGeocoder错误. GEOErrorDomain代码= -3

    有没有关于apple的地理编码请求的文档?谢谢你提前.更新这是我的整个代码请求解决方法在搜索到答案后,它在Apples文档中!

  8. ios – Sprite Kit – 确定滑动精灵的滑动手势向量

    我有一个游戏,圆形物体从屏幕底部向上射击,我希望能够滑动它们以向我的滑动方向轻弹它们.我的问题是,我不知道如何计算滑动的矢量/方向,以便使圆形物体以适当的速度在正确的方向上被轻弹.我正在使用的静态矢量“(5,5)”需要通过滑动的滑动速度和方向来计算.此外,我需要确保一旦我第一次接触到对象,就不再发生这种情况,以避免双重击中对象.这是我目前正在做的事情:解决方法以下是如何检测滑动手势的示例:首先,定

  9. ios – 如何使用Swift使用Core Data更新/保存和保留非标准(可转换)属性?

    我已经构建了一个非常基本的示例来演示我尝试更新可转换类型并在应用程序重新启动之间保持更改的问题.我有一个Destination类型的实体……解决方法核心数据无法跟踪该对象的脏状态,因为它不了解其内部.而不是改变对象,创建一个副本,改变它,然后设置新对象.它可能会变异,然后重新设置相同的对象,不确定,没有测试它.您可以检查,只是改变地址,然后询问托管对象是否有更改,如果没有则则不会保存.

  10. iOS检测模拟位置

    我假设一个封闭的目标,攻击者必须是开发人员才能使这个漏洞利用起来,但是唉,它仍然存在解决方法问题:有没有办法检测到这种行为并阻止它?实际上有两个独立的问题:如何检测,以及如何预防?

随机推荐

  1. Swift UITextField,UITextView,UISegmentedControl,UISwitch

    下面我们通过一个demo来简单的实现下这些控件的功能.首先,我们拖将这几个控件拖到storyboard,并关联上相应的属性和动作.如图:关联上属性和动作后,看看实现的代码:

  2. swift UISlider,UIStepper

    我们用两个label来显示slider和stepper的值.再用张图片来显示改变stepper值的效果.首先,这三个控件需要全局变量声明如下然后,我们对所有的控件做个简单的布局:最后,当slider的值改变时,我们用一个label来显示值的变化,同样,用另一个label来显示stepper值的变化,并改变图片的大小:实现效果如下:

  3. preferredFontForTextStyle字体设置之更改

    即:

  4. Swift没有异常处理,遇到功能性错误怎么办?

    本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容,请发送邮件至dio@foxmail.com举报,一经查实,本站将立刻删除。

  5. 字典实战和UIKit初探

    ios中数组和字典的应用Applicationschedule类别子项类别名称优先级数据包contactsentertainment接触UIKit学习用Swift调用CocoaTouchimportUIKitletcolors=[]varbackView=UIView(frame:CGRectMake(0.0,0.0,320.0,CGFloat(colors.count*50)))backView

  6. swift语言IOS8开发战记21 Core Data2

    上一话中我们简单地介绍了一些coredata的基本知识,这一话我们通过编程来实现coredata的使用。还记得我们在coredata中定义的那个Model么,上面这段代码会加载这个Model。定义完方法之后,我们对coredata的准备都已经完成了。最后强调一点,coredata并不是数据库,它只是一个框架,协助我们进行数据库操作,它并不关心我们把数据存到哪里。

  7. swift语言IOS8开发战记22 Core Data3

    上一话我们定义了与coredata有关的变量和方法,做足了准备工作,这一话我们来试试能不能成功。首先打开上一话中生成的Info类,在其中引用头文件的地方添加一个@objc,不然后面会报错,我也不知道为什么。

  8. swift实战小程序1天气预报

    在有一定swift基础的情况下,让我们来做一些小程序练练手,今天来试试做一个简单地天气预报。然后在btnpressed方法中依旧增加loadWeather方法.在loadWeather方法中加上信息的显示语句:运行一下看看效果,如图:虽然显示出来了,但是我们的text是可编辑状态的,在storyboard中勾选Editable,再次运行:大功告成,而且现在每次单击按钮,就会重新请求天气情况,大家也来试试吧。

  9. 【iOS学习01】swift ? and !  的学习

    如果不初始化就会报错。

  10. swift语言IOS8开发战记23 Core Data4

    接着我们需要把我们的Rest类变成一个被coredata管理的类,点开Rest类,作如下修改:关键字@NSManaged的作用是与实体中对应的属性通信,BinaryData对应的类型是NSData,CoreData没有布尔属性,只能用0和1来区分。进行如下操作,输入类名:建立好之后因为我们之前写的代码有些地方并不适用于coredata,所以编译器会报错,现在来一一解决。

返回
顶部