怎么样创建一个像RunKeeper一样的app(二)swift版

怎么样创建一个像RunKeeper一样的APP(二)swift版

</br>
本博将不定期更新外网的iOS最新教程

简书: @西木

微博: @角落里的monster

本文翻译自raywenderlich,版权归原作者所有,转载请注明出处

原文地址为 http://www.raywenderlich.com/97945/make-app-like-runkeeper-swift-part-2

</br>
这是第二部分,也是这篇教程的最后一部分,我们将会完成badge的部分

在第一部分中,我们完成了

  • 使用Core Location记录轨迹

  • 持续更新你的轨迹并且显示平均速度等

  • 跑步完成时显示跑步区域的地图,轨迹曲线为彩色显示,速度慢得部分为红色,速度快的部分为绿色

这个App可以很好地显示和记录你的跑步数据,但是要看到你的跑步中明显的各种变化,就不光是一个地图可以表现得,还需要做一些调整

这一部分中,你将完成MoonRunner的奖励体系的设置,它能够体现出你在运动过程中的愉悦和成就感。它能够帮助你有积极性来使用App记录你运动的历程

准备好解锁你第二部分额运动成就了吗?开始吧

Getting Started

如果你还没有看过第一部分的教程,可以查看我之前的博文

在项目文件的配置中已经包含了一个JSON文件,你可以先查看一下JSON文件,如果你好奇的话

徽章系统会从0开始记录,首先你需要完成一个马拉松,当然,很多人可能会完成更远的距离,你也可以想想是什么样的力量可以支持他们完成这些

首先,我们要把JSON数据转换成一个数组,新建一个swift文件,命名为Badge

然后,用下面的部分替换原文件的内容

import Foundation

let silverMultiplier = 1.05 // 5% speed increase
let goldMultiplier = 1.10 // 10% speed increase

class Badge {
  let name: String?
  let imageName: String?
  let information: String?
  let distance: Double?

  init(json: [String: String]) {
    name = json["name"]
    information = json["information"]
    imageName = json["imageName"]
    distance = (json["distance"] as NSString?)?.doubleValue
  }
}

如果字典中并没有包含所有的key的话,可以来这个问价查找

我们需要解析JSON数据完善你的徽章系统,仍然是这个文件,创建一个类命名为 BadgeController 并加入一下代码

class BadgeController {
  static let sharedController = BadgeController()

  lazy var badges : [Badge] = {
    var _badges = [Badge]()

    let filePath = NSBundle.mainBundle().pathForResource("badges", ofType: "json") as String!
    let jsonData = NSData.dataWithContentsOfMappedFile(filePath) as! NSData

    var error: NSError?
    if let jsonBadges = NSJSONSerialization.JSONObjectWithData(jsonData, options: NSJSONReadingOptions.AllowFragments, error: &error) as? [Dictionary<String, String>] {
      for jsonBadge in jsonBadges {
        _badges.append(Badge(json: jsonBadge))
      }
    }
    else {
      println(error)
    }

    return _badges
    }()

这里,你声明了 BadgeController 为一个单例,而且对 badges 数组做了懒加载,当第一次被调用的时候,会通过Badges.json 来初始化

Earning The Badge

你已经创建了Badge, 那么现在你需要一个对象来存储你获得的徽章奖励

这个对象需要把你的Badge 对象和Run 对象联系起来,如果有的话,还需要恩能够存储这个徽章的级别

打开 Badge.swift 在尾部添加以下代码

class BadgeEarnStatus {
  let badge: Badge
  var earnRun: Run?
  var silverRun: Run?
  var goldRun: Run?
  var bestRun: Run?

  init(badge: Badge) {
    self.badge = badge
  }
}

现在你已经可以把 Badge 和 Run 联系起来了,那么我们就需要建立它们之间的逻辑关系

添加以下代码到 Badge.swift 中

let silverMultiplier = 1.05 // 5% speed increase
let goldMultiplier = 1.10 // 10% speed increase

silverMultiplier 和 goldMultiplier是根据速度的快慢来划分的,越多的加成会获得更高级别的奖励

然后,添加以下方法在 BadgeController 类中

func badgeEarnStatusesForRuns(runs: [Run]) -> [BadgeEarnStatus] {
    var badgeEarnStatuses = [BadgeEarnStatus]()

    for badge in badges {
      let badgeEarnStatus = BadgeEarnStatus(badge: badge)

      for run in runs {
        if run.distance.doubleValue > badge.distance {

          // This is when the badge was first earned
          if badgeEarnStatus.earnRun == nil {
            badgeEarnStatus.earnRun = run
          }

          let earnRunSpeed = badgeEarnStatus.earnRun!.distance.doubleValue / badgeEarnStatus.earnRun!.duration.doubleValue
          let runSpeed = run.distance.doubleValue / run.duration.doubleValue

          // Does it deserve silver?
          if badgeEarnStatus.silverRun == nil && runSpeed > earnRunSpeed * silverMultiplier {
            badgeEarnStatus.silverRun = run
          }

          // Does it deserve gold?
          if badgeEarnStatus.goldRun == nil && runSpeed > earnRunSpeed * goldMultiplier {
            badgeEarnStatus.goldRun = run
          }

          // Is it the best for this distance?
          if let bestRun = badgeEarnStatus.bestRun {
            let bestRunSpeed = bestRun.distance.doubleValue / bestRun.duration.doubleValue
            if runSpeed > bestRunSpeed {
              badgeEarnStatus.bestRun = run
            }
          }
          else {
            badgeEarnStatus.bestRun = run
          }
        }
      }
      
      badgeEarnStatuses.append(badgeEarnStatus)
    }
    
    return badgeEarnStatuses
  }

这个方法会吧用户的跑步距离和对应的奖励的要求做个匹配,返回一个数组,数组里包含了所有的 BadgeEarnStatus

它的作用是,每当用户获得一个Badge的时候,它会产生一个像对应的速度,来判断这个奖励的级别是 silver version 还是 gold version

比如说,虽然你的小伙伴的速度比你快,但是如果你的进步足够大得话,依然有机会获得 gold version 的奖励

Displaying the Badges

现在是时候向用户展示你所有的奖励逻辑和UI界面了

你需要创建两个控制器和一个自定义的 table cell 来显示 Badg e数据

创建一个新的swift文件命名为 BadgeCell

打开这个文件,用下面的代码替换原来的内容

import UIKit
import HealthKit

class BadgeCell: UITableViewCell {
  @IBOutlet weak var nameLabel: UILabel!
  @IBOutlet weak var descLabel: UILabel!
  @IBOutlet weak var badgeImageView: UIImageView!
  @IBOutlet weak var silverImageView: UIImageView!
  @IBOutlet weak var goldImageView: UIImageView!
}

现在,你已经用 table view controller 为 badges 自定义了一个cell

接下来,在创建一个新的swift文件命名为 BadgesTableViewController, 打开文件替换里面的内容为

import UIKit
import HealthKit

class BadgesTableViewController: UITableViewController {
  var badgeEarnStatusesArray: [BadgeEarnStatus]!
  }

在之前的 BadgeController 里调用 badgeEarnStatusesForRuns(_:) 方法的时候会返回一个 badgeEarnStatuesArray 数组

添加如下属性给刚才的类

let redColor = UIColor(red: 1, green: 20/255, blue: 44/255, alpha: 1)
  let greenColor = UIColor(red: 0, green: 146/255, blue: 78/255, alpha: 1)
  let dateFormatter: NSDateFormatter = {
    let _dateFormatter = NSDateFormatter()
    _dateFormatter.dateStyle = .MediumStyle
    return _dateFormatter
    }()
  let transform = CGAffineTransformMakeRotation(CGFloat(M_PI/8.0))

每个cell会根据奖章的不同来显示不同的颜色

这些属性会保存在缓存里,不需要每次重新创建,每次创建新的会很耗性能,所以应该尽量考虑重复使用

然后,给 UITableViewDataSource 添加如下实现

// MARK: - UITableViewDataSource
extension BadgesTableViewController {
  override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return badgeEarnStatusesArray.count
  }

  override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier("BadgeCell") as! BadgeCell

    let badgeEarnStatus = badgeEarnStatusesArray[indexPath.row]

    cell.silverImageView.hidden = (badgeEarnStatus.silverRun != nil)
    cell.goldImageView.hidden = (badgeEarnStatus.goldRun != nil)

    if let earnRun = badgeEarnStatus.earnRun {
      cell.nameLabel.textColor = greenColor
      cell.nameLabel.text = badgeEarnStatus.badge.name!
      cell.descLabel.textColor = greenColor
      cell.descLabel.text = "Earned: " + dateFormatter.stringFromDate(earnRun.timestamp)
      cell.badgeImageView.image = UIImage(named: badgeEarnStatus.badge.imageName!)
      cell.silverImageView.transform = transform
      cell.goldImageView.transform = transform
      cell.userInteractionEnabled = true
    }
    else {
      cell.nameLabel.textColor = redColor
      cell.nameLabel.text = "?????"
      cell.descLabel.textColor = redColor
      let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: badgeEarnStatus.badge.distance!)
      cell.descLabel.text = "Run \(distanceQuantity.description) to earn"
      cell.badgeImageView.image = UIImage(named: badgeEarnStatus.badge.imageName!)
      cell.userInteractionEnabled = false
    }

    return cell
  }
}

这个方法告诉 tableView 要显示多少行,每个cell显示什么内容,你能够看到,每个cell对应的是不同的badge,而且,因为设置了 userInteractionEnabled,只有获得奖章的 cell才能被选中

现在你需要给 BadgesTableViewController 提供一些数据,打开 HomeViewController.swift 给 prepareForSegue(_:sender): 方法添加如下代码

else if segue.destinationViewController.isKindOfClass(BadgesTableViewController) {
      let fetchRequest = NSFetchRequest(entityName: "Run")

      let sortDescriptor = NSSortDescriptor(key: "timestamp", ascending: false)
      fetchRequest.sortDescriptors = [sortDescriptor]

      let runs = managedObjectContext!.executeFetchRequest(fetchRequest, error: nil) as! [Run]

      let badgesTableViewController = segue.destinationViewController as! BadgesTableViewController
      badgesTableViewController.badgeEarnStatusesArray = BadgeController.sharedController.badgeEarnStatusesForRuns(runs)
    }

这里,当 BadgesTableViewController 被压入导航栈里的时候,每一个奖励的状态都会被计算并且显示出来

链接storyboard,打开Main.storyboard做下面的事情

  • 绑定 BadgeCell 和 BadgesTableViewController
  • 脱线设置 name标签、Earned标签、头像icon和奖励标识如图所示

如果你已经用过它来跑步的话,肯定已经获得了 earth 级别的奖励,显然,奖励才刚开始

Badge Details

下一个控制器用来展示奖励的详细信息

创建一个新的swift文件命名为 BadgeDetailsViewController 并且替换内容为

import UIKit
import HealthKit

class BadgeDetailsViewController: UIViewController {
  var badgeEarnStatus: BadgeEarnStatus!

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

这个类用来存储你的获奖的详细状态,可以用来添加标识

添加如下的代码设置View

override func viewDidLoad() {
    super.viewDidLoad()

    let formatter = NSDateFormatter()
    formatter.dateStyle = .MediumStyle

    let transform = CGAffineTransformMakeRotation(CGFloat(M_PI/8.0))

    nameLabel.text = badgeEarnStatus.badge.name

    let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: badgeEarnStatus.badge.distance!)
    distanceLabel.text = distanceQuantity.description
    badgeImageView.image = UIImage(named: badgeEarnStatus.badge.imageName!)

    if let run = badgeEarnStatus.earnRun {
      earnedLabel.text = "Reached on " + formatter.stringFromDate(run.timestamp)
    }

    if let silverRun = badgeEarnStatus.silverRun {
      silverImageView.transform = transform
      silverImageView.hidden = false
      silverLabel.text = "Earned on " + formatter.stringFromDate(silverRun.timestamp)
    }
    else {
      silverImageView.hidden = true
      let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit())
      let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: badgeEarnStatus.earnRun!.duration.doubleValue / badgeEarnStatus.earnRun!.distance.doubleValue)
      silverLabel.text = "Pace < \(paceQuantity.description) for silver!"
    }

    if let goldRun = badgeEarnStatus.goldRun {
      goldImageView.transform = transform
      goldImageView.hidden = false
      goldLabel.text = "Earned on " + formatter.stringFromDate(goldRun.timestamp)
    }
    else {
      goldImageView.hidden = true
      let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit())
      let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: badgeEarnStatus.earnRun!.duration.doubleValue / badgeEarnStatus.earnRun!.distance.doubleValue)
      goldLabel.text = "Pace < \(paceQuantity.description) for gold!"
    }

    if let bestRun = badgeEarnStatus.bestRun {
      let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit())
      let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: bestRun.duration.doubleValue / bestRun.distance.doubleValue)
      bestLabel.text = "Best: \(paceQuantity.description), \(formatter.stringFromDate(bestRun.timestamp))"
    }
  }

这段代码设置了 badge image和相关的label中的数据

最有趣的部分是鼓励用户怎么样获得更高级别的奖励,这些鼓励会增加你的积极性,因为它需要更快地跑步记录

最后,添加这个方法

@IBAction func infoButtonPressed(sender: AnyObject) {
    UIAlertView(title: badgeEarnStatus.badge.name!,
      message: badgeEarnStatus.badge.information!,
      delegate: nil,
      cancelButtonTitle: "OK").show()
  }

当用户点击info按钮的时候会来到这里,将会显示badge的信息

现在详情页设置完毕了,你还需要确保在segue之前badges table view能够发送badge信息

打开BadgesTableViewController.swift 给 BadgesTableViewController添加如下方法


override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
    if segue.destinationViewController.isKindOfClass(BadgeDetailsViewController) {
      let badgeDetailsViewController = segue.destinationViewController as! BadgeDetailsViewController
      let badgeEarnStatus = badgeEarnStatusesArray[tableView.indexPathForSelectedRow()!.row]
      badgeDetailsViewController.badgeEarnStatus = badgeEarnStatus
    }
  }

当cell被点击的时候,BadgesDetailsViewController能展示相关的BadgeEarnStatus

现在,UI部分设置完毕了,打开Main.storyb做如下链接

  • 绑定BadgeDetailsViewController
  • 为BadgeDetailsVireController设置badgeImageView,bestLabel,distanceLabel,earnedLabel,goldImageView,goldLabel,nameLabel,silverImageLabel,silverLabel
  • 为info按钮设置点击事件

Badge Motivation

作为徽章奖励系统新的一部分,你需要回到UI部分,把它纳入之前的徽章体系中

打开Main.storyboard,找到new Run场景,在stop按钮的上方添加一个UIImageView和一个UILabel

为UIImageView,使用自动布局设置约束

  • Align Center X to Superview
  • Width equals 70
  • Height equals 70
  • Align top with the Start button

为UILabel,使用自动布局设置约束

  • Align Center X to Superview
  • Top Space to:UIImageView equals 10

新的界面长长这样

新的view在start按钮的地方会有部分重叠,但是在开始跑步以后start按钮会隐藏起来只显示另外两个控件

在跑步时会使用“carrot-on-a-stick”方式激励用户,会显示一个山峰的样子来描述你离下一个级别的奖励还有都少差距

在显示UI之前,你需要添加两个方法给 BadgeController 来决定你最好在在这次可以拿到某个奖励然后再下一次就可以拿到另外一个奖励

打开 Badge.swift 给 BadgeController添加以下方法

func bestBadgeForDistance(distance: Double) -> Badge {
    var bestBadge = badges.first as Badge!
    for badge in badges {
      if distance < badge.distance {
        break
      }
      bestBadge = badge
    }
    return bestBadge
  }

  func nextBadgeForDistance(distance: Double) -> Badge {
    var nextBadge = badges.first as Badge!
    for badge in badges {
      nextBadge = badge
      if distance < badge.distance {
        break
      }
    }
    return nextBadge
  }

这个很简单,只要你输入距离,就会返回

  • bestBadgeForDistance(_:): 你目前能获得的奖励
  • nextBadgeForDistance(_:): 你下一个能够获得的奖励

打开NewRunViewController.swift在顶部导入

import AudioToolbox

导入AudioToolbox之后你就能在用户每次获得新奖励的时候播放音效

接下来,为NewRunViewController 添加以下属性

var upcomingBadge : Badge?
  @IBOutlet weak var nextBadgeLabel: UILabel!
  @IBOutlet weak var nextBadgeImageView: UIImageView!

在viewWillAppear(_:)方法结尾处添加

nextBadgeLabel.hidden = true
nextBadgeImageView.hidden = true

badge label和badge image 一开始是需要隐藏的

给 startPressed(_:)方法结尾处添加

nextBadgeLabel.hidden = false
nextBadgeImageView.hidden = false

让 badge label 和 badge image 在跑步开始后显示

添加下面两个方法

func playSuccessSound() {
    let soundURL = NSBundle.mainBundle().URLForResource("success", withExtension: "wav")
    var soundID : SystemSoundID = 0
    AudioServicesCreateSystemSoundID(soundURL, &soundID)
    AudioServicesPlaySystemSound(soundID)

    //also vibrate
    AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate));
  }

  func checkNextBadge() {
    let nextBadge = BadgeController.sharedController.nextBadgeForDistance(distance)

    if let upcomingBadge = upcomingBadge {
      if upcomingBadge.name! != nextBadge.name! {
        playSuccessSound()
      }
    }
    
    upcomingBadge = nextBadge
  }

第一个方法播放音效的时候也会产生震动,以便在嘈杂的环境里通知用户或者防止播放音乐的过程中无法听到音效

当用户满足获得一个奖励的条件的时候会调用第二个方法检测这次获得的奖励是不是上一次获奖时记录的下一次即将获得的那个奖励,如果是,允许播放音效,并且把下一次即将要获得的奖励保存下来

为 eachSecond(_:)方法添加

checkNextBadge()
    if let upcomingBadge = upcomingBadge {
      let nextBadgeDistanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: upcomingBadge.distance! - distance)
      nextBadgeLabel.text = "\(nextBadgeDistanceQuantity.description) until \(upcomingBadge.name!)"
      nextBadgeImageView.image = UIImage(named: upcomingBadge.imageName!)
    }

这段代码可以让 nextBadgeLabel 和 nextBadgeImageView 在跑步的过程中持续更新

编译运行,start a new run

你可以看到label和image在不断更新

Where to go From Here

恭喜你!

完成了一个可以在跑步过程中实时记录运行轨迹并且有成就激励系统的App

你可以在这里下载完整代码

http://cdn3.raywenderlich.com/wp-content/uploads/2015/05/MoonRunner-Part2-Final.zip

根据这两篇教程,你做了一个app

  • 用Core Location 测量你的轨迹
  • 显示跑步过程中的实时数据
  • 在地图上用不同颜色的曲线来标记你的轨迹和位置
  • 速度和距离的个人奖励系统

这个app只是完成了这类app的基础功能,要让跟多的人使用你的app你还需要做更多地完善,成就奖励是一个“游戏化”app很好的方式

如果你想让你的app有进一步的提升,你还需要做

  • 显示用户的跑步历史记录
  • 将奖励和速度值标注在轨迹上
  • 将奖励和注释标注在地图上

end

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

推荐阅读更多精彩内容