Quantcast
Channel: Kodeco | High quality programming tutorials: iOS, Android, Swift, Kotlin, Unity, and more
Viewing all articles
Browse latest Browse all 4370

How To Make an App Like Runkeeper: Part 1

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

How To Make an App Like Runkeeper: Part 1

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

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.

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

Starting a Run

It’s almost time to start running. But first, the app needs to know where it is. For this, you will use Core Location. It is important that there be only one instance of CLLocationManager in your app and that it not be inadvertently deleted.

To accomplish this, add another Swift file to your project named LocationManager.swift. Replace the contents of the file with:

import CoreLocation

class LocationManager {
  static let shared = CLLocationManager()

  private init() { }
}

You need to make a couple of project level changes before you can begin tracking the user’s location.

First, click on the project at the top of the Project Navigator.

app like runkeeper - select project options

Select the Capabilities tab and switch Background Modes to ON. Check Location updates.

app like runkeeper - Enable background location updates

Next, open Info.plist. Click the + next to Information Property List. From the resulting drop-down list, select Privacy – Location When In Use Usage Description and set its value to MoonRunner needs access to your location in order to record and track your run!

Info.plist

Note: This Info.plist key is critical. Without it, your user will never be able to authorize your app to access location services.

Before your app can use location information, it must get permission from the user. Open AppDelegate.swift and add the following to application(_:didFinishLaunchingWithOptions:) just before return true:

let locationManager = LocationManager.shared
locationManager.requestWhenInUseAuthorization()

Open NewRunViewController.swift and import CoreLocation:

import CoreLocation

Next, add the following after the run property:

private let locationManager = LocationManager.shared
private var seconds = 0
private var timer: Timer?
private var distance = Measurement(value: 0, unit: UnitLength.meters)
private var locationList: [CLLocation] = []

Taking it line-by-line:

  • locationManager is the object you’ll use to start and stop location services.
  • seconds tracks the duration of the run, in seconds.
  • timer will fire each second and update the UI accordingly.
  • distance holds the cumulative distance of the run.
  • locationList is an array to hold all the CLLocation objects collected during the run.

Add the following after viewDidLoad():

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  timer?.invalidate()
  locationManager.stopUpdatingLocation()
}

This ensures that location updates, a big battery consumer, as well as the timer are stopped when the user navigates away from the view.

Add the following two methods:

func eachSecond() {
  seconds += 1
  updateDisplay()
}

private func updateDisplay() {
  let formattedDistance = FormatDisplay.distance(distance)
  let formattedTime = FormatDisplay.time(seconds)
  let formattedPace = FormatDisplay.pace(distance: distance,
                                         seconds: seconds,
                                         outputUnit: UnitSpeed.minutesPerMile)

  distanceLabel.text = "Distance:  \(formattedDistance)"
  timeLabel.text = "Time:  \(formattedTime)"
  paceLabel.text = "Pace:  \(formattedPace)"
}

eachSecond() will be called once per second by a Timer that you will set up shortly.

updateDisplay() uses the fancy formatting capabilities you built in FormatDisplay.swift to update the UI with the details of the current run.

Core Location reports location updates via its CLLocationManagerDelegate. Add this now in an extension at the end of the file:

extension NewRunViewController: CLLocationManagerDelegate {

  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    for newLocation in locations {
      let howRecent = newLocation.timestamp.timeIntervalSinceNow
      guard newLocation.horizontalAccuracy < 20 && abs(howRecent) < 10 else { continue }

      if let lastLocation = locationList.last {
        let delta = newLocation.distance(from: lastLocation)
        distance = distance + Measurement(value: delta, unit: UnitLength.meters)
      }

      locationList.append(newLocation)
    }
  }
}

This delegate method will be called each time Core Location updates the user's location, providing an array of CLLocation objects. Usually this array contains only one object but, if there are more, they are ordered by time with the most recent location last.

A CLLocation contains some great information, including the latitude, longitude, and timestamp of the reading.

Before blindly accepting the reading, it’s worth checking the accuracy of the data. If the device isn’t confident it has a reading within 20 meters of the user’s actual location, it’s best to keep it out of your dataset. It's also important to ensure that the data is recent.

Note: This check is especially important at the start of the run when the device first starts narrowing down the general area of the user. At that stage, it’s likely to update with some inaccurate data for the first few points.

If the CLLocation passes the checks, the distance between it and the most recent saved point is added to the cumulative distance of the run. distance(from:) is very convenient here, taking into account some surprisingly difficult math involving the Earth’s curvature, and returning a distance in meters.

Lastly, the location object itself is added to a growing array of locations.

Now add the following method back in NewRunViewController (not the extension):

private func startLocationUpdates() {
  locationManager.delegate = self
  locationManager.activityType = .fitness
  locationManager.distanceFilter = 10
  locationManager.startUpdatingLocation()
}

You make this class the delegate for Core Location so that you can receive and process location updates.

The activityType parameter is made specifically for an app like this. It helps the device to intelligently save power throughout the user’s run, such as when they stop to cross a road.

Lastly, you set a distanceFilter of 10 meters. As opposed to the activityType, this parameter doesn’t affect battery life. The activityType is for readings and the distanceFilter is for the reporting of readings.

As you’ll see after doing a test run later, the location readings can deviate a little from a straight line. A higher distanceFilter could reduce the zigging and zagging and, thus, give you a more accurate line. Unfortunately, a filter that's too high will pixelate your readings. That’s why 10 meters is a good balance.

Finally, you tell Core Location to start getting location updates!

To actually begin the run, add these lines to the end of startRun():

seconds = 0
distance = Measurement(value: 0, unit: UnitLength.meters)
locationList.removeAll()
updateDisplay()
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
  self.eachSecond()
}
startLocationUpdates()

This resets all of the values to be updated during the run to their initial state, starts the Timer that will fire each second, and begins collecting location updates.

Saving the Run

At some point, your user will get tired and stop running. You have the UI in place to do that, but you also need to save the run's data or your user will be very unhappy to see all of that effort go to waste.

Add the following method to NewRunViewController in NewRunViewController.swift:

private func saveRun() {
  let newRun = Run(context: CoreDataStack.context)
  newRun.distance = distance.value
  newRun.duration = Int16(seconds)
  newRun.timestamp = Date()

  for location in locationList {
    let locationObject = Location(context: CoreDataStack.context)
    locationObject.timestamp = location.timestamp
    locationObject.latitude = location.coordinate.latitude
    locationObject.longitude = location.coordinate.longitude
    newRun.addToLocations(locationObject)
  }

  CoreDataStack.saveContext()

  run = newRun
}

If you've used Core Data prior to Swift 3, you will notice the power and simplicity of iOS 10's Core Data support. You create a new Run object and initialize its values as with any other Swift object. You then create a Location object for each CLLocation you recorded, saving only the relevant data. Finally, you add each of these new Locations to the Run using the automatically generated addToLocations(_:).

When the user ends the run, you want to stop tracking location. Add the following to the end of stopRun():

locationManager.stopUpdatingLocation()

Finally, in stopTapped() locate the UIAlertAction titled "Save" and add a call to self.saveRun() so that it looks like this:

alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
  self.stopRun()
  self.saveRun() // ADD THIS LINE!
  self.performSegue(withIdentifier: .details, sender: nil)
})

Send the Simulator On a Run

While you should always test your app on a real device before releasing it, you don't have to go for a run each time you want to test MoonRunner.

Build and run in the simulator. Before pressing the New Run button, select Debug\Location\City Run from the Simulator menu.

Simulate a city run

Now, press New Run, then press Start and verify that the simulator begins its workout.

app like runkeeper - First run by the simulator

Map It Out

After all of that hard work, it's time to show the user where they went and how well they did.

Open RunDetailsViewController.swift and replace configureView() with:

private func configureView() {
  let distance = Measurement(value: run.distance, unit: UnitLength.meters)
  let seconds = Int(run.duration)
  let formattedDistance = FormatDisplay.distance(distance)
  let formattedDate = FormatDisplay.date(run.timestamp)
  let formattedTime = FormatDisplay.time(seconds)
  let formattedPace = FormatDisplay.pace(distance: distance,
                                         seconds: seconds,
                                         outputUnit: UnitSpeed.minutesPerMile)

  distanceLabel.text = "Distance:  \(formattedDistance)"
  dateLabel.text = formattedDate
  timeLabel.text = "Time:  \(formattedTime)"
  paceLabel.text = "Pace:  \(formattedPace)"
}

This formats all of the details of the run and sets them to display.

Rendering the run on the map requires a bit more work. There are three steps to this:

  1. Set the region for the map so that only the area of the run is shown, not the entire world.
  2. Provide a delegate method that styles the map overlay properly.
  3. Create an MKOverlay that describes the line to be drawn.

Add the following method:

private func mapRegion() -> MKCoordinateRegion? {
  guard
    let locations = run.locations,
    locations.count > 0
  else {
    return nil
  }

  let latitudes = locations.map { location -> Double in
    let location = location as! Location
    return location.latitude
  }

  let longitudes = locations.map { location -> Double in
    let location = location as! Location
    return location.longitude
  }

  let maxLat = latitudes.max()!
  let minLat = latitudes.min()!
  let maxLong = longitudes.max()!
  let minLong = longitudes.min()!

  let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2,
                                      longitude: (minLong + maxLong) / 2)
  let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * 1.3,
                              longitudeDelta: (maxLong - minLong) * 1.3)
  return MKCoordinateRegion(center: center, span: span)
}

An MKCoordinateRegion represents the display region for the map. You define it by supplying a center point and a span that defines horizontal and vertical ranges. It's important to add a little padding so that map edges don't crowd the route.

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

extension RunDetailsViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    guard let polyline = overlay as? MKPolyline else {
      return MKOverlayRenderer(overlay: overlay)
    }
    let renderer = MKPolylineRenderer(polyline: polyline)
    renderer.strokeColor = .black
    renderer.lineWidth = 3
    return renderer
  }
}

Each time MapKit wants to display an overlay, it asks its delegate for something to render that overlay. For now, if the overlay is an MKPolyine (a collection of line segments), you return MapKit's MKPolylineRenderer configured to draw in black. You'll make this more colorful shortly.

Finally, you need to create your overlay. Add the following method to RunDetailsViewController (not the extension):

private func polyLine() -> MKPolyline {
  guard let locations = run.locations else {
    return MKPolyline()
  }

  let coords: [CLLocationCoordinate2D] = locations.map { location in
    let location = location as! Location
    return CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
  }
  return MKPolyline(coordinates: coords, count: coords.count)
}

Here, you turn each recorded location from the run into a CLLocationCoordinate2D as required by MKPolyline.

Now it's time to glue all these bits together. Add the following method:

private func loadMap() {
  guard
    let locations = run.locations,
    locations.count > 0,
    let region = mapRegion()
  else {
      let alert = UIAlertController(title: "Error",
                                    message: "Sorry, this run has no locations saved",
                                    preferredStyle: .alert)
      alert.addAction(UIAlertAction(title: "OK", style: .cancel))
      present(alert, animated: true)
      return
  }

  mapView.setRegion(region, animated: true)
  mapView.add(polyLine())
}

Here, you make sure there is something to draw. Then you set the map region and add the overlay.

Now, add the following at the end of configureView().

loadMap()

Build and run. When you save your completed run, you should now see a map of the run!

app like runkeeper - First completed run map

Note: In the console, you will likely see some error messages that look like one or more of the following:
ERROR /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1230.34.9.30.27/GeoGL/GeoGL/GLCoreContext.cpp 1763: InfoLog SolidRibbonShader:
ERROR /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1230.34.9.30.27/GeoGL/GeoGL/GLCoreContext.cpp 1764: WARNING: Output of vertex shader 'v_gradient' not read by fragment shader
/BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

On the simulator, this is normal. The messages come from MapKit and do not indicate an error on your part.

Introducing Color

The app is pretty awesome already, but the map could be much better if you used color to highlight differences in pace.

Add a new Cocoa Touch Class file, and name it MulticolorPolyline. Make it a subclass of MKPolyline.

Open MulticolorPolyline.swift and import MapKit:

import MapKit

Add a color property to the class:

var color = UIColor.black

Wow, that was easy! :] Now, for the more difficult stuff, open RunDetailsViewController.swift and add the following method:

private func segmentColor(speed: Double, midSpeed: Double, slowestSpeed: Double, fastestSpeed: Double) -> UIColor {
  enum BaseColors {
    static let r_red: CGFloat = 1
    static let r_green: CGFloat = 20 / 255
    static let r_blue: CGFloat = 44 / 255

    static let y_red: CGFloat = 1
    static let y_green: CGFloat = 215 / 255
    static let y_blue: CGFloat = 0

    static let g_red: CGFloat = 0
    static let g_green: CGFloat = 146 / 255
    static let g_blue: CGFloat = 78 / 255
  }

  let red, green, blue: CGFloat

  if speed < midSpeed {
    let ratio = CGFloat((speed - slowestSpeed) / (midSpeed - slowestSpeed))
    red = BaseColors.r_red + ratio * (BaseColors.y_red - BaseColors.r_red)
    green = BaseColors.r_green + ratio * (BaseColors.y_green - BaseColors.r_green)
    blue = BaseColors.r_blue + ratio * (BaseColors.y_blue - BaseColors.r_blue)
  } else {
    let ratio = CGFloat((speed - midSpeed) / (fastestSpeed - midSpeed))
    red = BaseColors.y_red + ratio * (BaseColors.g_red - BaseColors.y_red)
    green = BaseColors.y_green + ratio * (BaseColors.g_green - BaseColors.y_green)
    blue = BaseColors.y_blue + ratio * (BaseColors.g_blue - BaseColors.y_blue)
  }

  return UIColor(red: red, green: green, blue: blue, alpha: 1)
}

Here, you define the recipes for your base red, yellow and green colors. Then you create a blended color based on where the specified speed falls in the range from slowest to fastest.

22_color_codes

Replace your polyLine() implementation with the following:

private func polyLine() -> [MulticolorPolyline] {

  // 1
  let locations = run.locations?.array as! [Location]
  var coordinates: [(CLLocation, CLLocation)] = []
  var speeds: [Double] = []
  var minSpeed = Double.greatestFiniteMagnitude
  var maxSpeed = 0.0

  // 2
  for (first, second) in zip(locations, locations.dropFirst()) {
    let start = CLLocation(latitude: first.latitude, longitude: first.longitude)
    let end = CLLocation(latitude: second.latitude, longitude: second.longitude)
    coordinates.append((start, end))

    //3
    let distance = end.distance(from: start)
    let time = second.timestamp!.timeIntervalSince(first.timestamp! as Date)
    let speed = time > 0 ? distance / time : 0
    speeds.append(speed)
    minSpeed = min(minSpeed, speed)
    maxSpeed = max(maxSpeed, speed)
  }

  //4
  let midSpeed = speeds.reduce(0, +) / Double(speeds.count)

  //5
  var segments: [MulticolorPolyline] = []
  for ((start, end), speed) in zip(coordinates, speeds) {
    let coords = [start.coordinate, end.coordinate]
    let segment = MulticolorPolyline(coordinates: coords, count: 2)
    segment.color = segmentColor(speed: speed,
                                 midSpeed: midSpeed,
                                 slowestSpeed: minSpeed,
                                 fastestSpeed: maxSpeed)
    segments.append(segment)
  }
  return segments
}

Here's what the new version does:

  1. A polyline is made up of line segments, each marked by its endpoints. Prepare to collect coordinate pairs to describe each segment and the speed for each segment.
  2. Convert each endpoint into a CLLocation object and save them in pairs.
  3. Calculate the speed for the segment. Note that Core Location occasionally returns more than one update with the same timestamp so guard against division by 0. Save the speed and update the minimum and maximum speeds.
  4. Calculate the average speed for the run.
  5. Use the previously prepared coordinate pairs to create a new MulticolorPolyline. Set its color.

You will now see an error on the line mapView.add(polyLine()) in loadMap(). Replace that line with:

mapView.addOverlays(polyLine())

Now replace mapView(_:rendererFor:) in the MKMapViewDelegate extension with:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
  guard let polyline = overlay as? MulticolorPolyline else {
    return MKOverlayRenderer(overlay: overlay)
  }
  let renderer = MKPolylineRenderer(polyline: polyline)
  renderer.strokeColor = polyline.color
  renderer.lineWidth = 3
  return renderer
}

This is very similar to the previous version. It now expects each overlay to be a MulticolorPolyline and uses the embedded color to render the segment.

Build and run! Let the simulator take a quick jog and then check out the fancy colored map at the end!

app like runkeeper - color-coded map

How About Some Breadcrumbs?

The post-run map is stunning, but how about having a map during the run?

The storyboard is set up using UIStackViews to make it easy to add one!

First, open NewRunViewController.swift and import MapKit:

import MapKit

Now, open Main.storyboard and find the New Run View Controller Scene. Be sure the Document Outline is visible. If not, press the button outlined in red below:

document outline

Drag a UIView into the Document Outline and place it between the Top Stack View and the Button Stack View. Make sure it appears between them and not inside one of them. Double-click it and rename it to Map Container View.

app like runkeeper - Add map container view

In the Attributes Inspector, check Hidden under Drawing.

set map container view hidden

In the Document Outline, Control-drag from the Map Container View to the Top Stack View and select Equal Widths from the pop-up.

Equal widths constraint

Drag an MKMapView into the Map Container View. Press the Add New Constraints button (A.K.A the "Tie Fighter button") and set all 4 constraints to 0. Make sure Constrain to margins is not checked. Click Add 4 Constraints.

add mapview constraints

With Map View selected in the Document Outline, open the Size Inspector (View\Utilities\Show Size Inspector). Double-click on the constraint Bottom Space to: Superview.

Bottom constraint

Change the priority to High (750).

priority 750

In the Document Outline, Control-drag from Map View to New Run View Controller and select delegate.

connect the delegate

Open the Assistant Editor, ensure it is showing NewRunViewController.swift and Control-drag from the Map View to create an outlet named mapView. Control-drag from Map Container View and create an outlet called mapContainerView.

connect map outlets

Close the Assistant Editor and open NewRunViewController.swift.

Add the following to the top of startRun():

mapContainerView.isHidden = false
mapView.removeOverlays(mapView.overlays)

To the top of stopRun() add the following:

mapContainerView.isHidden = true

Now, you need an MKMapViewDelegate to provide a renderer for the line. Add the following implementation in an extension at the bottom of the file:

extension NewRunViewController: MKMapViewDelegate {
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    guard let polyline = overlay as? MKPolyline else {
      return MKOverlayRenderer(overlay: overlay)
    }
    let renderer = MKPolylineRenderer(polyline: polyline)
    renderer.strokeColor = .blue
    renderer.lineWidth = 3
    return renderer
  }
}

This is just like the delegate you wrote in RunDetailsViewController.swift except that the line is blue.

Finally, you just need to add the line segment overlay and update the map region to keep it focused on the area of your run. Add the following to locationManager(_:didUpdateLocations:) after the line distance = distance + Measurement(value: delta, unit: UnitLength.meters):

let coordinates = [lastLocation.coordinate, newLocation.coordinate]
mapView.add(MKPolyline(coordinates: coordinates, count: 2))
let region = MKCoordinateRegionMakeWithDistance(newLocation.coordinate, 500, 500)
mapView.setRegion(region, animated: true)

Build and run and start a new run. You will see your new map updating in real time!

in-run map

Where To Go From Here?

Click here to download the the project up to this point.

You may have noticed that the user's pace always displays in "min/mi", even if your locale causes the distance to be displayed in meters (or km). Find a way to use the locale to choose between .minutesPerMile and .minutesPerKilometer in the places you call FormatDisplay.pace(distance:seconds:outputUnit:).

Continue to part two of the How to Make an App Like Runkeeper tutorial where you will introduce an achievement badge system.

As always, I look forward to your comments and questions! :]

The post How To Make an App Like Runkeeper: Part 1 appeared first on Ray Wenderlich.


Viewing all articles
Browse latest Browse all 4370

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>