How To Make an App Like Runkeeper: Part 2
This is the second and final part of a tutorial that teaches you how to create an app like Runkeeper, complete with color-coded maps and badges! By Richard Critz.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
This is the second and final part of a tutorial that teaches you how to create an app like Runkeeper, complete with color-coded maps and badges!
In part one of the tutorial, you created an app that:
- Uses Core Location to track your route.
- Maps your path and reports your average pace as you run.
- Shows a map of your route when the run is complete, color-coded to reflect your pace.
The app, in its current state, is great for recording and displaying data, but it needs a bit more spark to give users that extra bit of motivation.
In this section, you’ll complete the demo MoonRunner app by implementing a badge system that embodies the concept that fitness is a fun and progress-based achievement. Here’s how it works:
- A list maps out checkpoints of increasing distance to motivate the user.
- As the user runs, the app shows a thumbnail of the upcoming badge and the distance remaining to earn it.
- The first time a user reaches a checkpoint, the app awards a badge and notes that run’s average speed.
From there, silver and gold versions of the badge are awarded for reaching that checkpoint again at a proportionally faster speed. - The post-run map displays a dot at each checkpoint along the path with a custom callout showing the badge name and image.
Getting Started
If you completed part one of the tutorial, you can continue on with your completed project from that tutorial. If you’re starting here, download this starter project.
Regardless of which file you use, you’ll notice your project contains a number of images in the asset catalog and a file named badges.txt. Open badges.txt now. You can see it contains a large JSON array of badge objects. Each object contains:
- A name.
- Some interesting information about the badge.
- The distance in meters to achieve the badge.
- The name of the corresponding image in the asset catalog (imageName).
The badges go all the way from 0 meters — hey, you have to start somewhere — up to the length of a full marathon.
The first task is to parse the JSON text into an array of badges. Add a new Swift file to your project, name it Badge.swift, and add the following implementation to it:
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
}
}
This defines the Badge
structure and provides a failable initializer to extract the information from the JSON object.
Add the following property to the structure to read and parse the 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")
}
}()
You use basic JSON deserialization to extract the data from the file and flatMap
to discard any structures which fail to initialize. allBadges
is declared static
so that the expensive parsing operation happens only once.
You will need to be able to match Badge
s later, so add the following extension
to the end of the file:
extension Badge: Equatable {
static func ==(lhs: Badge, rhs: Badge) -> Bool {
return lhs.name == rhs.name
}
}
Earning The Badge
Now that you have created the Badge
structure, you’ll need a structure to store when a badge was earned. This structure will associate a Badge
with the various Run
objects, if any, where the user achieved versions of this badge.
Add a new Swift file to your project, name it BadgeStatus.swift, and add the following implentation to it:
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
}
This defines the BadgeStatus
structure and the multipliers that determine how much a user’s time must improve to earn a silver or gold badge. Now add the following method to the structure:
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)
}
}
This method compares each of the user’s runs to the distance requirements for each badge, making the associations and returning an array of BadgeStatus
values for each badge earned.
The first time a user earns a badge, that run’s speed becomes the reference used to determine if subsequent runs have improved enough to qualify for the silver or gold versions.
Lastly, the method keeps track of the user’s fastest run to each badge’s distance.
Displaying the Badges
Now that you have all of the logic written to award badges, it’s time to show them to the user. The starter project already has the necessary UI defined. You will display the list of badges in a UITableViewController
. To do this, you first need to define the custom table view cell that displays a badge.
Add a new Swift file to your project and name it BadgeCell.swift. Replace the contents of the file with:
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()
}
}
}
These are the outlets you will need to display information about a badge. You also declare a status
variable which is the model for the cell.
Next, add a configure()
method to the cell, right under the status
variable:
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
}
}
This straightforward method configures the table view cell based on the BadgeStatus
set into it.
If you copy and paste the code, you will notice that Xcode changes the #colorLiteral
s to swatches. If you’re typing by hand, start typing the words Color literal, select the Xcode completion and double-click on the resulting swatch.
This will display a simple color picker. Click the Other… button.
This will bring up the system color picker. To match the colors used in the sample project, use the Hex Color # field and enter FF142C for red and 00924E for green.
Open Main.storyboard and connect your outlets to the BadgeCell in the Badges Table View Controller Scene:
- badgeImageView
- silverImageView
- goldImageView
- nameLabel
- earnedLabel
Now that your table cell is defined, it is time to create the table view controller. Add a new Swift file to your project and name it BadgesTableViewController.swift. Replace the import section to import UIKit
and CoreData
:
import UIKit
import CoreData
Now, add the class definition:
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 []
}
}
}
When the view loads, you ask Core Data for a list of all completed runs, sorted by date, and then use this to build the list of badges earned.
Next, add the UITableViewDataSource
methods in an extension
:
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
}
}
These are the standard UITableViewDataSource
methods required by all UITableViewController
s, returning the number of rows and the configured cells to the table. Just as in part 1, you are reducing “stringly typed” code by dequeuing the cell via a generic method defined in StoryboardSupport.swift.
Build and run to check out your new badges! You should see something like this: