Swift

CoreLocation框架详细解析(十四) —— 仿Runke

2018-10-18  本文已影响51人  刀客传奇

版本记录

版本号 时间
V1.0 2018.10.18 星期四

前言

很多的app都有定位功能,比如说滴滴,美团等,他们都需要获取客户所在的位置,并且根据位置推送不同的模块数据以及服务,可以说,定位方便了我们的生活,接下来这几篇我们就说一下定位框架CoreLocation。感兴趣的可以看我写的上面几篇。
1. CoreLocation框架详细解析 —— 基本概览(一)
2. CoreLocation框架详细解析 —— 选择定位服务的授权级别(二)
3. CoreLocation框架详细解析 —— 确定定位服务的可用性(三)
4. CoreLocation框架详细解析 —— 获取用户位置(四)
5. CoreLocation框架详细解析 —— 监控用户与地理区域的距离(五)
6. CoreLocation框架详细解析 —— 确定接近iBeacon(六)
7. CoreLocation框架详细解析 —— 将iOS设备转换为iBeacon(七)
8. CoreLocation框架详细解析 —— 获取指向和路线信息(八)
9. CoreLocation框架详细解析 —— 在坐标和用户友好的地名之间转换(九)
10. CoreLocation框架详细解析(十) —— 跟踪访问位置简单示例(一)
11. CoreLocation框架详细解析(十一) —— 跟踪访问位置简单示例(二)
12. CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)
13. CoreLocation框架详细解析(十三) —— 仿Runkeeper的简单实现(二)

开始

首先看一下写作环境

Swift 4, iOS 11, Xcode 9

该应用程序在当前状态下非常适合记录和显示数据,但它需要更多的火花才能为用户提供额外的动力。

在本节中,您将通过实施徽章系统来完成演示MoonRunner应用程序,该系统体现了健身是一种有趣且基于进步的成就的概念。 以下是它的工作原理:

无论您使用哪个文件,您都会注意到您的项目包含资产目录中的许多图像以及名为badges.txt的文件。 现在打开badges.txt。 您可以看到它包含一个大型JSON徽章对象数组。 每个对象包含:

徽章从0米开始 - 嘿,你必须从某个地方开始 - 直到完整马拉松的长度。

第一项任务是将JSON文本解析为徽章数组。 将新的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
  }
}

Earning The Badge - 赢得徽章

现在您已经创建了Badge结构,在获得徽章时,您需要一个存储结构。 此结构将徽章与用户获得此Badge版本的各种Run对象(如果有)相关联。

将新的Swift文件添加到项目中,将其命名为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值。

用户第一次获得徽章时,该跑步速度成为用于确定后续跑步是否已经足够改进以获得银色或金色版本的参考。

最后,该方法跟踪用户对每个徽章距离的最快跑步。


Displaying the Badges - 显示徽章

现在您已经将所有逻辑写入授予徽章,现在是时候向用户显示它们了。 初始项目已经定义了必要的UI。 您将在UITableViewController中显示徽章列表。 为此,首先需要定义显示徽章的自定义table view cell

将新的Swift文件添加到项目中并将其命名为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()
    }
  }
}

这些是显示徽章信息所需的outlets。 您还声明了一个status变量,它是单元格的模型。

接下来,在status变量下面的单元格中添加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配置表视图单元格。

如果复制并粘贴代码,您会注意到Xcode将#colorLiterals更改为样本。 如果您手动输入,请开始键入单词Color literal,选择Xcode完成并双击生成的样本。

这将显示一个简单的颜色选择器。 单击Other…按钮。

这将调出系统颜色选择器。 要匹配示例项目中使用的颜色,请使用Hex Color#字段,输入FF142C表示红色,输入00924E表示绿色。

打开Main.storyboard并将outlets连接到Badges Table View Controller Scene中的BadgeCell

现在已经定义了table cell,现在是时候创建table view controller了。 将新的Swift文件添加到项目中并将其命名为BadgesTableViewController.swift。 替换导入部分以导入UIKitCoreData

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询问所有已完成的跑步的列表,按日期排序,然后使用此列表构建所获得的徽章列表。

接下来,在扩展中添加UITableViewDataSource方法:

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
  }
}

这些是所有UITableViewControllers所需的标准UITableViewDataSource方法,将行数和已配置的单元格返回到表中。 正如在第1部分中一样,您通过在StoryboardSupport.swift中定义的泛型方法使单元格出列来减少“stringly typed”代码。

Build并运行以检查您的新徽章! 你应该看到这样的东西:


What Does a Runner Have to Do to Get a Gold Medal Around Here? - 跑步者必须做些什么来获得这里的金牌?

MoonRunner的最后一个视图控制器是显示徽章细节的控制器。 将新的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所需的所有outlets以及作为此视图模型的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信息的详细视图中设置标签。 现在,你需要设置金色和银色徽章。

将以下代码添加到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来隐藏金色和银色图像视图。这适用于嵌套的UIStackViews和自动布局之间的交互。

最后,添加以下方法:

@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)
}

按下信息按钮时将调用此选项,并显示带有徽章信息的弹出窗口。

打开Main.storyboard。 连接BadgeDetailsViewControlleroutlets

将操作infoButtonTapped()连接到info button。 最后,在Badges Table View Controller Scene中选择Table View

选中Attributes Inspector中的User Interaction Enabled复选框:

打开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
    }
  }
}

当用户点击表中的徽章时,这会将BadgeStatus传递给BadgeDetailsViewController

iOS 11注意:iOS 11的当前测试版在配置单元格之后和显示之前将表格单元格的属性isUserInteractionEnabled重置为true。 因此,您必须实现shouldPerformSegue(withIdentifier:sender :)以防止访问未获得的徽章的徽章详细信息。 如果更高版本的iOS 11更正此错误,则可以删除此方法。

建立并运行。 查看您的新徽章的详细信息!


Carrot Motivation - 激励

现在您已经拥有了一个很酷的新徽章系统,您需要更新现有应用程序的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 Scene中找到Button Stack View。 将UIImageView和UILabel拖到Document Outline中。 确保它们位于Button Stack View的顶部:

选择这两个新视图,然后选择Editor \ Embed In \ Stack View。 更改生成的堆栈视图的属性,如下所示:

将图像视图的Content Mode设置为Aspect Fit

更改Label的属性如下:

使用您最喜欢的Assistant Editor连接新的Stack View, Image View and Label中的outlets,命名如下:

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

Xcode 9注意:如果你看到一对警告说你的新UI项目的垂直位置不明确,请不要担心。 您的Xcode版本无法正确计算隐藏项目的子视图的布局。 要使警告消失,请取消选中Main.storyboard中徽章堆栈视图上的Hidden属性。 然后将以下行添加到NewRunViewController.swift中的viewDidLoad()

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

打开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是作为“success sound”的音频播放器创建的,每次获得新徽章时都会播放该声音播放器。

接下来,找到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()

构建并运行以在模拟器运行时观察标签更新。 通过新徽章时听取声音!

注意:在控制台中,一旦播放成功声音,您可能会看到一些如下所示的错误消息:

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

在模拟器上,这是正常的。 消息来自AVFoundation,并不表示您的错误。

此外,如果您不想等待测试徽章,您可以随时在模拟器的Debug \ Location菜单中切换到其他位置模式。

后记

本篇主要讲述了仿Runkeeper的简单实现,感兴趣的给个赞或者关注~~~

上一篇下一篇

猜你喜欢

热点阅读