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:
A Run
has three attributes: distance
, duration
and timestamp
. It has a single relationship, locations
, that connects it to the Location
entity.
Now, set up Location
with the following 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”.
Select the locations
relationship, and set the Type to To Many, and check the Ordered box in the Data Model Inspector’s Relationship pane.
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).
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”:
Press the Stop button and verify that pressing Save takes you to the “Details” screen.
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 Unit
s 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.
Select the Capabilities tab and switch Background Modes to ON. Check 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!
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 theCLLocation
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.
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 Location
s 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.
Now, press New Run, then press Start and verify that the simulator begins its workout.
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:
- Set the region for the map so that only the area of the run is shown, not the entire world.
- Provide a delegate method that styles the map overlay properly.
- 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!
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.
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:
- 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.
- Convert each endpoint into a
CLLocation
object and save them in pairs. - 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.
- Calculate the average speed for the run.
- 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!
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 UIStackView
s 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:
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.
In the Attributes Inspector, check Hidden under Drawing.
In the Document Outline, Control-drag from the Map Container View to the Top Stack View and select Equal Widths from the pop-up.
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.
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.
Change the priority to High (750).
In the Document Outline, Control-drag from Map View to New Run View Controller and select 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.
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!
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.