Build a Compass app with SwiftUI

Darren
12 min readOct 10, 2019

--

Thanks to drawkit.io for the nice illustration

Building this simple compass app took much longer than it should have. The actual SwiftUI code is around 100 lines, but even so, I definitely had some frustrating moments.

This post is mostly about the SwiftUI side of the compass, and not the actual compass logic(CoreLocation). The main part of this tutorial will be getting the rotations and padding correct, things like theText views always need to be readable even when the compass is turning and then spacing out each of the degrees Views.

This is what the final app will look like:

But enough of that, let’s see what the code looks like.

Step 1: Create the ContentView

struct ContentView : View {    
var body: some View {
VStack {
// Code here
}
}
}

In your ContentView replace the Text view with a VStack, this will be our base for everything else.

Next, we are going to add a Capsule view. This Capsule view will be used to show which direction you are facing. This is not really a required view, but I saw that the Apple compass had something similar :)

struct ContentView : View {
var body: some View {
VStack {
Capsule()
.frame(width: 5,
height: 50)
}
}
}

If you build and run the app now, your app will look like this:

Great, we have our app running, now we can get into the more tricky part of the code.

Update the ContentView

Replace your ContentView with the following:

struct ContentView : View {
var body: some View {
VStack {
Capsule()
.frame(width: 5,
height: 50)

// 1
ZStack {
// 2
ForEach([0, 30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330], id: \.self) { marker in
// CompassViewMarker(still to come)
}
}
.frame(width: 300,
height: 300)
.rotationEffect(Angle(degrees: 0)) // 3
.statusBar(hidden: true)
}
}
}

In the above code, we added a couple of things. We added a ZStack with some styling as well as a ForEach that will display the CompassViewMarkers.

  1. We are using the ZStack because we want to put all the “compass marker views” on top of one another, this will allow us to rotate each one of those “compass marker views” based on the values that we pass to the ForEach. If we used a VStack or an HStack the markers would be laid out incorrectly. This will make more sense later in this article when we create and add the CompassMarkerView.
  2. We are working with a ForEach because we need multiple “compass marker views”. There are 12 values in the array for the ForEach, this represents each mark on the compass in degrees.
  3. .rotationEffect, this will rotate the ZStack when we get info from the compass logic later on. For now, we will set it to 0 so that we can finish the rest of the layout. Once the layout is done, adding the compass logic will be quick and easy.

If you are confused about what a “compass marker view” is, don’t worry, we will be creating that in Step 3.

Step 2: Creating and updating the compass data

In the previous step, we had a simple array that contained the degrees for each “compass marker view”. We will now create our data model and method that will return an array which will contain all the data we need.

To do this we will create a struct called Marker. The Marker will have two properties, degrees and label. We will also create a custom init so that we only need to add the label text to Markers that require it. We will also need to make the Marker Hashable so that we can use it in the ForEach. Luckily this is really simple as everything in Marker is already Hashable so we don’t need to do any extra work.

struct Marker: Hashable {
let degrees: Double
let label: String

init(degrees: Double, label: String = "") {
self.degrees = degrees
self.label = label
}
}

Since the markers that we want will be static, we will create a new static method which will return an array of Marker. This will contain all the degrees that we want to display as well as the label info that we need.

Update the above Marker to look like the following:

struct Marker: Hashable {
let degrees: Double
let label: String

init(degrees: Double, label: String = "") {
self.degrees = degrees
self.label = label
}

static func markers() -> [Marker] {
return [
Marker(degrees: 0, label: "N"),
Marker(degrees: 30),
Marker(degrees: 60),
Marker(degrees: 90, label: "E"),
Marker(degrees: 120),
Marker(degrees: 150),
Marker(degrees: 180, label: "S"),
Marker(degrees: 210),
Marker(degrees: 240),
Marker(degrees: 270, label: "w"),
Marker(degrees: 300),
Marker(degrees: 330)
]
}
}

Step 3: Creating the CompassMarkerView

Ok, now that we have our data we can move on to the most complicated view in the app, the CompassMarkerView. It contains three child views(two Text views and one Capsule view).

The CompassMarkerView will have two properties, marker so that we can pass a Marker through from the ForEach, as well as compassDegrees, which will come from the compass logic which we will add later.

Let’s start with the code so that we can a basic visual running:

struct CompassMarkerView: View {
let marker: Marker
let compassDegress: Double

var body: some View {
VStack {
// 1
Text("\(marker.degrees)")

// 2
Capsule()
.frame(width: 3,
height: 30)
.foregroundColor(Color.gray)

// 3
Text(marker.label)
}
.rotationEffect(Angle(degrees: marker.degrees)) // 4
}
}

We have a VStack which contains two Text views and one Capsule view. At the moment only the Capsule as some styling.

  1. The first Text view will display the degree value of the Marker that we initialized this view with.
  2. This is going to act as a line for the current degree value to make it clearer for the user. To make this look a bit better during testing, we just add a frame for the size and foreground color.
  3. This lastText view will display the direction(i.e N, S, E, W). This direction is the Marker label value which we set when we created our Marker model in Step 2.
  4. The rotationEffect will rotate each VStack so that each marker view is at the correct angle. If you remove this you will see that all the markers will be on top of each other.

Before we build and run the app, let’s fix an issue that is causing a compile issue.

Go back to the ContentView and replace the comment // CompassViewMarker(still to come) with the following:

CompassMarkerView(marker: marker,
compassDegress: 0)

And replace the array with Marker.markers().

After you have updated your ContentView it should look like this:

struct ContentView : View {
var body: some View {
VStack {
Capsule()
.frame(width: 5,
height: 50)

ZStack {
ForEach(Marker.markers(), id: \.self) { marker in
CompassMarkerView(marker: marker,
compassDegress: 0)
}
}
.frame(width: 300,
height: 300)
.rotationEffect(Angle(degrees: 0))
.statusBar(hidden: true)
}
}
}

Now we are using the correct data for our ForEach and we are creating all the CompassMarkerView views that we need.

If you build and run the app now you should see the following:

This is not exactly what we are going for, but it is progress :) Technically all the correct information should be there, we just have a small styling issue which we will fix now, and we should be able to see more clearly what the app will look like.

Step 4: Update styling for CompassMarkerView

Time to fix the styling. We will do this view by view, so we will do the first Text view first, then the Capsule and then the last Text view. This will not use the actual data just yet, we only want to get the styling correct at the moment.

Update the first Text view to look like this:

Text("\(marker.degrees)")
.fontWeight(.light)
.rotationEffect(Angle(degrees: 0))

You can build and run now, but it will look the same as the previous step. What we have done is set the Text view up so that it can rotate later on when we get the information from the compass logic. I also just made the fontWeight light to make it look a bit better(IMO).

Next, we will update the Capsule view. Currently, we have set the frame as well as the foreground color. We will now add the padding. Update your Capsule code with the following:

Capsule()
.frame(width: 3,
height: 30)
.foregroundColor(Color.gray)
.padding(.bottom, 120)

As you can see, all we have done is add some bottom spacing. If you build and run now, you will see some progress.

We can now update our last Text view. We will be making the font bold, we will also be adding a rotationEffect and lastly padding at the bottom so that we can make the compass a bit wider.

Update the last Text view with the following code:

Text(marker.label)
.fontWeight(.bold)
.rotationEffect(Angle(degrees: 0))
.padding(.bottom, 80)

We set the rotationEffect to 0, this will be changed later to use the compass logic, and then we added the bottom padding of 80.

If you build and run the app now, it should look like this:

This is looking much better now. There are a few issues still, the first one being the degree labels having a decimal value, and the N, S, E, W labels are in the wrong position. I will talk about this at the end of the post.

This is what your CompassMarkerView code needs to look like now:

struct CompassMarkerView: View {
let marker: Marker
let compassDegress: Double

var body: some View {
VStack {
Text("\(marker.degrees)")
.fontWeight(.light)
.rotationEffect(Angle(degrees: 0))

Capsule()
.frame(width: 3,
height: 30)
.foregroundColor(Color.gray)
.padding(.bottom, 120)

Text(marker.label)
.fontWeight(.bold)
.rotationEffect(Angle(degrees: 0))
.padding(.bottom, 80)
}.rotationEffect(Angle(degrees: marker.degrees))
}
}

Step 5: Fix the degree labels

This is a quick fix. Add the following method to your Marker struct.

func degreeText() -> String {
return String(format: "%.0f", self.degrees)
}

Once that is done, we need to go back to the CompassMarkerView and update the first Text view to use marker.degreeText() instead of marker.degrees.

In the CompassMarkerView we need to update the Text view code to look like the below:

Text(marker.degreeText())
.fontWeight(.light)
.rotationEffect(Angle(degrees: 0))

If you build and run the app now, your app should look like this:

This is looking much better now. Let's add the final styling and then we can move on to having everything work off the compass logic.

Step 6: Final styling

In this step, we will make the Capsule for the 0 degrees marker red, we will also set different widths and heights for the Capsules, and finally, we will get the rotation for the text working with.

Add the following 4 functions to your CompassMarkerView :

// 1
private func capsuleWidth() -> CGFloat {
return degrees == 0 ? 7 : 3
}

// 2
private func capsuleHeight() -> CGFloat {
return degrees == 0 ? 45 : 30
}

// 3
private func capsuleColor() -> Color {
return degrees == 0 ? .red : .gray
}

// 4
private func textAngle() -> Angle {
return Angle(degrees: -self.compassDegress - self.marker.degrees)
}
  1. capsuleWidth : This method will set the width of each capsule that we have. If it is the capsule for 0 degrees, we want it to have a width of 7 and a normal capsule should have a width of 3.
  2. capsuleHeight : This is exactly the same as the capsuleWidth. For the 0 degrees capsule we want the height to be 45 and for every other capsule we want it to be 30.
  3. capsuleColor : Once again, the logic is the same as the above two, depending on the degrees we set to use a different color.
  4. textAngle : This is used to calculate the angle of the text. It uses marker degrees as well as the current compass degrees to calculate the angle that we want to apply to our Text views.

If you want, you could pass the marker and/or the compass degrees depending on the method to make these methods testable.

We now need to update our CompassMarkerView once more. Update the body property with the following:

var body: some View {
VStack {
Text(marker.degreeText())
.fontWeight(.light)
.rotationEffect(self.textAngle()) // 1

Capsule()
.frame(width: self.capsuleWidth(), // 2
height: self.capsuleHeight()) // 3
.foregroundColor(self.capsuleColor()) // 4
.padding(.bottom, 120)

Text(marker.label)
.fontWeight(.bold)
.rotationEffect(self.textAngle()) // 5
.padding(.bottom, 80)
}.rotationEffect(Angle(degrees: marker.degrees))
}
  1. We are now using the textAngle method we just created to set the rotationEffect for our degrees Text view.
  2. We set the Capsule width using our capsuleWidth method
  3. We set the Capsule height using our capsuleHeight method
  4. We set the Capsule color using our capsuleColor method
  5. And lastly, we set the rotationEffect on our direction Text view.

This is the final CompassMarkerView code:

struct CompassMarkerView: View {
let marker: Marker
let compassDegress: Double

var body: some View {
VStack {
Text(marker.degreeText())
.fontWeight(.light)
.rotationEffect(self.textAngle())

Capsule()
.frame(width: self.capsuleWidth(),
height: self.capsuleHeight())
.foregroundColor(self.capsuleColor())
.padding(.bottom, 120)

Text(marker.label)
.fontWeight(.bold)
.rotationEffect(self.textAngle())
.padding(.bottom, 80)
}.rotationEffect(Angle(degrees: marker.degrees))
}

private func capsuleWidth() -> CGFloat {
return self.marker.degrees == 0 ? 7 : 3
}

private func capsuleHeight() -> CGFloat {
return self.marker.degrees == 0 ? 45 : 30
}

private func capsuleColor() -> Color {
return self.marker.degrees == 0 ? .red : .gray
}

private func textAngle() -> Angle {
return Angle(degrees: -self.compassDegress - self.marker.degrees)
}
}

If you run the app now, it should look like this:

Finally, all the styling is done. Now we need to move onto the compass logic so that we can get this app working as expected.

Step 7: Compass logic

This is something that was driving me crazy. Trying to get this to work properly has been a struggle, and it is still not working correctly.

Create a new file called CompassHeading.swift and the following code to it:

import Foundation
import Combine
import CoreLocation

class CompassHeading: NSObject, ObservableObject, CLLocationManagerDelegate {
var objectWillChange = PassthroughSubject<Void, Never>()
var degrees: Double = .zero {
didSet {
objectWillChange.send()
}
}

private let locationManager: CLLocationManager

override init() {
self.locationManager = CLLocationManager()
super.init()

self.locationManager.delegate = self
self
.setup()
}

private func setup() {
self.locationManager.requestWhenInUseAuthorization()

if CLLocationManager.headingAvailable() {
self.locationManager.startUpdatingLocation()
self.locationManager.startUpdatingHeading()
}
}

func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
self.degrees = -1 * newHeading.magneticHeading
}
}

Basically we are just setting up a locationManager and in the setup method we tell it to startUpdatingHeading.

In the locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) we multiply the heading by -1. This seems to make it work like the Apple compass app, but there are still some issues sometimes which I find odd, but it seems to exist in the Apple compass app too. I tried to use CoreMotion, but that gave me the same heading.

Step 8: Using the CompassHeading

Now that we have the CompassHeading up and running, we can use it in our ContentView.

Add the following code before the body of the ContentView:

@ObservedObject var compassHeading = CompassHeading()

Then the last thing that we need to do is update the rotationEffect of the ZStack in the ContentView. Update it to look like this:

.rotationEffect(Angle(degrees: self.compassHeading.degrees))

If you run the app now, it should all be working, but you will need to run this on a device.

Before I end, as you can see, when you run the app, the direction labels are incorrect. I mentioned this earlier in the article. In all honesty, I could not figure this out, I tried a few things and nothing worked, so I ended up just changing the data model. As soon as I put the direction label above the Capsule in the CompassMarkerView then it worked as expected, and I am not sure why the position of the Text view would change its content, if you have any idea what could be causing this, please let me know in the comments.

This is the updated model hack:

struct Marker: Hashable {
let degrees: Double
let label: String

init(degrees: Double, label: String = "") {
self.degrees = degrees
self.label = label
}

func degreeText() -> String {
return String(format: "%.0f", self.degrees)
}

static func markers() -> [Marker] {
return [
Marker(degrees: 0, label: "S"),
Marker(degrees: 30),
Marker(degrees: 60),
Marker(degrees: 90, label: "W"),
Marker(degrees: 120),
Marker(degrees: 150),
Marker(degrees: 180, label: "N"),
Marker(degrees: 210),
Marker(degrees: 240),
Marker(degrees: 270, label: "E"),
Marker(degrees: 300),
Marker(degrees: 330)
]
}
}

If you run the app now, all the direction labels will be correct and your app should look like this:

For the final source code, you can click here.

--

--