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.

Leave a rating/review
Save for later
Share
Update note: This tutorial has been updated to iOS 11 Beta 1, Xcode 9 and Swift 4 by Richard Critz. Original tutorial by Matt Luedke.

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 Badges 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 #colorLiterals 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.

app like runkeeper

This will display a simple color picker. Click the Other… button.

app like runkeeper

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.

app like runkeeper

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 UITableViewControllers, 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:

app like runkeeper