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
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

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