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。

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

使用  Core Location 追踪路线.

跑步过程中显示一个地图并不断的更新位置.

当您跑步时记录下您的平均速度.

不同距离授予不同的徽章. 无论你的跑步起始点在哪里,每个徽章都由银色和金色两种组成,用于表示个人进度.

通过跟踪到下一级徽章的剩余距离来激励你.

当跑步结束后显示一个路线地图. 不同颜色的线段表示不同的速度.

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

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

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

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

开始

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

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

模型: Runs 和 Locations

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

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

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

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

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

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

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

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

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

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

完成基本的应用流程

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

[plain]view plaincopy

var run: Run!

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

[plain]view plaincopy

private func configureView() {

}

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

[plain]view plaincopy

configureView()

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

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

[plain]view plaincopy

private var run: Run?

接着, 添加如下新方法:

[plain]view plaincopy

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()的调用.

[plain]view plaincopy

startRun()

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

[plain]view plaincopy

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()中添加如下代码:

[plain]view plaincopy

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界面已经变为了 “跑步模式”:

按下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 latitudeintrigger specification

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

单位 和 格式化

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

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

[plain]view plaincopy

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:).

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

[plain]view plaincopy

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的默认单位为 “米/秒”. 你的扩展中可以让速度 按照分/千米分/米来表示.

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

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

[plain]view plaincopy

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) -> 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, 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中只能有一个CLLocationManager实例,它不能被无意中删除.

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

[plain]view plaincopy

import CoreLocation

class LocationManager {

static let shared = CLLocationManager()

private init() { }

}

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

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

选择Capabilities栏开启Background Modes. 选中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.plistkey 是非常重要的. 如果没有它, 你的用户将不会为你的app来授权访问位置服务.

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

[plain]view plaincopy

let locationManager = LocationManager.shared

locationManager.requestWhenInUseAuthorization()

打开NewRunViewController.swift并且导入CoreLocation:

[plain]view plaincopy

import CoreLocation

接着, 在 run属性后添加如下属性:

[plain]view plaincopy

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()之后添加以下方法:

[plain]view plaincopy

override func viewWillDisappear(_ animated: Bool) {

super.viewWillDisappear(animated)

timer?.invalidate()

locationManager.stopUpdatingLocation()

}

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

添加以下两个方法:

[plain]view plaincopy

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记录位置更新 . 在文件末尾添加扩展:

[plain]view plaincopy

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中添加如下方法(不是扩展中):

[plain]view plaincopy

private func startLocationUpdates() {

locationManager.delegate = self

locationManager.activityType = .fitness

locationManager.distanceFilter = 10

locationManager.startUpdatingLocation()

}

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

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

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

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

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

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

[plain]view plaincopy

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中添加如下方法:

[plain]view plaincopy

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()方法末尾添加如下代码:

[plain]view plaincopy

locationManager.stopUpdatingLocation()

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

[plain]view plaincopy

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.

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

地图展示

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

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

[plain]view plaincopy

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

}

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

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

设置地图显示区域,仅仅显示跑步的区域而不是整个世界地图.

提供 一个描述覆盖图层的代理方法.

创建一个MKOverlay用于描述画线.

添加如下方法:

[plain]view plaincopy

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表示地图显示区域. 通过提供中心点和垂直,水平范围即可确定地图显示区域.

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

[plain]view plaincopy

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中添加如下方法(不是扩展中):

[plain]view plaincopy

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类型

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

[plain]view plaincopy

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()方法结尾添加如下调用.

[plain]view plaincopy

loadMap()

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

注意:在控制台, 你将会看到类似以下的错误信息:

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:

[plain]view plaincopy

import MapKit

类中添加 color 属性:

[plain]view plaincopy

var color = UIColor.black

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

[plain]view plaincopy

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()中的代码替换为

[plain]view plaincopy

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

}

以下是新版本的内容:

polyline 由线段组成, 每段由端点标记. 收集用于描述每段的坐标对及每段的速度.

将端点转换成CLLocation对象并成对保存.

计算每段的速度. 注意, Core Location 偶尔会返回时间戳相同的多个更新,所以要防止除以0的错误问题. 保存速度并且更新最大和最小速度.

计算整个里程的平均速度.

使用之前计算好的坐标对生成新的MulticolorPolyline,并设置颜色.

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

[plain]view plaincopy

mapView.addOverlays(polyLine())

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

[plain]view plaincopy

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并且使用内含的颜色渲染线段.

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

如何实时导航?

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

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

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

[plain]view plaincopy

import MapKit

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

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

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

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

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

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

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

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

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

关闭Assistant Editor并打开NewRunViewController.swift.

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

[plain]view plaincopy

mapContainerView.isHidden = false

mapView.removeOverlays(mapView.overlays)

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

[plain]view plaincopy

mapContainerView.isHidden = true

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

[plain]view plaincopy

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)之下添加代码:

[plain]view plaincopy

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)

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

下一步


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

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

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

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

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

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

推荐阅读更多精彩内容