原文: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 类也有 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
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 是用于启动和停止 location 服务的对象。
- seconds 用于记录跑步的时间,单位秒。
- timer 用于每秒触发一次方法调用,并刷新 UI。
- distance 用于保存跑步的累计长度。
- 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 个步骤:
新增如下方法:
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
添加颜色
这个 app 挺不错的,但如果能够用不同的颜色将线段根据不同的步速标记出来就更好了。
新建 Cocoa Touch Class 文件,命名为 MulticolorPolyline,继承 MKPolyline 类。
打开 MulticolorPolyline.swift ,导入 MapKit:
import MapKit
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)
}
这里,你用红、黄、绿色值定义了几个常量。
然后检查指定速度在最慢到最快之间的分布来合成一个颜色。
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
}
- 一条折线由多条线段组成,每个线段由 2 个端点组成。准备构成每条线段端点坐标对的数组,以及这段线段的步速数组。
- 将每个端点转换成 CLLocation 对象,然后以两两配对的形式进行保存。
- 计算这段线段的步速。注意 Core Location 偶尔会在同一个时间戳返回不止一个位置刷新信息,因此需要做一个保护,防止 0 除错误。保存步速,及时更新最大速度和最小速度。
- 计算本次计步的平均速度。
- 用之前准备好的坐标对创建 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 文件。
mapContainerView.isHidden = false
mapView.removeOverlays(mapView.overlays)
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。
期待你的评论和提问!:]