With iOS 8, Apple introduced the new HealthKit API and corresponding Health App. Meanwhile, apps in the Health & Fitness category are hugely popular on the App Store.
This tutorial will show you how to make an app like RunKeeper, a GPS-based app to help you track your runs. Your new app, called MoonRunner, takes things to the next level with badges based on planets and moons in our Solar System!
Along the way, you’ll build all the features needed in a a motivational run-tracking app:
- Core Location to track your route.
- Shows a map during your run, with a constantly-updating line denoting your path.
- Continually reports your average pace as you run.
- Awards badges for running various distances.
- Shows a map of your route when you’re finished.
You should already be familiar with the basics of Storyboards and Core Data before continuing. 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, and the second segment introduces the badge system.
Getting Started
Begin by downloading the starter project download for the tutorial. Unzip it and open the project file, called MoonRunner.xcodeproj.
Build and run your project to check out what you’ll be starting with. The app will have a pretty simple flow:
- A Home screen that presents a few app navigation options (three buttons).
- A New Run screen where the user begins and records a new run (be sure to check the hidden
UILabels
). - A Run Details screen that shows the results, including the color-coded map.
Even a marathon begins with just a single CLLocationManager
update. It’s time to start tracking some movement!
Starting the Run
You need to make a few important project-level changes first. Click on the MoonRunner project at the top of the project navigator. Select the MoonRunner target and then the Capabilities tab. Open up Background Modes, turn on the switch for this section on the right and then tick Location Updates. This will allow the app to update the location even if the user presses the home button to take a call, browse the net or find out where the nearest Starbucks is! Neat!
Next, select the Info tab and open up Custom iOS Target Properties. Add these two lines to the plist:
NSLocationWhenInUseUsageDescription |
String | MoonRunner wants to track your run |
NSLocationAlwaysUsageDescription |
String | MoonRunner wants to track your run |
iOS will display the text in these two lines when it asks the user whether they want to allow the app to access the location data.
Now, back to the code. Open NewRunViewController.swift and add the following imports:
import CoreLocation import HealthKit |
You’ll need Core Location to access all the location-based APIs, and Health Kit to access units, quantities and conversion methods.
Then, at the end of the file, add a class extension to conform to the CLLocationManagerDelegate
protocol:
// MARK: - CLLocationManagerDelegate extension NewRunViewController: CLLocationManagerDelegate { } |
You’ll implement some delegate methods later, to be notified on location updates.
Next, add several new properties to the class:
var seconds = 0.0 var distance = 0.0 lazy var locationManager: CLLocationManager = { var _locationManager = CLLocationManager() _locationManager.delegate = self _locationManager.desiredAccuracy = kCLLocationAccuracyBest _locationManager.activityType = .Fitness // Movement threshold for new events _locationManager.distanceFilter = 10.0 return _locationManager }() lazy var locations = [CLLocation]() lazy var timer = NSTimer() |
Note the syntax to lazily instantiate variables in Swift. Pretty straightforward, huh? Here is what these properties are here for:
seconds
tracks the duration of the run, in seconds.distance
holds the cumulative distance of the run, in meters.locationManager
is the object you’ll tell to start or stop reading the user’s location.locations
is an array to hold all theLocation
objects that will roll in.timer
will fire each second and update the UI accordingly.
Let’s pause for a second to talk about your CLLocationManager
and its configuration.
Once lazily instantiated, you set the CLLocationManagerDelegate
to your NewRunViewController
.
You then feed it a desiredAccuracy
of kCLLocationAccuracyBest
. Since you’re tracking a run, you want the best and most precise location readings, which also use more battery power.
The activityType
parameter is made specifically for an app like this. It intelligently helps the device to save some power throughout a user’s run, say if they stop to cross a road.
Lastly, you set a distanceFilter
of 10 meters. As opposed to the desiredAccuracy
, this parameter doesn’t affect battery life. The desiredAccuracy
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 be a little zigged or zagged away from a straight line.
A higher distanceFilter
could reduce the zigging and zagging and thus give you a more accurate line. Unfortunately, too high a filter would pixelate your readings. That’s why 10 meters is a good balance.
Next, add this line at the end of viewWillAppear(_:)
:
locationManager.requestAlwaysAuthorization() |
This iOS 8 only method will request the location usage authorization from your users. If you want your app to run on iOS versions prior to 8, you will need to test the availability of this method before calling it.
Now add the following method to your implementation:
override func viewWillDisappear(animated: Bool) { super.viewWillDisappear(animated) timer.invalidate() } |
With this method, the timer is stopped when the user navigates away from the view.
Add the following method:
func eachSecond(timer: NSTimer) { seconds++ let secondsQuantity = HKQuantity(unit: HKUnit.secondUnit(), doubleValue: seconds) timeLabel.text = "Time: " + secondsQuantity.description let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: distance) distanceLabel.text = "Distance: " + distanceQuantity.description let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit()) let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: seconds / distance) paceLabel.text = "Pace: " + paceQuantity.description } |
This is the method that will be called every second, by using an NSTimer
(which will be set up shortly). Each time this method is called, you increment the second count and update each of the statistics labels accordingly.
Here’s the final helper method to add to the class before starting your run:
func startLocationUpdates() { // Here, the location manager will be lazily instantiated locationManager.startUpdatingLocation() } |
Here, you tell the manager to start getting location updates! Time to hit the pavement. Wait! Are you lacing up your shoes? Not that kind of run!
To actually begin the run, add these lines to the end of startPressed(_:)
:
seconds = 0.0 distance = 0.0 locations.removeAll(keepCapacity: false) timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: "eachSecond:", userInfo: nil, repeats: true) startLocationUpdates() |
Here, you’re resetting all the fields that will update continually throughout the run and then starting up the timer and location updates.
Build and run. If you start a new run you will see the time label increment.
However, the distance and pace labels are not updated because you are not tracking the location yet. So, let’s do this now!
Recording the Run
You’ve created the CLLocationManager
, but now you need to get updates from it. That is done through its delegate
. Open NewRunViewController.swift once again and add the following method to the class extension conforming to CLLocationManagerDelegate
:
func locationManager(manager: CLLocationManager!, didUpdateLocations locations: [AnyObject]!) { for location in locations as! [CLLocation] { if location.horizontalAccuracy < 20 { //update distance if self.locations.count > 0 { distance += location.distanceFromLocation(self.locations.last) } //save location self.locations.append(location) } } } |
This delegate method will be called each time there are new location updates to provide the app. Usually, the locations
array only contains one object, but if there are more, they are ordered by time with the most recent location last.
A CLLocation
contains some great information. Namely the latitude and longitude, along with the timestamp of the reading.
But before blindly accepting the reading, it’s worth a horizontalAccuracy
check. 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. 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 check, then you add the distance between it and the most recent point to the cumulative distance of the run. The distanceFromLocation(_:)
method is very convenient here, taking into account all sorts of surprisingly-difficult math involving the Earth’s curvature.
Finally, you append the location object itself to a growing array of locations.
CLLocation
object also contains information on altitude, with a corresponding verticalAccuracy
value. As every runner knows, hills can be a game changer on any run, and altitude can affect the amount of oxygen available. A challenge to you, then, is to think of a way to incorporate this data into the app.
Send the Simulator on a Run
As much as I hope that this tutorial and the apps you build encourage more enthusiasm for fitness, the build and run phase does not need to be taken that literally while developing it!
You don’t need to lace up and head out the door either, for there’s a way to get the simulator to pretend it’s running!
Build and run in the simulator, and select Debug\Location\City Run to have the simulator start generating mock data:
Of course, this is much easier and less exhausting than taking a short run to test this — or any other — location-based app.
However, I recommend eventually doing a true beta test with a device. Doing so gives you the chance to fine-tune the location manager parameters and assess the quality of location data you can really get.
Thorough testing could help instill a healthy habit, too. :]
Saving the Run
At some point, despite that voice of motivation inside you that tells you to keep going (mine sounds like Gunnery Sergeant Hartman in Full Metal Jacket), there comes a time to end the run. You already arranged for the UI to accept this input, and now it’s time to process that data.
Add this method to NewRunViewController.swift:
func saveRun() { // 1 let savedRun = NSEntityDescription.insertNewObjectForEntityForName("Run", inManagedObjectContext: managedObjectContext!) as! Run savedRun.distance = distance savedRun.duration = seconds savedRun.timestamp = NSDate() // 2 var savedLocations = [Location]() for location in locations { let savedLocation = NSEntityDescription.insertNewObjectForEntityForName("Location", inManagedObjectContext: managedObjectContext!) as! Location savedLocation.timestamp = location.timestamp savedLocation.latitude = location.coordinate.latitude savedLocation.longitude = location.coordinate.longitude savedLocations.append(savedLocation) } savedRun.locations = NSOrderedSet(array: savedLocations) run = savedRun // 3 var error: NSError? let success = managedObjectContext!.save(&error) if !success { println("Could not save the run!") } } |
So what’s happening here? If you’ve done a simple Core Data flow before, this should look like a familiar way to save new objects:
1. You create a new Run
object, and give it the cumulative distance and duration values as well as assign it a timestamp.
2. Each of the CLLocation
objects recorded during the run is trimmed down to a new Location
object and saved. Then you link the locations to the Run
3. You then save your NSManagedObjectContext
Finally, you’ll need to call this method when the user stops the run and then chooses to save it. Find actionSheet(_:clickedButtonAtIndex:)
and add the following line to the top the if buttonIndex == 1
block, above the call to performSegueWithIdentifier(_:sender:)
:
saveRun() |
Build and run. You are now able to record a run and save it.
However, the detail view of the run is mostly empty. That’s coming up next!
Revealing the Map
Now it’s time to show the map post-run stats. Open DetailViewController.swift and import HealthKit
:
import HealthKit |
Next, find configureView()
and add the following code to the method:
let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: run.distance.doubleValue) distanceLabel.text = "Distance: " + distanceQuantity.description let dateFormatter = NSDateFormatter() dateFormatter.dateStyle = .MediumStyle dateLabel.text = dateFormatter.stringFromDate(run.timestamp) let secondsQuantity = HKQuantity(unit: HKUnit.secondUnit(), doubleValue: run.duration.doubleValue) timeLabel.text = "Time: " + secondsQuantity.description let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit()) let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: run.duration.doubleValue / run.distance.doubleValue) paceLabel.text = "Pace: " + paceQuantity.description |
This sets up the details of the run into the three labels on the screen.
Rendering the map will require just a little more detail. There are three basic steps to it:
- First, the region needs to be set so that only the run is shown and not the entire world!
- Then the line drawn over the top to indicate where the run went needs to be created.
- Finally, you’ll add some styling to the line to indicate the speed for particular sections of the run.
Start by adding the following method to the class:
func mapRegion() -> MKCoordinateRegion { let initialLoc = run.locations.firstObject as! Location var minLat = initialLoc.latitude.doubleValue var minLng = initialLoc.longitude.doubleValue var maxLat = minLat var maxLng = minLng let locations = run.locations.array as! [Location] for location in locations { minLat = min(minLat, location.latitude.doubleValue) minLng = min(minLng, location.longitude.doubleValue) maxLat = max(maxLat, location.latitude.doubleValue) maxLng = max(maxLng, location.longitude.doubleValue) } return MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: (minLat + maxLat)/2, longitude: (minLng + maxLng)/2), span: MKCoordinateSpan(latitudeDelta: (maxLat - minLat)*1.1, longitudeDelta: (maxLng - minLng)*1.1)) } |
An MKCoordinateRegion
represents the display region for the map, and you define it by supplying a center
point and a span
that defines horizontal and vertical ranges.
For example, my jog may be quite zoomed in around my short route, while my more athletic friend’s map will appear more zoomed out to cover all the distance she traveled. It’s important to also account for a little padding, so that the route doesn’t crowd all the way to the edge of the map.
Next, add the following method:
func mapView(mapView: MKMapView!, rendererForOverlay overlay: MKOverlay!) -> MKOverlayRenderer! { if !overlay.isKindOfClass(MKPolyline) { return nil } let polyline = overlay as! MKPolyline let renderer = MKPolylineRenderer(polyline: polyline) renderer.strokeColor = UIColor.blackColor() renderer.lineWidth = 3 return renderer } |
This method says that whenever the map comes across a request to add an overlay, it should check if it’s an MKPolyline
. If so, it should use a renderer that will make a black line. You’ll spice this up shortly. An overlay is something that is drawn on top of a map view. A polyline is such an overlay and represents a line drawn from a series of location points.
Lastly, you need to define the coordinates for the polyline. Add the following method:
func polyline() -> MKPolyline { var coords = [CLLocationCoordinate2D]() let locations = run.locations.array as! [Location] for location in locations { coords.append(CLLocationCoordinate2D(latitude: location.latitude.doubleValue, longitude: location.longitude.doubleValue)) } return MKPolyline(coordinates: &coords, count: run.locations.count) } |
Here, you shove the data from the Location
objects into an array of CLLocationCoordinate2D
, the required format for polylines.
Now, it’s time to put these three together! Add the following method:
func loadMap() { if run.locations.count > 0 { mapView.hidden = false // Set the map bounds mapView.region = mapRegion() // Make the line(s!) on the map mapView.addOverlay(polyline()) } else { // No locations were found! mapView.hidden = true UIAlertView(title: "Error", message: "Sorry, this run has no locations saved", delegate:nil, cancelButtonTitle: "OK").show() } } |
Here, you make sure that there are points to draw, set the map region as defined earlier, and add the polyline overlay.
Finally, add the following code at the end of configureView()
:
loadMap() |
And now build and run!. You should now see a map after your simulator is done with its workout.
Finding the Right Color
The app is pretty cool as-is, but one way you can help your users train even smarter is to show them how fast or slow they ran at each leg of the run. That way, they can identify areas where they are most at risk of straying from an even pace.
To do this, you’ll extend the polyline class you’ve already been using to add color support.
In Xcode, navigate to File\New\File…, and select iOS\Source\Swift File. Call the file MulticolorPolylineSegment
and save it to disk. When the file opens in the editor, replace its contents with the following:
import UIKit import MapKit class MulticolorPolylineSegment: MKPolyline { var color: UIColor? } |
This special, custom, polyline will be used to render each segment of the run. The color is going to denote the speed and so the color of each segment is stored here on the polyline. Other than that, it’s the same as an MKPolyline
. There will be one of these objects for each segment connecting two locations.
Next, you need to figure out how to assign the right color to the right polyline segment. Add the following class method to your MulticolorPolylineSegment
class:
private class func allSpeeds(forLocations locations: [Location]) -> (speeds: [Double], minSpeed: Double, maxSpeed: Double) { // Make Array of all speeds. Find slowest and fastest var speeds = [Double]() var minSpeed = DBL_MAX var maxSpeed = 0.0 for i in 1..<locations.count { let l1 = locations[i-1] let l2 = locations[i] let cl1 = CLLocation(latitude: l1.latitude.doubleValue, longitude: l1.longitude.doubleValue) let cl2 = CLLocation(latitude: l2.latitude.doubleValue, longitude: l2.longitude.doubleValue) let distance = cl2.distanceFromLocation(cl1) let time = l2.timestamp.timeIntervalSinceDate(l1.timestamp) let speed = distance/time minSpeed = min(minSpeed, speed) maxSpeed = max(maxSpeed, speed) speeds.append(speed) } return (speeds, minSpeed, maxSpeed) } |
This method returns the array of speed values for each sequential pair of locations, along with the minimum and maximum speeds. To return multiple values, you wrap them in a tuple.
The first thing you’ll notice is a loop through all the locations from the input. You have to convert each Location
to a CLLocation
so you can use distanceFromLocation(_:)
.
Remember basic physics: distance divided by time equals speed. Each location after the first is compared to the one before it, and by the end of the loop you have a complete collection of all the changes in speed throughout the run.
This method is private, and can only be accessed from within the class. Next, add the following class method that will act as the public interface:
class func colorSegments(forLocations locations: [Location]) -> [MulticolorPolylineSegment] { var colorSegments = [MulticolorPolylineSegment]() // RGB for Red (slowest) let red = (r: 1.0, g: 20.0 / 255.0, b: 44.0 / 255.0) // RGB for Yellow (middle) let yellow = (r: 1.0, g: 215.0 / 255.0, b: 0.0) // RGB for Green (fastest) let green = (r: 0.0, g: 146.0 / 255.0, b: 78.0 / 255.0) let (speeds, minSpeed, maxSpeed) = allSpeeds(forLocations: locations) // now knowing the slowest+fastest, we can get mean too let meanSpeed = (minSpeed + maxSpeed)/2 return colorSegments } |
Here you define the three colors you’ll use for slow, medium and fast polyline segments.
Each color, in turn, has its own RGB components. The slowest components will be completely red, the middle will be yellow, and the fastest will be green. Everything else will be a blend of the two nearest colors, so the end result could be quite colorful.
Notice how you also retrieve and decompose the return tuple from allSpeeds(forLocations:)
and then use minSpeed
and maxSpeed
to compute a meanSpeed
.
Finally, add the following to the end of the method, before the return statement:
for i in 1..<locations.count { let l1 = locations[i-1] let l2 = locations[i] var coords = [CLLocationCoordinate2D]() coords.append(CLLocationCoordinate2D(latitude: l1.latitude.doubleValue, longitude: l1.longitude.doubleValue)) coords.append(CLLocationCoordinate2D(latitude: l2.latitude.doubleValue, longitude: l2.longitude.doubleValue)) let speed = speeds[i-1] var color = UIColor.blackColor() if speed < minSpeed { // Between Red & Yellow let ratio = (speed - minSpeed) / (meanSpeed - minSpeed) let r = CGFloat(red.r + ratio * (yellow.r - red.r)) let g = CGFloat(red.g + ratio * (yellow.g - red.g)) let b = CGFloat(red.r + ratio * (yellow.r - red.r)) color = UIColor(red: r, green: g, blue: b, alpha: 1) } else { // Between Yellow & Green let ratio = (speed - meanSpeed) / (maxSpeed - meanSpeed) let r = CGFloat(yellow.r + ratio * (green.r - yellow.r)) let g = CGFloat(yellow.g + ratio * (green.g - yellow.g)) let b = CGFloat(yellow.b + ratio * (green.b - yellow.b)) color = UIColor(red: r, green: g, blue: b, alpha: 1) } let segment = MulticolorPolylineSegment(coordinates: &coords, count: coords.count) segment.color = color colorSegments.append(segment) } |
In this loop, you determine the value of each pre-calculated speed, relative to the full range of speeds. This ratio then determines the UIColor
to apply to the segment.
Next, you construct a new MulticolorPolylineSegment
with the two coordinates and the blended color.
Finally, you collect all the multicolored segments together, and you’re almost ready to render!
Applying the Multicolored Segments
Repurposing the detail view controller to use your new multicolor polyline is actually quite simple! Open DetailViewController.swift, find loadMap()
, and replace the following line:
mapView.addOverlay(polyline()) |
with the following:
let colorSegments = MulticolorPolylineSegment.colorSegments(forLocations: run.locations.array as! [Location]) mapView.addOverlays(colorSegments) |
This creates the array of segments and adds all the overlays to the map.
Lastly, you need to prepare your polyline renderer to pay attention to the specific color of each segment. So replace your current implementation of mapView(_:rendererForOverlay:)
with the following:
func mapView(mapView: MKMapView!, rendererForOverlay overlay: MKOverlay!) -> MKOverlayRenderer! { if !overlay.isKindOfClass(MulticolorPolylineSegment) { return nil } let polyline = overlay as! MulticolorPolylineSegment let renderer = MKPolylineRenderer(polyline: polyline) renderer.strokeColor = polyline.color renderer.lineWidth = 3 return renderer } |
This is very similar to what you had before, but now the specific color of each segment renders individually.
Alright! Now you’re all set to build & run, let the simulator go on a little jog, and check out the fancy multi-colored map afterward!
Leaving a Trail Of Breadcrumbs
That post-run map is stunning, but how about one during the run?
Open Main.storyboard
and find the New Run Scene. Drag in a new MapKit View and size it roughly so it fits between the “Ready to Launch” label and the Start button.
Then, be sure to add the four Auto Layout constraints displayed in the screenshots:
Top Space
to Ready to Launch? label of 20 points.Bottom Space
to Start! button of 20 points.Trailing Space
andLeading Space
of 0 points to the Superview.
Then open NewRunViewController.swift and add the MapKit
import:
import MapKit |
Next, add an outlet for the map to the class:
@IBOutlet weak var mapView: MKMapView! |
Then add this line to the end of viewWillAppear(_:)
:
mapView.hidden = true |
This makes sure that the map is hidden at first. Now add this line to the end of startPressed(_:)
:
mapView.hidden = false |
This makes the map appear when the run starts.
The trail is going to be another polyline, which means you’ll need to implement a map delegate method. Add the following class extension to the end of the file:
// MARK: - MKMapViewDelegate extension NewRunViewController: MKMapViewDelegate { func mapView(mapView: MKMapView!, rendererForOverlay overlay: MKOverlay!) -> MKOverlayRenderer! { if !overlay.isKindOfClass(MKPolyline) { return nil } let polyline = overlay as! MKPolyline let renderer = MKPolylineRenderer(polyline: polyline) renderer.strokeColor = UIColor.blueColor() renderer.lineWidth = 3 return renderer } } |
This version is similar to the one for the run details screen, except that the stroke color is always blue here.
Next, you need to write the code to update the map region and draw the polyline every time a valid location is found. Find your current implementation of locationManager(_:didUpdateLocations:)
and update it to this:
func locationManager(manager: CLLocationManager!, didUpdateLocations locations: [AnyObject]!) { for location in locations as! [CLLocation] { let howRecent = location.timestamp.timeIntervalSinceNow if abs(howRecent) < 10 && location.horizontalAccuracy < 20 { //update distance if self.locations.count > 0 { distance += location.distanceFromLocation(self.locations.last) var coords = [CLLocationCoordinate2D]() coords.append(self.locations.last!.coordinate) coords.append(location.coordinate) let region = MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500) mapView.setRegion(region, animated: true) mapView.addOverlay(MKPolyline(coordinates: &coords, count: coords.count)) } //save location self.locations.append(location) } } } |
Now, the map always centers on the most recent location, and constantly adds little blue polylines to show the user’s trail thus far.
Open Main.storyboard
and find the New Run Scene. Connect the outlet for mapView
to the map view, and set its delegate
to the view controller.
Build and run, and start a new run. You’ll see the map updating in real-time!
Is your heart rate up yet? :]
Where To Go From Here
Here’s a download of the sample project with all the code you’ve written up until this point.
You’ve seen how to store data for a run in a simple set of Core Data models, and display the run details (even in real time!) on a map. That’s really the core of MoonRunner, so congratulations!
If you’re up for some super-extra credit challenges, why not try to use the altitude information, maybe to change the thickness of the line segments? Or try blending the segment colors more smoothly by averaging a segment’s speed with that of the segment before it.
In any case, stay tuned for part two of this tutorial, where you’ll add the final touches and introduce a badge system personalized for each user.
As always, feel free to post comments and questions in the forum discussion below!
The post How To Make an App Like RunKeeper with Swift: Part 1 appeared first on Ray Wenderlich.