How To Make an App Like Runkeeper: Part 1

Runkeeper, a GPS app like the one you’re about to make, has over 40 million users! This tutorial will show you how to make an app like Runkeeper. 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.

The motivational run-tracking app Runkeeper has over 40 million users! This tutorial will show you how to make an app like Runkeeper that will teach you the following:

  • Uses Core Location to track your route.
  • Shows a map during your run with a constantly updating line marking your path.
  • Reports your average pace as you run.
  • Awards badges for running various distances. Silver and gold versions of each badge recognize personal improvements, regardless of your starting point.
  • Encourages you by tracking the remaining distance to the next badge.
  • Shows a map of your route when you’re finished. The map line is color-coded to reflect your pace.

The result? Your new app — MoonRunner — with badges based on planets and moons in our Solar System!

Before you run headlong into this tutorial, you should be familiar with Storyboards and Core Data. Check out the linked tutorials if you feel you need a refresher.

This How to Make an App Like Runkeeper tutorial also makes use of iOS 10’s new Measurement and MeasurementFormatter capabilities. See the linked screencasts if you need more detail.

There’s so much to talk about that this tutorial comes in two parts. The first segment focuses on recording the run data and rendering the color-coded map. The second segment introduces the badge system.

Getting Started

Download the starter project. It includes all of the project files and assets that you will need to complete this tutorial.

Take a few minutes to explore the project. Main.storyboard already contains the UI. CoreDataStack.swift removes Apple’s template Core Data code from AppDelegate and puts it in its own class. Assets.xcassets contains the images and sounds you will use.

Model: Runs and Locations

MoonRunner’s use of Core Data is fairly simple, using only two entities: Run and Location.

Open MoonRunner.xcdatamodeld and create two entities: Run and Location. Configure Run with the following properties:

app like runkeeper - Run properties

A Run has three attributes: distance, duration and timestamp. It has a single relationship, locations, that connects it to the Location entity.

Note: You will be unable to set the Inverse relationship until after the next step. This will cause a warning. Don’t panic!

Now, set up Location with the following properties:

Location properties

A Location also has three attributes: latitude, longitude and timestamp and a single relationship, run.

Select the Run entity and verify that its locations relationship Inverse property now says “run”.

app like runkeeper - Run properties completed

Select the locations relationship, and set the Type to To Many, and check the Ordered box in the Data Model Inspector’s Relationship pane.

locations data model inspector

Finally, verify that both Run and Location entities’ Codegen property is set to Class Definition in the Entity pane of the Data Model Inspector (this is the default).

app like runkeeper Codegen properties

Build your project so that Xcode can generate the necessary Swift definitions for your Core Data model.

Completing the Basic App Flow

Open RunDetailsViewController.swift and add the following line right before viewDidLoad():

var run: Run!

Next, add the following function after viewDidLoad():

private func configureView() {
}

Finally, inside viewDidLoad() after the call to super.viewDidLoad(), add a call to configureView().

configureView()

This sets up the bare minimum necessary to complete navigation in the app.

Open NewRunViewController.swift and add the following line right before viewDidLoad():

private var run: Run?

Next, add the following new methods:

private func startRun() {
  launchPromptStackView.isHidden = true
  dataStackView.isHidden = false
  startButton.isHidden = true
  stopButton.isHidden = false
}
  
private func stopRun() {
  launchPromptStackView.isHidden = false
  dataStackView.isHidden = true
  startButton.isHidden = false
  stopButton.isHidden = true
}

The stop button and the UIStackView containing the labels that describe the run are hidden in the storyboard. These routines switch the UI between its “not running” and “during run” modes.

In startTapped(), add a call to startRun().

startRun()

At the end of the file, after the closing brace, add the following extension:

extension NewRunViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "RunDetailsViewController"
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! RunDetailsViewController
      destination.run = run
    }
  }
}

Apple’s interface for storyboard segues is what is commonly known as “stringly typed”. The segue identifier is a string, and there is no error checking. Using the power of Swift protocols and enums, and a little bit of pixie dust in StoryboardSupport.swift, you can avoid much of the pain of such a “stringly typed” interface.

Next, add the following lines to stopTapped():

let alertController = UIAlertController(title: "End run?", 
                                        message: "Do you wish to end your run?", 
                                        preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
  self.stopRun()
  self.performSegue(withIdentifier: .details, sender: nil)
})
alertController.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
  self.stopRun()
  _ = self.navigationController?.popToRootViewController(animated: true)
})
    
present(alertController, animated: true)

When the user presses the stop button, you should let them decide whether to save, discard, or continue the run. You use a UIAlertController to prompt the user and get their response.

Build and run. Press the New Run button and then the Start button. Verify that the UI changes to the “running mode”:

Running mode

Press the Stop button and verify that pressing Save takes you to the “Details” screen.

app like runkeeper Details screen

This is normal and does not indicate an error on your part.

Note: In the console, you will likely see some error messages that look like this:
MoonRunner[5400:226999] [VKDefault] /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

This is normal and does not indicate an error on your part.

MoonRunner[5400:226999] [VKDefault] /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

Units and Formatting

iOS 10 introduced new capabilities that make it far easier to work with and display units of measurement. Runners tend to think of their progress in terms of pace (time per unit distance) which is the inverse of speed (distance per unit time). You must extend UnitSpeed to support the concept of pace.

Add a new Swift file to your project named UnitExtensions.swift. Add the following after the import statement:

class UnitConverterPace: UnitConverter {
  private let coefficient: Double
  
  init(coefficient: Double) {
    self.coefficient = coefficient
  }
  
  override func baseUnitValue(fromValue value: Double) -> Double {
    return reciprocal(value * coefficient)
  }
  
  override func value(fromBaseUnitValue baseUnitValue: Double) -> Double {
    return reciprocal(baseUnitValue * coefficient)
  }
  
  private func reciprocal(_ value: Double) -> Double {
    guard value != 0 else { return 0 }
    return 1.0 / value
  }
}

Before you can extend UnitSpeed to convert to and from a pace measurement, you must create a UnitConverter that can handle the math. Subclassing UnitConverter requires that you implement baseUnitValue(fromValue:) and value(fromBaseUnitValue:).

Now, add this code to the end of the file:

extension UnitSpeed {
  class var secondsPerMeter: UnitSpeed {
    return UnitSpeed(symbol: "sec/m", converter: UnitConverterPace(coefficient: 1))
  }
  
  class var minutesPerKilometer: UnitSpeed {
    return UnitSpeed(symbol: "min/km", converter: UnitConverterPace(coefficient: 60.0 / 1000.0))
  }
  
  class var minutesPerMile: UnitSpeed {
    return UnitSpeed(symbol: "min/mi", converter: UnitConverterPace(coefficient: 60.0 / 1609.34))
  }
}

UnitSpeed is one of the many types of Units provided in Foundation. UnitSpeed‘s default unit is “meters/second”. Your extension allows the speed to be expressed in terms of minutes/km or minutes/mile.

You need a uniform way to display quantities such as distance, time, pace and date throughout MoonRunner. MeasurementFormatter and DateFormatter make this simple.

Add a new Swift file to your project named FormatDisplay.swift. Add the following after the import statement:

struct FormatDisplay {
  static func distance(_ distance: Double) -> String {
    let distanceMeasurement = Measurement(value: distance, unit: UnitLength.meters)
    return FormatDisplay.distance(distanceMeasurement)
  }
  
  static func distance(_ distance: Measurement<UnitLength>) -> String {
    let formatter = MeasurementFormatter()
    return formatter.string(from: distance)
  }
  
  static func time(_ seconds: Int) -> String {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.hour, .minute, .second]
    formatter.unitsStyle = .positional
    formatter.zeroFormattingBehavior = .pad
    return formatter.string(from: TimeInterval(seconds))!
  }
  
  static func pace(distance: Measurement<UnitLength>, seconds: Int, outputUnit: UnitSpeed) -> String {
    let formatter = MeasurementFormatter()
    formatter.unitOptions = [.providedUnit] // 1
    let speedMagnitude = seconds != 0 ? distance.value / Double(seconds) : 0
    let speed = Measurement(value: speedMagnitude, unit: UnitSpeed.metersPerSecond)
    return formatter.string(from: speed.converted(to: outputUnit))
  }
  
  static func date(_ timestamp: Date?) -> String {
    guard let timestamp = timestamp as Date? else { return "" }
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    return formatter.string(from: timestamp)
  }
}

These simple functions should be mostly self-explanatory. In pace(distance:seconds:outputUnit:), you must set the MeasurementFormatter‘s unitOptions to .providedUnits to prevent it from displaying the localized measurement for speed (e.g. mph or kph).