Waldo sessions now support scripting! – Learn more
App Development

Building a location publisher with Combine and Core Location

Donny Wals
Donny Wals
Building a location publisher with Combine and Core Location
October 26, 2022
10
min read

While Combine is no longer the obvious choice for everything asynchronous now that Swift has a new concurrency library, there are still use cases for Combine. For example, when you want to obtain and publish values that represent state changes over time.

An example of such state is a user’s location. A user’s location can change over time, and it can do so many times. This is a typical example where Combine provides us with useful mechanisms to make the user’s location available to the places in your app that need it.

Your initial thought might be to build a publisher from scratch to achieve this goal. In this post, we won’t be doing that. Instead, you will learn how you can leverage existing tools to wrap various useful parts of Core Location in a Combine shell.

By the end of this post you will have a much better understanding of the following topics:

  1. Subjects in Combine and how they are used
  2. Building an object that forwards delegate callbacks to your subjects

But before we dig in, let’s make sure we understand the goals and direction for this post.

Designing our location publisher’s interface

Before we dig into the Combine bits and pieces, let’s take a look at what we want to achieve. One limitation in our design is that we can’t (or rather, should not) implement an object that conforms to the Publisher protocol that’s defined within Combine. First and foremost we shouldn’t do this because Apple explicitly tells us not to, but we also don’t have to.

The final implementation should adhere to the following requirements:

  • Easy to observe the current authorization status for location services so we always have the most up to date permissions.
  • A way to observe the user’s current location.
  • Lastly, we should be able to start and stop the location manager so we don’t monitor the user’s location for longer than needed.

While implementing our location publisher, we should consider the current location permissions and the user’s last known location as state. More importantly, we should consider these values mutable state, allowing us to change these values when needed.

Ideally, we can use our publisher as shown below:


var cancellables = Set<AnyCancellable>()
let locationPublisher = LocationPublisher()

// observing the user's authorization status
locationPublisher.authorizationStatus
  .sink { currentStatus in 
    // use the current status
  }.store(in: &cancellables)

// request authorization
locationPublisher.requestAuthorization()

// observing the user's current location
locationPublisher.currentLocation
  .sink { currentLocation in 
    // use the current location
  }.store(in: &cancellables)

// start observing location
locationPublisher.startMonitoringLocation()

// stop observing location
locationPublisher.stopMonitoringLocation()

The code above should look fairly familiar if you’ve worked with Combine before. We subscribe to state changes for authorizationStatus and currentLocation using sink, and we’ll handle any incoming values as needed.

The design also includes some methods that can be called to request location permissions, and to start and stop location monitoring. These methods will simply call the relevant methods on the CLLocationManager that’s provided by CoreLocation.

Now that we have a good sense of what we’d like to end up with, let’s go ahead and define our LocationPublisher object.

Implementing the location publisher basics

Before we take a look at the interesting bits like how we should implement the CLLocationManagerDelegate methods and publishers that we need, let’s define the core of our LocationPublisher:


class LocationPublisher: NSObject, CLLocationManagerDelegate {
    private let locationManager: CLLocationManager
    
    var currentLocation: CLLocation?
    var authorizationStatus: CLAuthorizationStatus
    
    init(locationManager: CLLocationManager = CLLocationManager()) {
        self.locationManager = locationManager
        self.authorizationStatus = locationManager.authorizationStatus
        locationManager.delegate = self
    }
    
    func requestAuthorization() {
        locationManager.requestAlwaysAuthorization()
    }
    
    func startMonitoringLocation() {
        locationManager.startUpdatingLocation()
    }
    
    func stopMonitoringLocation() {
        locationManager.stopUpdatingLocation()
    }
    
    // delegate methods...
}

The code above declares our LocationPublisher. Notice that this object is a class that inherits from NSObject and implements the CLLocationManagerDelegate protocol. We must inherit from NSObject because it’s a CLLocationManagerDelegate requirement.

Conforming to CLLocationManagerDelegate allows us to receive and handle various important events like when a user changes their location permissions, or whenever a new location for our user becomes available.

We also define two properties that we’ll need to hold our state. We also have an initializer that optionally accepts a location manager object, or we create a new one if no manager was passed to the initializer. This allows users of our LocationPublisher to either provide their own location manager, or use whatever we make for them.

Inside of the initializer, we assign ourself to be the delegate for the CLLocationManager. This allows the CLLocationManagerDelegate methods that we’ll implement shortly to be called.

We also defined the three methods that you saw in the previous section. As mentioned, those methods only call the respective methods on CLLocationManager without doing any extra work. These methods are nice and easy to implement.

The next thing to do is to implement the delegate methods that are needed to respond to changes in the user’s location permissions and location updates.

Let’s start with the delegate method that allows us to respond to changes in location permissions:


class LocationPublisher: CLLocationManagerDelegate {
    // ...
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        self.authorizationStatus = manager.authorizationStatus
    }
}

All we want to do for now is read the new authorization status and use it to update our state. We’ll figure out how to actually make this state change something that’s wrapped by a publisher later.

The next delegate method we’ll implement will allow us to respond to location updates:


class LocationPublisher: CLLocationManagerDelegate {
    // ...
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        self.currentLocation = locations.last
    }
}

This method follows a similar pattern to what you saw before. The CLLocationManager we’re wrapping calls the locationManager(_:didUpdateLocations:) with an array of CLLocation objects. The last item in this array is always the most recent location. Usually there will be only one location in the array that’s passed to this method, but in some situations CLLocationManager will have collected multiple locations.

In the event that our method gets called with multiple locations, we’ve made the choice to ignore everything that’s not the most recent location.

Now that we’re updating state inside of our LocationPublisher, let’s see how we can publish these state changes using Combine.

Wrapping our state in Combine subjects

Combine comes with various built-in mechanisms to observe state and build pipelines of publishers and operators. For example, Apple has added publishers to Timer, URLSession, NotificationCenter, and more to make it easy to work with these objects in Combine.

Apple also added the Subject protocol to Combine. This protocol allows us to easily build our own publishers that we can use to publisher values at will.

Combine comes with two subjects:

  • PassthroughSubject
  • CurrentValueSubject

A PassthroughSubject is useful when you want to build a publisher that emits values to subscribers, and then immediately forgets these values. It doesn’t have a sense of state. This kind of publisher is useful to model things like button taps or NotificationCenter notifications. These events are useful when they occur, but after that they’re no longer useful.

The second kind of subject is CurrentValueSubject. It’s very similar to a PassthroughSubject because we can use either subject to send values at-will, but there’s one major difference. A CurrentValueSubject has a sense of state. It remembers the last value that it sent. This kind of publisher is useful to model something like a toggle’s state, or to model anything else that has a current value; usually any kind of state.

Since CurrentValueSubject and PassthroughSubject are so similar, we won’t go over them individually. We’ll dive straight into using a CurrentValueSubject to make our LocationPublisher publish the state that we’re tracking.

First, we’ll need to define two properties that can be used to hold our CurrentValueSubject publishers:


let currentLocationSubject = CurrentValueSubject<CLLocation?, Never>(nil)
let authorizationStatusSubject : CurrentValueSubject<CLAuthorizationStatus, Never>

This code should be added to LocationPublisher and it declares two subjects. One CurrentValueSubject that we initialize immediately with an initial value of nil. This indicates that we start out with a nil value for our current location because we haven’t obtained it yet.

The second publisher should be initialized in the LocationPublisher's initializer because its initial value depends on the location manager object that we’re working with:


init(locationManager: CLLocationManager = CLLocationManager()) {
    self.locationManager = locationManager
    self.authorizationStatusSubject = CurrentValueSubject(locationManager.authorizationStatus)
    
    super.init()
    
    locationManager.delegate = self
}

The two subjects that we’ve defined at this point will act as the publishers for our state. We’ll use them to publish any changes that occur for the location permissions and current location.

At this point, our code no longer compiles but that’s okay. We’ll fix that later. For now, let’s update the delegate methods that you saw earlier.


func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
    self.authorizationStatusSubject.value = manager.authorizationStatus
}

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    self.currentLocationSubject.value = locations.last
}

To send new values over our CurrentValueSubject we can assign a new value to the subject’s value property. This will automatically send the new value to any existing subscribers.

At this point, we could remove the currentLocation and authorizationStatus properties that we had before, and we could go ahead and use our LocationPublisher in almost exactly the way that we outlined earlier:


var cancellables = Set<AnyCancellable>()
let locationPublisher = LocationPublisher()

// observing the user's authorization status
locationPublisher.authorizationStatusSubject
    .sink { currentStatus in 
        // use the current status
    }.store(in: cancellables)

// observing the user's current location
locationPublisher.currentLocationSubject
    .sink { currentLocation in 
        // use the current location
    }.store(in: &cancellables)

There’s one huge problem with the code that we have. Remember how we assign a new value to a subject’s value property to send new values to subscribers. Well, anybody can do this. There’s nothing preventing us from doing the following:


locationPublisher.currentLocationSubject.value = nil

We don’t want to allow this. Our publishers should be read-only to the outside world.

One technique that we can leverage is to make our subjects private, and provide a type-erased publisher to users of our LocationPublisher. To do this, we can update our LocationPublisher as follows:


class LocationPublisher: NSObject {
    private let locationManager: CLLocationManager
    
    private let currentLocationSubject = CurrentValueSubject<CLLocation?, Never>(nil)
    private let authorizationStatusSubject : CurrentValueSubject<CLAuthorizationStatus, Never>
    
    var currentLocation: AnyPublisher<CLLocation?, Never> {
        currentLocationSubject.eraseToAnyPublisher()
    }
    
    var authorizationStatus: AnyPublisher<CLAuthorizationStatus, Never> {
        authorizationStatusSubject.eraseToAnyPublisher()
    }
    
    // ...
}

With this in place, our subjects are private, and we should subscribe to currentLocation and authorizationStatus instead. These two properties are now type-erased versions of the subjects we keep privately. By type erasing the subjects, the outside world only knows that our LocationPublisher exposes publishers that emit CLLocation and CLAuthorizationStatus objects. They don’t see what underlying mechanism we use to do this.

Unfortunately, the code to do this isn’t the shortest to write. There’s some boilerplate involved with having the computed properties to provide type-erased versions of our subjects.

There’s one more mechanism that we can leverage to achieve our goal in the cleanest way possible and that’s the @Published property wrapper. This property wrapper allows us to subscribe to state changes to the properties that we mark @Published.

We can refactor the LocationProvider to use these properties as follows:


class LocationPublisher: NSObject {
    private let locationManager: CLLocationManager
    
    @Published private(set) var currentLocation: CLLocation?
    @Published private(set) var authorizationStatus: CLAuthorizationStatus
    
    init(locationManager: CLLocationManager = CLLocationManager()) {
        self.locationManager = locationManager
        self.authorizationStatus = locationManager.authorizationStatus
        
        super.init()
        
        locationManager.delegate = self
    }
    // ...
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        self.authorizationStatus = manager.authorizationStatus
    }

    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        self.currentLocation = locations.last
    }
}

Notice how similar this code is to the very first draft we had for the LocationPublisher. The major difference here is in how we’ve defined our state:


@Published private(set) var currentLocation: CLLocation?
@Published private(set) var authorizationStatus: CLAuthorizationStatus

By marking the two properties with @Published and private(set) we allow people to subscribe to state changes for these properties, but we also make assigning new values to these properties something that can only be done from within the LocationPublisher. It’s the best of both worlds essentially.

Let’s take one last look at how we can subscribe to state changes from the location publisher with this final version of the code:


  var cancellables = Set<AnyCancellable>()
  let locationPublisher = LocationPublisher()

  // observing the user's authorization status
  locationPublisher.$authorizationStatus
      .sink { currentStatus in 
          // use the current status
      }.store(in: &cancellables)

  // observing the user's current location
  locationPublisher.$currentLocation
      .sink { currentLocation in 
          // use the current location
      }.store(in: &cancellables)

By accessing a property that’s marked as @Published with a $ in front of the property name, you access the publisher for this property. Accessing the property without the $ prefix will allow you to read the property’s current value just like any property would.

In Summary

In this post, you’ve learned how you can create a wrapper for a CLLocationManager object that allows users of this object to respond to changes in location permissions and the user’s current location. You learned how you can leverage Subject or @Published properties to hold state that users of our LocationPublisher should be able to monitor for changes by subscribing to publishers.

You saw that Subject is a convenient protocol, but that it has its issues with preventing others to send values over the subject. We leveraged type erasure to prevent this from happening, but this required a bunch of extra code. After that, you saw that @Published is a convenient way to turn a regular property into a publisher, and that we can use access control prevent folks from assigning new values to our properties from the outside.

The patterns that you saw in this post can be leveraged for many callback and delegate based APIs, making them a very powerful tool in your developer toolbox.

Automated E2E tests for your mobile app

Waldo provides the best-in-class runtime for all your mobile testing needs.
Get true E2E testing in minutes, not months.

Reproduce, capture, and share bugs fast!

Waldo Sessions helps mobile teams reproduce bugs, while compiling detailed bug reports in real time.