如何编写 Runkeeper 一样的 app(1)

前端之家收集整理的这篇文章主要介绍了如何编写 Runkeeper 一样的 app(1)前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。

原文: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——用太阳系中的行星和月亮来作为徽章。

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

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

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

开始

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

来看一下项目结构。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。

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

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

原文链接:https://www.f2er.com/swift/321278.html

猜你在找的Swift相关文章