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

What Does a Runner Have to Do to Get a Gold Medal Around Here?

The last view controller for MoonRunner is the one that shows the details of a badge. Add a new Swift file to your project and name it BadgeDetailsViewController.swift. Replace the contents of the file with the following:

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

This declares all of the outlets you will need to control the UI and the BadgeStatus that is the model for this view.

Next, add your 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)
}

This sets up the labels in the detail view from the BadgeStatus information. Now, you need to set up the gold and silver badges.

Add the following code to the end of 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!"
}

The gold and silver image views are hidden when necessary by setting their alphas to 0. This works around an interaction between nested UIStackViews and Auto Layout.

Finally, add the following method:

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

This will be invoked when the info button is pressed and will show a pop-up with the badge's information.

Open Main.storyboard. Connect the outlets of BadgeDetailsViewController:

  • badgeImageView
  • nameLabel
  • distanceLabel
  • earnedLabel
  • bestLabel
  • silverLabel
  • goldLabel
  • silverImageLabel
  • goldImageLabel

Connect the action infoButtonTapped() to the info button. Finally, Select the Table View in the Badges Table View Controller Scene.

app like runkeeper

Check the User Interaction Enabled checkbox in the Attributes Inspector:

app like runkeeper

Open BadgesTableViewController.swift and add the following extension:

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

This takes care of passing a BadgeStatus to BadgeDetailsViewController when the user taps a badge in the table.

iOS 11 Note: The current beta of iOS 11 resets the table cell's property isUserInteractionEnabled to true after the cell is configured and before it is displayed. As a result, you must implement shouldPerformSegue(withIdentifier:sender:) to prevent accessing badge details for unearned badges. If later versions of iOS 11 correct this error, this method can be dropped.

Build and run. Check out your new badges' details!

app like runkeeper

Carrot Motivation

Now that you have a cool new badge system, you need to update the UI of the existing app to incorporate it. Before you can do that, you need a couple of utility methods to determine the most recently earned badge and the next badge to earn for a given distance.

Open Badge.swift and add these methods:

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

Each of these methods filters the list of badges depending on whether they have been earned or are, as yet, unearned.

Now, open Main.storyboard. Find the Button Stack View in the New Run View Controller Scene. Drag a UIImageView and a UILabel into the Document Outline. Make sure they are at the top of Button Stack View:

app like runkeeper

Select both of these new views and select Editor\Embed In\Stack View. Change the resulting Stack View's properties as follows:

  • Axis: Horizontal
  • Distribution: Fill Equally
  • Spacing: 10
  • Hidden: checked

app like runkeeper

Set the Image View's Content Mode to Aspect Fit.

Change the Label's properties as follows:

  • Color: White Color
  • Font: System 14.0
  • Lines: 0
  • Line Break: Word Wrap
  • Autoshrink: Minimum Font Size
  • Tighten Letter Spacing: checked

app like runkeeper

Use your favorite Assistant Editor technique to connect outlets from the new Stack View, Image View and Label, named as follows:

@IBOutlet weak var badgeStackView: UIStackView!
@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoLabel: UILabel!
Xcode 9 note: If you see a pair of warnings that your new UI items' vertical positions are ambiguous, fret not. Your version of Xcode is not correctly calculating layout for subviews of a hidden item. To make the warnings disappear, uncheck the Hidden attribute on the Badge Stack View in Main.storyboard. Then add the following line to viewDidLoad() in NewRunViewController.swift:
badgeStackView.isHidden = true // required to work around behavior change in Xcode 9 beta 1

With luck, this problem will be resolved in a future release of Xcode 9.

Open NewRunViewController.swift and import AVFoundation:

import AVFoundation

Now, add the following properties:

private var upcomingBadge: Badge!
private let successSound: AVAudioPlayer = {
  guard let successSound = NSDataAsset(name: "success") else {
    return AVAudioPlayer()
  }
  return try! AVAudioPlayer(data: successSound.data)
}()

successSound is created as an audio player for the "success sound" that will be played each time a new badge is earned.

Next, find updateDisplay() and add:

let distanceRemaining = upcomingBadge.distance - distance.value
let formattedDistanceRemaining = FormatDisplay.distance(distanceRemaining)
badgeInfoLabel.text = "\(formattedDistanceRemaining) until \(upcomingBadge.name)"

This will keep the user up-to-date about the next badge to be earned.

In startRun(), before the call to updateDisplay(), add:

badgeStackView.isHidden = false
upcomingBadge = Badge.next(for: 0)
badgeImageView.image = UIImage(named: upcomingBadge.imageName)

This shows the initial badge to earn.

In stopRun() add:

badgeStackView.isHidden = true

Just like the other views, all of the badge info needs to be hidden between runs.

Add the following new method:

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

This detects when a badge has been achieved, updates the UI to show the next badge, and plays a success sound to celebrate completing a badge.

In eachSecond() add a call to checkNextBadge() before the call to updateDisplay():

checkNextBadge()

Build and run to watch the label update as the simulator goes for a run. Listen for the sound when it passes a new badge!

app like runkeeper

Note: In the console, once the success sound is played, you will likely see some error messages that look like this:
[aqme] 254: AQDefaultDevice (188): skipping input stream 0 0 0x0

On the simulator, this is normal. The messages come from AVFoundation and do not indicate an error on your part.

Also, if you don't want to wait around to test out the badges, you can always switch to a different location mode in the Simulator's Debug\Location menu. Don't worry, we won't tell anyone. :]

Everything Is Better When it Has a "Space Mode"

After a run has finished, it would be nice to provide your users with the ability to see the last badge that they earned.

Open Main.storyboard and find the Run Details View Controller Scene. Drag a UIImageView on top of the Map View. Control-drag from the Image View to the Map View. On the resulting pop-up, hold down Shift and select Top, Bottom, Leading and Trailing. Click Add Constraints to pin the edges of the Image View to those of the Map View.

app like runkeeper

Xcode will add the constraints, each with a value of 0, which is exactly what you want. Currently, however, the Image View doesn't completely cover the Map View so you see the orange warning lines. Click the Update Frames button (outlined in red below) to resize the Image View.

app like runkeeper

Drag a UIButton on top of the Image View. Delete the Button's Title and set its Image value to info.

app like runkeeper

Control-drag from the button to the Image View. On the resulting pop-up, hold down Shift and select Bottom and Trailing. Click Add Constraints to pin the button to the bottom right corner of the image view.

app like runkeeper

In the Size Inspector, Edit each constraint and set its value to -8.

app like runkeeper

Click the Update Frames button again to fix the Button's size and position.

app like runkeeper

Select the Image View and set its Content Mode to Aspect Fit and its Alpha to 0.

app like runkeeper

Select the Button and set its Alpha to 0.

Note: You are hiding these views using their Alpha property instead of their Hidden property because you're going to animate them into view for a smoother user experience.

Drag a UISwitch and a UILabel into the bottom right corner of the view.

app like runkeeper

Select the Switch and press the Add New Contraints button (the "Tie Fighter" button). Add constraints for Right, Bottom and Left with a value of 8. Make sure the Left constraint is relative to the Label. Select Add 3 Constraints.

app like runkeeper

Set the Switch Value to Off.

app like runkeeper

Control-drag from the Switch to the Label. On the resulting pop-up, select Center Vertically.

app like runkeeper

Select the Label, set its Title to SPACE MODE and it's Color to White Color.

app like runkeeper

In the Document Outline, Control-drag from the Switch to the Stack View. Select Vertical Spacing from the resulting pop-up.

app like runkeeper

In the Size Inspector for the Switch, Edit the constraint for Top Space to: Stack View. Set its relation to and its value to 8.

app like runkeeper

Whew! You deserve a badge after all of that layout work! :]

Open RunDetailsViewController.swift in the Assistant Editor and connect outlets for the Image View and Info Button as follows:

@IBOutlet weak var badgeImageView: UIImageView!
@IBOutlet weak var badgeInfoButton: UIButton!

Add the following action routine for the Switch and connect it:

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

When the switch value changes, you animate the visibilities of the Image View, the Info Button and the Map View by changing their alpha values.

Now add the action routine for the Info Button and connect it:

@IBAction func infoButtonTapped() {
  let badge = Badge.best(for: run.distance)
  let alert = UIAlertController(title: badge.name,
                                message: badge.information,
                                preferredStyle: .alert)
  alert.addAction(UIAlertAction(title: "OK", style: .cancel))
  present(alert, animated: true)
}

This is exactly the same as the button handler you implemented in BadgeDetailsViewController.swift.

The final step is to add the following to the end of configureView():

let badge = Badge.best(for: run.distance)
badgeImageView.image = UIImage(named: badge.imageName)

You find the last badge the user earned on the run and set it to display.

Build and run. Send the simulator on a run, save the details and try out your new "Space Mode"!

app like runkeeper

Mapping the Solar System In Your Town

The post-run map already helps you remember your route and even identify specific areas where your speed was lower. Now you'll add a feature that shows exactly where each badge was earned.

MapKit uses annotations to display point data such as this. To create annotations, you need:

  • A class conforming to MKAnnotation that provides a coordinate describing the annotation's location.
  • A subclass of MKAnnotationView that displays the information associated with an annotation.

To implement this, you will:

  • Create the class BadgeAnnotation that conforms to MKAnnotation.
  • Create an array of BadgeAnnotation objects and add them to the map.
  • Implement mapView(_:viewFor:) to create the MKAnnotationViews.

Add a new Swift file to your project and name it BadgeAnnotation.swift. Replace its contents with:

import MapKit

class BadgeAnnotation: MKPointAnnotation {
  let imageName: String
  
  init(imageName: String) {
    self.imageName = imageName
    super.init()
  }
}

MKPointAnnotation conforms to MKAnnotation so all you need is a way to pass the image name to the rendering system.

Open RunDetailsViewController.swift and add this new method:

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
}

This creates an array of BadgeAnnotation objects, one for each badge earned on the run.

Add the following at the end of loadMap():

mapView.addAnnotations(annotations())

This puts the annotations on the map.

Finally, add this method to the MKMapViewDelegate extension:

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
}

Here, you create an MKAnnotationView for each annotation and configure it to display the badge's image.

Build and run. Send the simulator on a run and save the run at the end. The map will now have annotations for each badge earned. Click on one and you can see its name, picture and distance.

app like runkeeper

Where to Go From Here?

You can find the completed sample project for this tutorial here.

Over the course of this two-part tutorial you built an app that:

  • Measures and tracks your runs using Core Location.
  • Displays real-time data, like the run's average pace, along with an active map.
  • Maps out a run with a color-coded polyline and custom annotations at each checkpoint.
  • Awards badges for personal progress in distance and speed.

There are more things for you to implement on your own:

  • Add a table for a user's past runs. NSFetchedResultsController and the existing RunDetailsViewController make this a snap!
  • Find the average pace between each checkpoint and display it on the MKAnnotationView callout.

Thanks for reading. As always, I look forward to your comments and questions! :]