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.
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
How To Make an App Like Runkeeper: Part 1
35 mins
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:
A Run
has three attributes: distance
, duration
and timestamp
. It has a single relationship, locations
, that connects it to the Location
entity.
Now, set up Location
with the following 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”.
Select the locations
relationship, and set the Type to To Many, and check the Ordered box in the Data Model Inspector’s Relationship pane.
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).
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”:
Press the Stop button and verify that pressing Save takes you to the “Details” screen.
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
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 Unit
s 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).