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

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

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

本教程的第二部分,将实现地图的颜色标记和奖牌。

第一部分中,你完成了:

  • 用 Core Location 记录行程。
  • 在地图上画出路线,报告平均步速。
  • 当练习结束,显示行程地图,通过颜色标记来反应步速。

现在的 app 能很好地记录和显示数据,但还需要在激励用户方面做更多工作。

在这一部分,你将实现一个奖牌系统,体现“锻炼是有趣的,是一个不断取得成就的过程”的概念,并完成这个 MoonRunner app。包括:

  • 一个关卡列表,列出提升的距离以激励用户。
  • 当用户练习时,app 显示一个即将达到的奖牌图标,以及还剩下多少距离即可获得。
  • 当用户第一次到达某个级别,app 会给予一个奖章并标记练习者的平均步速。这里,银质奖章和金质奖章用于奖励再次达到某个级别并在速度上进行了一定程度的提高。
  • 练习后地图上会在行程上到达每一级时显示一个点,并通过自定义气泡显示奖项名称和图片。

开始

如果你完成了第一部分内容,你可以从自己完成的项目开始。如果没有,从这里下载第二部分的开始项目。

不管你打开的是那个文件,你都会在项目的 asset catalog 中找到许多图片,以及一个 badges.txt。打开 badges.txt。你会发现它包含了一个由 badge 对象构成的 JSON 数组。每个 badge 对象包括:

  • 一个名字。
  • 这个奖项的有意义的描述。
  • 要取得这个奖牌必须达到的里程数(米)。
  • 对应的 asset catalog 中的图片名(imageName)。

每个奖项全部从 0 米开始——嘿,你必须从某处开始——一直到全程马拉松的距离。

第一个任务是将 JSON 字符串转换成 badge 数组。在项目中新增 Swift 文件 Badge.swift,并编写代码如下:

struct Badge {
  let name: String
  let imageName: String
  let information: String
  let distance: Double

  init?(from dictionary: [String: String]) {
    guard
      let name = dictionary["name"],
      let imageName = dictionary["imageName"],
      let information = dictionary["information"],
      let distanceString = dictionary["distance"],
      let distance = Double(distanceString)
    else {
      return nil
    }
    self.name = name
    self.imageName = imageName
    self.information = information
    self.distance = distance
  }
}

这里定义了一个 Badge 结构,实现了一个允许失败的初始化函数,从 JSON 对象中读取数据。

为这个结构添加一个属性,用于读取和解析 JSON:

static let allBadges: [Badge] = {
  guard let fileURL = Bundle.main.url(forResource: "badges", withExtension: "txt") else {
    fatalError("No badges.txt file found")
  }
  do {
    let jsonData = try Data(contentsOf: fileURL, options: .mappedIfSafe)
    let jsonResult = try JSONSerialization.jsonObject(with: jsonData) as! [[String: String]]
    return jsonResult.flatMap(Badge.init)
  } catch {
    fatalError("Cannot decode badges.txt")
  }
}()

用基本的 JSON 反序列化方法从文件中读取数据并用 flatMap 方法将初始化失败的结构体舍弃掉。allBadges 被声明为 static,因此这个昂贵的解析动作只会被执行一次。

为了能够对 Badge 对象进行比较,在文件中添加一个扩展:

extension Badge: Equatable {
  static func ==(lhs: Badge, rhs: Badge) -> Bool {
    return lhs.name == rhs.name
  }
}

赢取奖牌

创建好 Badge 结构,你需要用一个结构来保存用户获得的奖牌。这个结构将一个 Badge 对象和许多 Run 对象(如果有的话)关联,也就是用户是在哪次练习中获得了那个奖项的奖牌类型。

在项目中新建 BadgeStatus.swift 文件,在其中编写如下代码:

struct BadgeStatus {
  let badge: Badge
  let earned: Run?
  let silver: Run?
  let gold: Run?
  let best: Run?

  static let silverMultiplier = 1.05
  static let goldMultiplier = 1.1
}

定义了一个 BadgeStatus 结构,以及两个倍率,用于用户提升到银质和金质奖牌需要将成绩提高到百分之几。然后在结构中新增方法:

static func badgesEarned(runs: [Run]) -> [BadgeStatus] {
  return Badge.allBadges.map { badge in
    var earned: Run?
    var silver: Run?
    var gold: Run?
    var best: Run?

    for run in runs where run.distance > badge.distance {
      if earned == nil {
        earned = run
      }

      let earnedSpeed = earned!.distance / Double(earned!.duration)
      let runSpeed = run.distance / Double(run.duration)

      if silver == nil && runSpeed > earnedSpeed * silverMultiplier {
        silver = run
      }

      if gold == nil && runSpeed > earnedSpeed * goldMultiplier {
        gold = run
      }

      if let existingBest = best {
        let bestSpeed = existingBest.distance / Double(existingBest.duration)
        if runSpeed > bestSpeed {
          best = run
        }
      } else {
        best = run
      }
    }

    return BadgeStatus(badge: badge, earned: earned, silver: silver, gold: gold, best: best)
  }
}

这个方法将用户每次的练习和每个奖项的距离进行比较,建立关系,返回一个 BadgeStatus 数组,BadgeStatus 中包含了每个奖牌需要达到的值。

当用户第一次获得某个项目的记录时,这次练习的速度将做为后续练习的参考,并决定后续练习是否足以提升至获得银牌或金牌。

最后,这个方法记录了用户在每个级别的奖牌上所获得的最快的记录。

显示奖牌

写完获得奖牌的逻辑,就需要将奖牌显示给用户了。开始项目中已经定义了必要的 UI。你将用 UITableViewController 来显示奖牌列表。首先需要定义一个自定义 Table View Cell 用于显示奖牌。

新建一个 BadgeCell.swift 文件。修改其内容为:

import UIKit

class BadgeCell: UITableViewCell {

  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!

  var status: BadgeStatus! {
    didSet {
      configure()
    }
  }
}

这些出口需要在显示奖牌时用到。status 变量用于充当 cell 的模型。

接着实现 configure() 方法:

private let redLabel = #colorLiteral(red: 1, green: 0.07843137255, blue: 0.1725490196, alpha: 1)
private let greenLabel = #colorLiteral(red: 0, green: 0.5725490196, blue: 0.3058823529, alpha: 1)
private let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)

private func configure() {
  silverImageView.isHidden = status.silver == nil
  goldImageView.isHidden = status.gold == nil
  if let earned = status.earned {
    nameLabel.text = status.badge.name
    nameLabel.textColor = greenLabel
    let dateEarned = FormatDisplay.date(earned.timestamp)
    earnedLabel.text = "Earned: \(dateEarned)"
    earnedLabel.textColor = greenLabel
    badgeImageView.image = UIImage(named: status.badge.imageName)
    silverImageView.transform = badgeRotation
    goldImageView.transform = badgeRotation
    isUserInteractionEnabled = true
    accessoryType = .disclosureIndicator
  } else {
    nameLabel.text = "?????"
    nameLabel.textColor = redLabel
    let formattedDistance = FormatDisplay.distance(status.badge.distance)
    earnedLabel.text = "Run \(formattedDistance) to earn"
    earnedLabel.textColor = redLabel
    badgeImageView.image = nil
    isUserInteractionEnabled = false
    accessoryType = .none
    selectionStyle = .none
  }
}

方法很简单,基于赋值后的 BadgeStatus 来设置 table view cell。

如果你的代码是复制粘贴上去的,你可能注意到 Xcode 会将 #colorLiterals 替换成调色板。如果你是手写的代码,先敲入 Color Literals,选择 Xcode 自动完成选项,然后双击出现的调色板。

这会显示一个简单的颜色拾取器,点击 Other… 按钮。

这会打开系统颜色拾取器,要和示例项目中的颜色值保持一致,请在 Hex Color # 字段中输入 FF142C 用于 redLabel,输入 00924E 用于 greenLabel。

打开 Main.storyboard ,在 Badges Table View Controller 场景,分别连接 BadgeCell 的如下出口:

  • badgeImageView
  • silverImageView
  • goldImageView
  • nameLabel
  • earnedLabel

Table view cell 完成后,再来创建 table view controller。添加新的 Swift 文件 BadgesTableViewController.swift。导入 UIKit 和 CoreData:

import UIKit
import CoreData

然后是类的定义:

class BadgesTableViewController: UITableViewController {

  var statusList: [BadgeStatus]!

  override func viewDidLoad() {
    super.viewDidLoad()
    statusList = BadgeStatus.badgesEarned(runs: getRuns())
  }

  private func getRuns() -> [Run] {
    let fetchRequest: NSFetchRequest<Run> = Run.fetchRequest()
    let sortDescriptor = NSSortDescriptor(key: #keyPath(Run.timestamp), ascending: true)
    fetchRequest.sortDescriptors = [sortDescriptor]
    do {
      return try CoreDataStack.context.fetch(fetchRequest)
    } catch {
      return []
    }
  }
}

当视图一加载,你向 Core Data 查询所有已完成的联系数据,对日期进行排序,然后构建获得的奖牌列表。

然后在扩展中添加 table view 的数据源方法:

extension BadgesTableViewController {
  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return statusList.count
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell: BadgeCell = tableView.dequeueReusableCell(for: indexPath)
    cell.status = statusList[indexPath.row]
    return cell
  }
}

这是标准的 UITableViewDataSource 方法,返回表格行数并配置表格 cell。和第一部分中一样,你在 StoryboardSupport.swift 中通过定义泛型方法来 dequeue 表格 cell,从而避免了“string 类型转换”的问题。

Build & run,查看你的新奖牌!你会看到:

怎样才能获得一块金牌

最后一个 View controller 是显示奖牌的详情。新建 Swift 文件 BadgeDetailsViewController.swift。编辑其内容为:

import UIKit

class BadgeDetailsViewController: UIViewController {

  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var distanceLabel: UILabel!
  @IBOutlet weak var earnedLabel: UILabel!
  @IBOutlet weak var bestLabel: UILabel!
  @IBOutlet weak var silverLabel: UILabel!
  @IBOutlet weak var goldLabel: UILabel!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!

  var status: BadgeStatus!
}

这里声明了所有需要连接 UI 所用到的出口,BadgeStatus 是模型。

然后,在 viewDidLoad() 方法中:

override func viewDidLoad() {
  super.viewDidLoad()
  let badgeRotation = CGAffineTransform(rotationAngle: .pi / 8)

  badgeImageView.image = UIImage(named: status.badge.imageName)
  nameLabel.text = status.badge.name
  distanceLabel.text = FormatDisplay.distance(status.badge.distance)
  let earnedDate = FormatDisplay.date(status.earned?.timestamp)
  earnedLabel.text = "Reached on \(earnedDate)"

  let bestDistance = Measurement(value: status.best!.distance, unit: UnitLength.meters)
  let bestPace = FormatDisplay.pace(distance: bestDistance, 
                                    seconds: Int(status.best!.duration), 
                                    outputUnit: UnitSpeed.minutesPerMile)
  let bestDate = FormatDisplay.date(status.earned?.timestamp)
  bestLabel.text = "Best: \(bestPace), \(bestDate)"

  let earnedDistance = Measurement(value: status.earned!.distance, unit: UnitLength.meters)
  let earnedDuration = Int(status.earned!.duration)
}

将 BadgeStatus 信息中的数据显示到 label 中。然后,需要显示银牌和金牌。

在 viewDidLoad() 方法中继续编写代码:

if let silver = status.silver {
  silverImageView.transform = badgeRotation
  silverImageView.alpha = 1
  let silverDate = FormatDisplay.date(silver.timestamp)
  silverLabel.text = "Earned on \(silverDate)"
} else {
  silverImageView.alpha = 0
  let silverDistance = earnedDistance * BadgeStatus.silverMultiplier
  let pace = FormatDisplay.pace(distance: silverDistance, 
                                seconds: earnedDuration, 
                                outputUnit: UnitSpeed.minutesPerMile)
  silverLabel.text = "Pace < \(pace) for silver!"
}

if let gold = status.gold {
  goldImageView.transform = badgeRotation
  goldImageView.alpha = 1
  let goldDate = FormatDisplay.date(gold.timestamp)
  goldLabel.text = "Earned on \(goldDate)"
} else {
  goldImageView.alpha = 0
  let goldDistance = earnedDistance * BadgeStatus.goldMultiplier
  let pace = FormatDisplay.pace(distance: goldDistance, 
                                seconds: earnedDuration, 
                                outputUnit: UnitSpeed.minutesPerMile)
  goldLabel.text = "Pace < \(pace) for gold!"
}

金牌和银牌的图片当 alpha 被设置为 0 时隐藏。这种方法能很好地在嵌套的 stack view 和自动布局下工作。

最后,添加下列方法:

@IBAction func infoButtonTapped() {
  let alert = UIAlertController(title: status.badge.name,
                                message: status.badge.information,
                                preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel))
  present(alert, animated: true)
}

当 Info 按钮被点击,弹出一个关于该奖牌的信息的描述。

打开 Main.storyboard。连接好 BadgeDetailsViewController 的如下出口:

  • badgeImageView
  • nameLabel
  • distanceLabel
  • earnedLabel
  • bestLabel
  • silverLabel
  • goldLabel
  • silverImageLabel
  • goldImageLabel

将 infoButtonTapped() 连接到 info 按钮。最后,在 Badges Table View Controller 场景中选中 table view 对象。

在属性面板中勾选 User Interaction Enabled checkbox:

打开 BadgesTableViewController.swift 添加一个扩展:

extension BadgesTableViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "BadgeDetailsViewController"
  }

  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! BadgeDetailsViewController
      let indexPath = tableView.indexPathForSelectedRow!
      destination.status = statusList[indexPath.row]
    }
  }

  override func shouldPerformSegue(withIdentifier identifier: String, sender: Any?) -> Bool {
    guard let segue = SegueIdentifier(rawValue: identifier) else { return false }
    switch segue {
    case .details:
      guard let cell = sender as? UITableViewCell else { return false }
      return cell.accessoryType == .disclosureIndicator
    }
  }
}

当用户点击 table 上的奖牌时,将一个 BadgeStatus 对象传递给 BadgeDetailsViewController。

iOS 11 请注意:iOS 11 beta 目前会在 cell 被配置之后但被显示之前将 cell 的 isUserInteractionEnabled 属性设置为 true。因此,你必须实现shouldPerformSegue(withIdentifier:sender:) 方法,以免在未获得的奖牌上访问奖牌详情。如果 iOS 11 之后此 Bug 已经被修复,请删除这个方法。

Build & run,查看奖牌详情!

胡萝卜激励理论

奖牌系统很漂亮,但还需要修改 app 中已有的 UI 以便集成它。在此之前,你需要些几个工具方法,用于判断当前最新赢得的奖牌以及下一个可以获得的奖牌。

打开 Badge.swift 新增方法:

static func best(for distance: Double) -> Badge {
  return allBadges.filter { $0.distance < distance }.last ?? allBadges.first!
}

static func next(for distance: Double) -> Badge {
  return allBadges.filter { distance < $0.distance }.first ?? allBadges.last!
}

这两个方法用于根据用户是否获得奖牌,以及获得的最高奖牌来过滤奖牌列表。

然后,打开 Main.storyboard。找到 New Run View Controller 场景中的 Button Stack View。拖一个 UIImageView 和一个 UILabel 到 Document Outline 中。

确认它们位于 Button Stack View 的最上面。

选择这两个对象,点击 Editor\Embed In\Stack View。然后将新的 Stack View 的属性设置为:

  • Axis: Horizontal
  • Distribution: Fill Equally
  • Spacing: 10
  • Hidden: checked

将 Image view 的 Content Mode 设置为 Aspect Fit。

将 Label 的属性设置为:

  • Color: White Color
  • Font: System 14.0
  • Lines: 0
  • Line Break: Word Wrap
  • Autoshrink: Minimum Font Size
  • Tighten Letter Spacing: checked

用助手编辑器将新 stack view、image view 和 label 连接到出口:

@IBOutlet weak var badgeStackView: UIStackView!
@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoLabel: UILabel!

Xcode 9 请注意: 如果你看到新的 view 上的垂直位置有冲突提示,不用担心。你的 Xcode 版本无法正确计算隐藏对象的子对象的布局。要让警告消失,取消 Badge Stack View 的 Hidden 属性的勾选状态。然后在 NewRunViewController.swift 的 viewDidLoad() 方法增加这句:

badgeStackView.isHidden = true // required to work around behavior change in Xcode 9 beta 1

但愿这个问题在 Xcode 9 正式发布时得到解决。

打开 NewRunViewController.swift ,导入 AVFoundation 框架:

import AVFoundation

然后添加属性声明:

private var upcomingBadge: Badge!
private let successSound: AVAudioPlayer = {
  guard let successSound = NSDataAsset(name: "success") else {
    return AVAudioPlayer()
  }
  return try! AVAudioPlayer(data: successSound.data)
}()

successSound 用于创建一个播放“成功”音效的 audio player,每当获得一个新奖牌时播放。

在 updateDisplay() 方法中添加:

let distanceRemaining = upcomingBadge.distance - distance.value
let formattedDistanceRemaining = FormatDisplay.distance(distanceRemaining)
badgeInfoLabel.text = "\(formattedDistanceRemaining) until \(upcomingBadge.name)"

这会导致用户下一个奖牌实时更新。

在 startRun() 方法中,调用 updateDisplay() 之前,添加:

badgeStackView.isHidden = false
upcomingBadge = Badge.next(for: 0)
badgeImageView.image = UIImage(named: upcomingBadge.imageName)

这将显示第一个可以获得的奖牌。

在 stopRun() 方法中加入:

badgeStackView.isHidden = true

和别的视图一样,在两次练习之间所有的奖牌信息都不可见。

添加新方法:

private func checkNextBadge() {
  let nextBadge = Badge.next(for: distance.value)
  if upcomingBadge != nextBadge {
    badgeImageView.image = UIImage(named: nextBadge.imageName)
    upcomingBadge = nextBadge
    successSound.play()
    AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
  }
}

这个方法检测是否获得了新奖牌,刷新 UI,显示下一块奖牌,播放成功音效以示庆祝。

在 eachSecond() 方法中,在调用 updateDisplay() 方法之前调用 checkNextBadge() 方法:

checkNextBadge()

Build & run,模拟跑一小段距离,然后查看 label 的更新。当你获得一块奖牌是会听到声音!

注意:在控制台中,当成功音效播放时,你会看到类似如下的错误信息:

[aqme] 254: AQDefaultDevice (188): skipping input stream 0 0 0x0

在模拟器中,这是正常的。这个信息来自于 AVFoundation,并非是你代码中的错误。
如果你快速获得奖牌,你可以在模拟器菜单 Debug\Location 菜单来切换坐标。别担心,我们不会告诉其他人的!

“空间模式”

当一次跑步完成,最好能让用户看看他们获得的最后一个奖牌。

打开 Main.stroyboard,找到 Run Detail View Controller 场景。拖一个 UIImageView 到 Map View 的上面。右键,从 Image View 拖到 Map View。在弹出菜单中,按住 shift 键,接连选择 Top、Bottom、Leading 和 Trailing。点击 Add Constrains,将 Image View 的四边和 Map View 对齐。

Xcode 会添加约束,每边的值都是 0,和我们想要的是一样的。但是,Image View 不可能完全遮住地图,你会看到橙色的线条。点击 Update Frame 按钮(下图红框处),重新设置 Image View 的大小。

拖一个 UIButton 到 Image View 上。删除按钮的 Title,设置 Image 为 info。

右键,从按钮拖到 Image View。在弹出菜单中,按住 Shift 键,同时选择 Bottom 和 Trailing。点击 Add Constraints,将按钮的底边和右边对齐 Image View。

在 Size 面板中,编辑两条约束,将 value 设置为 -8。

点击 Update Frames 按钮,重设按钮的大小位置。

点中 Image View,将 Content Mode 设置为 Aspect Fit,Alpha 值设置为 0。

选中按钮,将 Alpha 设为 0。

注意:你通过设置 Alpha 值而不是 Hidden 属性来隐藏 view。因为你准备对这些 view 使用动画以提供平滑的用户体验。

拖一个 UISwitch 和一个 UILabel 到视图的右下角。

选中 Switch,点击 Add New Constraints 按钮(“钛战机”按钮)。添加右、下、左边距都为 8。确保左边距是相对于 Label 的。然后点击 Add 3 Constraints。

将 Switch 的 value 设为 off。

右键,从 Switch 拖到 Label。在弹出菜单中,选择 Center Vertically。

选中 Label,设置 Title 为 SPACE MODE,颜色设置为白色。

在 Document Outline 窗口中,右键从 Switch 拖到 Stack View。从弹出菜单中选择 Vertical Spacing。

在 Switch 的 Size 面板中,编辑 Top Space to:Stack View 的约束。将 raltion 设置为 ≥,value 设为 8。

啊!应该为整个布局工作颁一大奖!:]

用助手编辑器打开 RunDetailsViewController.swift,将 Image View 和 Info 按钮连接到以下出口:

@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoButton: UIButton!

为 Switch 添加一个 Action 方法并连接到它:

@IBAction func displayModeToggled(_ sender: UISwitch) {
  UIView.animate(withDuration: 0.2) {
    self.badgeImageView.alpha = sender.isOn ? 1 : 0
    self.badgeInfoButton.alpha = sender.isOn ? 1 : 0
    self.mapView.alpha = sender.isOn ? 0 : 1
  }
}

当 switch 的值发生改变,以动画方式修改 Image View、Info 按钮和 Map View 的 alpha 值。

现在,为 Info 按钮添加 action 方法并连接它:

@IBAction func infoButtonTapped() {
  let badge = Badge.best(for: run.distance)
  let alert = UIAlertController(title: badge.name,
                                message: badge.information,
                                preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel))
  present(alert, animated: true)
}

这和你在 BadgeDetailsViewController.swift 中实现的处理是一模一样的。

最后一步是在 configureView() 方法最后添加:

let badge = Badge.best(for: run.distance)
badgeImageView.image = UIImage(named: badge.imageName)

从用户所获得的奖牌中获得最后一块奖牌,然后显示。

Build & run。模拟器一次跑步练习,保存这次练习,看一下你的“空间模式”!

显示你的太阳系

每次练习后的地图已经帮你保存了你的行程,并将你跑得比较慢的地方标记出来。现在你将添加一个新功能,精确显示每个奖牌是在哪个地方获得的。

MapKit 使用标注来显示点数据之类的信息。要创建标注,你需要:

  • 一个实现了 MKAnnotation 的类,用于提供一个坐标,表示标注所在的位置。
  • 一个 MKAnnotationView 子类,用于显示给标注关联的信息。

因此接下来你应该:

  • 创建 BadgeAnnotation 类,实现 MKAnnotation。
  • 创建一个 BadgeAnnotation 对象数据,然后将之添加到地图上。
  • 实现 mapView(_:viewFor:) 方法,创建 MKAnnotationViews。

新建一个 Swift 文件 BadgeAnnotation.swift。编辑内容为:

import MapKit

class BadgeAnnotation: MKPointAnnotation {
  let imageName: String

  init(imageName: String) {
    self.imageName = imageName
    super.init()
  }
}

MKPointAnnotation 继承 MKPointAnnotation ,这样你只需要传入一个图片名给渲染引擎。

打开 RunDetailsViewController.swift 添加方法:

private func annotations() -> [BadgeAnnotation] {
  var annotations: [BadgeAnnotation] = []
  let badgesEarned = Badge.allBadges.filter { $0.distance < run.distance }
  var badgeIterator = badgesEarned.makeIterator()
  var nextBadge = badgeIterator.next()
  let locations = run.locations?.array as! [Location]
  var distance = 0.0

  for (first, second) in zip(locations, locations.dropFirst()) {
    guard let badge = nextBadge else { break }
    let start = CLLocation(latitude: first.latitude, longitude: first.longitude)
    let end = CLLocation(latitude: second.latitude, longitude: second.longitude)
    distance += end.distance(from: start)
    if distance >= badge.distance {
      let badgeAnnotation = BadgeAnnotation(imageName: badge.imageName)
      badgeAnnotation.coordinate = end.coordinate
      badgeAnnotation.title = badge.name
      badgeAnnotation.subtitle = FormatDisplay.distance(badge.distance)
      annotations.append(badgeAnnotation)
      nextBadge = badgeIterator.next()
    }
  }

  return annotations
}

这里创建了一个 BadgeAnnotation 对象数组,每个对象对应了在这次跑步中获得的一个奖牌。

在 loadMap() 方法最后添加:

mapView.addAnnotations(annotations())

这会将标注添加到地图上。

然后,在 MKMapViewDelegate 扩展中添加方法:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
  guard let annotation = annotation as? BadgeAnnotation else { return nil }
  let reuseID = "checkpoint"
  var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseID)
  if annotationView == nil {
    annotationView = MKAnnotationView(annotation: annotation, reuseIdentifier: reuseID)
    annotationView?.image = #imageLiteral(resourceName: "mapPin")
    annotationView?.canShowCallout = true
  }
  annotationView?.annotation = annotation

  let badgeImageView = UIImageView(frame: CGRect(x: 0, y: 0, width: 50, height: 50))
  badgeImageView.image = UIImage(named: annotation.imageName)
  badgeImageView.contentMode = .scaleAspectFit
  annotationView?.leftCalloutAccessoryView = badgeImageView

  return annotationView
}

这个方法中,你为每个标注创建了一个 MKAnnotationView,配置它的属性让它显示奖牌的图片。

Build & run。模拟一次跑步练习,保存跑步。地图将通过标注上显示你获得的奖牌。点击标注,可以查看奖项名称、图片和距离。

结束

你可以从这里下载最终完成后的项目。

通过这两部分教程的学习,你构建了一个这样的 app:

  • 用 Core Location 为跑步练习计步。
  • 显示实时数据,比如平均步速,以及一个实时刷新的地图。
  • 将跑步行程用不同颜色的线段进行标记,并在每个破记录的地方显示自定义标注。
  • 根据每个人的距离和速度的进步来赢取奖章。

你依然可以自己完成以下内容:

  • 添加一个用于显示用户场景跑过的练习的列表。NSFetchedResultsController 和现成的 RunDetailsViewController 为你节省了工作量!
  • 计算破纪录点之间的平均步速并将它显示在 MKAnnotationView 的气泡上。

敬谢赏阅。期待你的评论和提问!:]

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值