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:
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 UIStackView
s 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.
Check the User Interaction Enabled checkbox in the Attributes Inspector:
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.
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!
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:
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
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
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!
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!
[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.
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.
Drag a UIButton
on top of the Image View. Delete the Button's Title and set its Image value to info.
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.
In the Size Inspector, Edit each constraint and set its value to -8.
Click the Update Frames button again to fix the Button's size and position.
Select the Image View and set its Content Mode to Aspect Fit and its Alpha to 0.
Select the Button and set its Alpha to 0.
Drag a UISwitch
and a UILabel
into the bottom right corner of the view.
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.
Set the Switch Value to Off.
Control-drag from the Switch to the Label. On the resulting pop-up, select Center Vertically.
Select the Label, set its Title to SPACE MODE and it's Color to White Color.
In the Document Outline, Control-drag from the Switch to the Stack View. Select Vertical Spacing from the resulting pop-up.
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.
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"!
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 toMKAnnotation
. - Create an array of
BadgeAnnotation
objects and add them to the map. - Implement
mapView(_:viewFor:)
to create theMKAnnotationView
s.
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.
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 existingRunDetailsViewController
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! :]