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