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
.
- 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 theForEach
. If we used aVStack
or anHStack
the markers would be laid out incorrectly. This will make more sense later in this article when we create and add theCompassMarkerView
. - We are working with a
ForEach
because we need multiple “compass marker views”. There are 12 values in the array for theForEach
, this represents each mark on the compass in degrees. .rotationEffect
, this will rotate theZStack
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.
- The first
Text
view will display the degree value of theMarker
that we initialized this view with. - 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.
- This last
Text
view will display the direction(i.e N, S, E, W). This direction is theMarker
label value which we set when we created ourMarker
model inStep 2
. - The
rotationEffect
will rotate eachVStack
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)
}
capsuleWidth
: This method will set the width of eachcapsule
that we have. If it is thecapsule
for 0 degrees, we want it to have a width of 7 and a normalcapsule
should have a width of 3.capsuleHeight
: This is exactly the same as thecapsuleWidth
. For the 0 degreescapsule
we want the height to be 45 and for every othercapsule
we want it to be 30.capsuleColor
: Once again, the logic is the same as the above two, depending on the degrees we set to use a different color.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 ourText
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))
}
- We are now using the
textAngle
method we just created to set therotationEffect
for our degreesText
view. - We set the
Capsule
width using ourcapsuleWidth
method - We set the
Capsule
height using ourcapsuleHeight
method - We set the
Capsule
color using ourcapsuleColor
method - And lastly, we set the
rotationEffect
on our directionText
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.