如何编写 Runkeeper 一样的 app(2)
本教程的第二部分,将实现地图的颜色标记和奖牌。 在第一部分中,你完成了:
现在的 app 能很好地记录和显示数据,但还需要在激励用户方面做更多工作。 在这一部分,你将实现一个奖牌系统,体现“锻炼是有趣的,是一个不断取得成就的过程”的概念,并完成这个 MoonRunner app。包括:
开始如果你完成了第一部分内容,你可以从自己完成的项目开始。如果没有,从这里下载第二部分的开始项目。 不管你打开的是那个文件,你都会在项目的 asset catalog 中找到许多图片,以及一个 badges.txt。打开 badges.txt。你会发现它包含了一个由 badge 对象构成的 JSON 数组。每个 badge 对象包括:
每个奖项全部从 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 的如下出口:
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,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 的如下出口:
将 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。
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 的最上面。 选择这两个对象,点击 EditorEmbed InStack View。然后将新的 Stack View 的属性设置为:
将 Image view 的 Content Mode 设置为 Aspect Fit。 将 Label 的属性设置为:
用助手编辑器将新 stack view、image view 和 label 连接到出口: @IBOutlet weak var badgeStackView: UIStackView!
@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoLabel: UILabel!
打开 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 的更新。当你获得一块奖牌是会听到声音!
“空间模式”当一次跑步完成,最好能让用户看看他们获得的最后一个奖牌。 打开 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。
拖一个 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,animated: true)
}
这和你在 BadgeDetailsViewController.swift 中实现的处理是一模一样的。 最后一步是在 configureView() 方法最后添加: let badge = Badge.best(for: run.distance)
badgeImageView.image = UIImage(named: badge.imageName)
从用户所获得的奖牌中获得最后一块奖牌,然后显示。 Build & run。模拟器一次跑步练习,保存这次练习,看一下你的“空间模式”! 显示你的太阳系每次练习后的地图已经帮你保存了你的行程,并将你跑得比较慢的地方标记出来。现在你将添加一个新功能,精确显示每个奖牌是在哪个地方获得的。 MapKit 使用标注来显示点数据之类的信息。要创建标注,你需要:
因此接下来你应该:
新建一个 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:
你依然可以自己完成以下内容:
敬谢赏阅。期待你的评论和提问!:] (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |