How to display a map and track the user’s location in SwiftUI

Pau Blanes
9 min readJun 3, 2023

--

Interactive maps and displaying the user’s location are two very common features of mobile apps. To implement them, Apple provides two powerful tools: MapKit and CoreLocation.

This article aims to show you a straightforward way to use these tools in SwiftUI. Let’s get started!

Displaying the map

Implementing a simple map view in SwiftUI is really easy. First we need to create a new SwiftUI view and import MapKit. Then we will go to the body and add a view of type Map.

var body: some View {
Map(coordinateRegion: Binding<MKCoordinateRegion>)
}

As you can see, a binding of type MKCoordinateRegion is needed. This region will be used to define the center coordinates and zoom level of the map. Let’s create a new @State variable to hold the region.

@State var region = MKCoordinateRegion(
center: .init(latitude: 37.334_900,longitude: -122.009_020),
span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
)

For this example, we will center the map at Apple Park in Cupertino. For the span, a good zoomed-in view is around 0.1 to 0.5 degrees, while a nice zoomed-out view is around 1 to 10 degrees. Therefore, we will use a span of 0.2.

And voilà, just like that we have a map!

Here is the full code:

import MapKit
import SwiftUI

struct ContentView: View {

@State var region = MKCoordinateRegion(
center: .init(latitude: 37.334_900,longitude: -122.009_020),
span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
)

var body: some View {
Map(coordinateRegion: $region)
.edgesIgnoringSafeArea(.all)
}
}

As you can see, we added .edgesIgnoringSafeArea(.all) to prevent white borders from appearing at the top and bottom.

One last interesting feature of the Map view is that you can control how the user interacts with it by using a second parameter: interactionModes. By default, it is set to .all, but you can define it to only .zoom or .pan to restrict the user actions.

Map(coordinateRegion: $region, interactionModes: .pan)

You could also do interactionModes: [] to disable all interaction modes while maintaining the possibility to add tap gestures.

Now that we have the map up and running, let’s move on to displaying the user’s location. In SwiftUI, we have two ways to achieve this. Let’s start with the easier one.

Tracking the user’s location (the simple way)

⚠️ Disclaimer: This method always centers the map to the user’s location, therefore the user cannot freely move the map. If this is not the behaviour you want, jump straight to the next section 😉.

Showing the user location is really straightforward in SwiftUI, we just need to add two new parameters to the Map view.

Map(
coordinateRegion: $region,
showsUserLocation: true,
userTrackingMode: .constant(.follow)
)

The first new property is in charge of displaying the blue dot that we all know, but it will not move the map to the user’s location, that’s why we have the second property, which will do just that.

But if we run this code, we will still see the same map that we had, showing Apple Park, and no blue dot. Why is that? Apple takes user privacy very seriously, and we cannot show the user’s location if we don’t have permission.

First of all we need to add the text that we will show the user explaining why we want their location. Go to the project tab and inside Info select any key and press enter. Now choose Privacy — Location When in Usage Description from the dropdown and type the description.

Secondly, in the ContentView we need to import CoreLocation and add a new property of type CLLocationManager. After that we need to add the actual permission request. We can use the view modifier .onAppear for that.

Map(
coordinateRegion: $region,
showsUserLocation: true,
userTrackingMode: .constant(.follow)
)
.edgesIgnoringSafeArea(.all)
.onAppear {
locationManager.requestWhenInUseAuthorization()
}

If we run the app again we will now see this popup:

If you tap on Allow Once or Allow While Using App, the map should move to your location and display the blue dot.

You can go for a walk with your real device or simulate movement with the simulator, and you will see that the blue dot position is being updated and the map is automatically moving to always follow the user.

This is the final code, it is amazing that we are now displaying the location with just this few lines of code!

import CoreLocation
import MapKit
import SwiftUI

struct ContentView: View {

let locationManager = CLLocationManager()

@State var region = MKCoordinateRegion(
center: .init(latitude: 37.334_900,longitude: -122.009_020),
span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
)

var body: some View {
Map(coordinateRegion: $region, showsUserLocation: true, userTrackingMode: .constant(.follow))
.edgesIgnoringSafeArea(.all)
.onAppear {
locationManager.requestWhenInUseAuthorization()
}
}
}

There is only one problem to this approach: we cannot move the map away from the user’s location. It is automatically being centered all the time. If that is not the behaviour you want, then follow the next section as we will use a different method.

Tracking the user’s location (the complex way)

This approach is a bit more complex but it gives us much more control regarding when we update the map. This is the Google Maps behaviour: when you open the app it centers the map on your location, but after that, you can freely move the map. Let’s begin!

The first thing that we need to do is add the text that we will show the user explaining why we want their location. Go to the project tab and inside Info select any key and press enter. Now choose Privacy — Location When in Use Usage Description from the dropdown and type the description.

Secondly, we will create a new class called LocationManager and we will move the region variable there. For that, we need to import MapKit. We will also need to conform to ObservableObject and change @State to @Published. This way the view will be able to use this property the same way that it did with @State.

import MapKit

final class LocationManager: ObservableObject {
@Published var region = MKCoordinateRegion(
center: .init(latitude: 37.334_900, longitude: -122.009_020),
span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
)
}

Now its time to start adding the CoreLocation logic. First we need a CLLocationManager property. We can configure it in many ways. For example, we could specify a lower desiredAccuracy to consume less battery.

import MapKit

final class LocationManager: ObservableObject {
private let locationManager = CLLocationManager()

@Published var region = MKCoordinateRegion()

override init() {
super.init()

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

private func setup() {
switch locationManager.authorizationStatus {
//If we are authorized then we request location just once,
// to center the map
case .authorizedWhenInUse:
locationManager.requestLocation()
//If we don´t, we request authorization
case .notDetermined:
locationManager.requestWhenInUseAuthorization()
default:
break
}
}
}

Let’s take a look at this setup() method. If the user has given us permission then we will request the user’s location with locationManager.requestLocation() (this method will request the location just once). On the other hand, if we don’t have the user permission we will request it.

Try to build this code and you will see a compile error. We need two things to fix it. First we need to implement the CLLocationManagerDelegate protocol. Secondly, we need to make our LocationManager inherit from NSObject (this is required in order to implement the protocol).

extension LocationManager: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {

}

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
}

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {

}
}

Out of this three new methods, the first one is called when the user changes authorization. If it is accepted, we want to request the location in order to update the map (like we did in the setup method).

func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard .authorizedWhenInUse == manager.authorizationStatus else { return }
locationManager.requestLocation()
}

The second method is just to prevent crashes. For this example we will leave it empty, but feel free to implement any error handling there.

The last method is the most interesting one. It is called when we have a location update, and it gives us an array of CLLocation. According to the official documentation the last location of the array is the most recent one, so we will use that to center the map to the user’s position.

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locations.last.map {
region = MKCoordinateRegion(
center: $0.coordinate,
span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
}
}

Now we are ready to use this manager in the view. We will add a @StateObject (or @ObservedObject if you are using iOS13) to receive region updates.

We will also add a new property to the Map view: showsUserLocation. If we set it to true it will display the blue circle where the user is located. If you have read the previous section, you will notice that this time we did not add userTrackingMode, this is because we are updating the map center in our own manager, we do not want SwiftUI to center the map for us.

If we run the app we will now see this popup:

If you tap on Allow Once or Allow While Using App, the map should move to your location and display the blue dot.

You can go for a walk with your real device or simulate movement with the simulator and you will see that the blue dot position is being updated, but you can freely move the map.

There is one final thing to note. If you build the app using a real device, you will notice that after accepting the permission, it takes a long time for the map to center to your location, typically between 6–10 seconds.

There is a trick that I found to speed this up: in the LocationManager we can add locationManager.startUpdatingLocation() just before requesting authorization.

case .notDetermined:
locationManager.startUpdatingLocation()
locationManager.requestWhenInUseAuthorization()

Then we need to call locationManager.stopUpdatingLocation() once we receive the location to prevent getting constant updates, which would cause the map to be centered to our location all the time.

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locationManager.stopUpdatingLocation()
locations.last.map {
region = MKCoordinateRegion(
center: .init(latitude: $0.coordinate.latitude, longitude: $0.coordinate.longitude),
span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
}
}

Now the map is updated much sooner after authorizing the app! This is the final code:

import MapKit

final class LocationManager: NSObject, ObservableObject {
private let locationManager = CLLocationManager()

@Published var region = MKCoordinateRegion(
center: .init(latitude: 37.334_900, longitude: -122.009_020),
span: .init(latitudeDelta: 0.2, longitudeDelta: 0.2)
)

override init() {
super.init()

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

func setup() {
switch locationManager.authorizationStatus {
//If we are authorized then we request location just once, to center the map
case .authorizedWhenInUse:
locationManager.requestLocation()
//If we don´t, we request authorization
case .notDetermined:
locationManager.startUpdatingLocation()
locationManager.requestWhenInUseAuthorization()
default:
break
}
}
}

extension LocationManager: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
guard .authorizedWhenInUse == manager.authorizationStatus else { return }
locationManager.requestLocation()
}

func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("Something went wrong: \(error)")
}

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
locationManager.stopUpdatingLocation()
locations.last.map {
region = MKCoordinateRegion(
center: $0.coordinate,
span: .init(latitudeDelta: 0.01, longitudeDelta: 0.01)
)
}
}
}
import MapKit
import SwiftUI

struct ContentView: View {

@StateObject var manager = LocationManager()

var body: some View {
Map(coordinateRegion: $manager.region, showsUserLocation: true)
.edgesIgnoringSafeArea(.all)
}
}

Conclusion

We have seen how to use MapKit to easily display a Map in SwiftUI, and two different ways to track the user’s location using CLLocationManager.

In the next article, I will show various ways to add a Location Button that centers the map on the user’s location when pressed. Also, if the user has denied permission we will give them an easy way to change the settings.

Until the next one!

--

--