翻译自: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— 徽章系统基于太阳系中的行星和卫星!
开始本教程之前, 你应该熟悉Storyboards和Core Data. 如果您绝得需要复习下知识,请查阅链接教程.
本教程同时也使用了iOS10中新增加的Measurement和MeasurementFormatter功能. 更多了解请观看视频.
鉴于内容众多,本教程将分为两部分. 第一部分重点讲解 记录跑步数据和地图路线展示. 第二部分介绍了徽章系统.
下载项目模板. 其中包括要完成本教程的所有文件和资源.
花费几分钟熟悉下项目.Main.storyboard已经包含了 所有UI界面. 将AppDelegate中关于Core Data的模板代码移到CoreDataStack.Swift中.Assets.xcassets中包含了将要使用的图片和声音文件.
MoonRunner 使用 Core Data 相对简单, 仅仅使用了两个实体:Run和Location.
打开MoonRunner.xcdatamodeld同时创建两个实体:Run和Location. 在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 View和Button Stack View之间. 确保其实在他们的之间而不是在任何一个之中. 双击它并将其命名为MapContainerView.
在 Attributes Inspector中, 选中Drawing下的Hidden.
在大纲视图中, Control+拖拽 从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 Inspector(View\Utilities\Show Size Inspector). 在 constraint区域双击Bottom Space to: Superview.
改变优先次序为High (750).
在大纲视图, Control+拖拽 从Map View到New 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 的跑步应用之引入徽章成就系统.
一如既往, 期待您的意见和问题! :]