Introduction to Reactive Programming with Combine

Matteo Porcu
OverApp
Published in
12 min readMar 27, 2023

--

When developing an app it is often necessary to react asynchronously to various events. For instance, an app may need to respond to user inputs, wait for data from a server, or process data from a sensor. While there are various tools available for asynchronous programming in iOS, the Combine framework is often overlooked.

Developed by Apple, the Combine framework is a reactive programming framework that is compatible with apps targeting iOS 13 or later.
Reactive programming involves programming with asynchronous data streams, which are sequences of values emitted over time.
By using the Combine framework, developers can model data changes and events as data streams and react to them accordingly.

Our goal

The primary aim of this article is to provide an introduction to using Combine in UIKit. While it won’t serve as a comprehensive guide to the framework (that would require a much longer article), it will offer an opportunity to see Combine in action and to begin considering when its implementation can prove beneficial to your projects.

In this article we are going to use Combine to manage data coming from CMMotionManager, and update the user interface to indicate whether the device is being held vertically.

Basics of Combine

Publishers are entities that emit values over a period of time. They can also complete, which means that they will no longer emit values and will send a completion event to signal this.
Publishers are associated with two types:

  • the Output type, which represents the type of values the publisher emits
  • the Failure type, which represents the type of error that the publisher may encounter. If a publisher cannot fail, the Failure type is specified as Never.

There are several ways to create a publisher, but one of the most simple and flexible techniques is to create a published property: by using the @Published property wrapper when defining a property, a publisher associated with the property is automatically created. This publisher will emit the value of the property every time it changes, and will never complete (so its Failure type is set to Never).

Then using $ we can access the publisher associated with the property:

$currentText

Publishers are equipped with operators, which are methods that create and return a new publisher. This new publisher receives and manipulates each value and completion event emitted by the upstream publisher. By chaining these operators together, it is possible to create a pipeline of publishers that process each value, step by step, until it reaches the end of the chain.

Subscribers are the entities that receive the values at the end of the chain.
When a subscriber receives a value, it reacts by performing a specific action. It is important to note that a publisher will only start to emit values if there is a subscriber at the end of the pipeline that is actively requesting them.

Here’s a simple pratical example:

import UIKit
import Combine

import UIKit
import Combine
class ExampleViewController: UIViewController {

@IBOutlet weak var textField: UITextField!
@IBOutlet weak var label: UILabel!

var cancellable: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()

let textDidChangeNotificationPublisher = NotificationCenter.default.publisher(
for: UITextField.textDidChangeNotification,
object: textField
) // 1

cancellable = textDidChangeNotificationPublisher // 5
.compactMap { ($0.object as? UITextField)?.text } // 2
.receive(on: DispatchQueue.main) // 3
.sink { self.label.text = $0 } // 4
}

}

In this ViewController we have a text field and a label; every time the text in the text field changes, the new text gets automatically assigned to the label.

Let’s see more in detail how this works.

  1. First we create a publisher that will publish the textDidChangeNotification notification whenever the text inside of textField changes.
    Publishers can be created from various preexisting classes, and creating a publisher from NotificationCenter is just one example of this.
  2. Next, we call the compactMap operator on the newly created publisher. This operator creates a new publisher that works similarly to the compactMap method you can use on collections: the publisher transforms every value it receives using the closure we pass to the operator, and publishes this new value if it is not nil. In this case for each notification it receives it tries to access the current text contained in the text field by optionally casting the object property of the notification to UITextField, and optionally access its text property.
    If the value returned by the function is nil (meaning the object property of the notification did not refer to an instance of UITextField, or the text property of the text field was nil), the value is not published; otherwise the text currently contained in the text field is published as a string.
  3. The receive operator is used to create a publisher that republishes the values and completion events it receives on a specific scheduler. The way we are using this operator is one of its most common use cases: since the values will be used to update the UI, we need to make sure they are received on the main queue.
  4. Using the sink method we assign the received string to the text property of the text field. The sink method creates a subscriber that executes the closure passed with the receiveValue parameter for every value it receives, and the closure passed with the receiveCompletion parameter when the completion is received.
    The method can be called without the receiveCompletion parameter if the publisher has an error type of Never (which means it cannot fail), otherwise the closure for the completion needs to be provided in order to manage the failure case.
    In this case we can omit the receiveCompletion parameter because the initial publisher has a Failure type of Never and none of the operators change the Failure type.
  5. The sink method does not return the subscriber it creates, but it returns an instance of AnyCancellable instead. AnyCancellable is a class used to represent a cancellable operation, and this AnyCancellableobject represents the subscription: the subscription will only persist until the object is kept in memory, and it will automatically be cancelled when the object is deallocated.
    Therefore, we need to keep a reference to this object as long as we want to keep the subscription active, and deallocate it (or call its cancel() method) when we want to cancel the subscription. In order to keep the AnyCancellableobject in memory even after the viewDidLoad finishes executing, we store it in the cancellable property, which is declared outside of the method.

sink is not the only method we can use to create a subscriber.
If our goal is only to assign the values that reach the end of the chain to a property of an object, like in the previous example, we can also use the assign method.
This method takes an object and a keypath as parameters and creates a subscriber that will assign the received value to the specified property of the object every time a new value is emitted by the publisher.
Here is an updated example that uses assign instead of sink:

        cancellable = textDidChangeNotificationPublisher
.compactMap { ($0.object as? UITextField)?.text }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: label) // <-

Just like sink, the assign method returns an instance of AnyCancellable instead of the subscriber it creates.

There is also another version of assign, which allows you to assign the value to a published property and does not require keeping an instance of AnyCancellable in memory to keep the subscription active; we will see an example of its usage in the project.

The project

So, now that we have gone over the basics of Combine, let’s take a look at the project.

struct Constants {

static let deviceMotionUpdateInterval = 1.0 / 60.0

static let frontalTiltThreshold: Double = 0.25
static let lateralTiltThreshold: Double = 0.125

}
import UIKit
import Combine

class DevicePositionViewController: UIViewController {

// MARK: - Outlets

@IBOutlet weak var frontalTiltLabel: UILabel!
@IBOutlet weak var lateralTiltLabel: UILabel!
@IBOutlet weak var positionLabel: UILabel!

// MARK: - ViewModel

lazy var viewModel = DevicePositionViewModel()

// MARK: - Combine

var bag = Set<AnyCancellable>()

// MARK: - ViewController Lifecycle

override func viewDidLoad() {
super.viewDidLoad()

viewModel.$deviceFrontalTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: frontalTiltLabel)
.store(in: &bag)

viewModel.$deviceLateralTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: lateralTiltLabel)
.store(in: &bag)

viewModel.$devicePosition
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: positionLabel)
.store(in: &bag)
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
}

}
import UIKit
import Combine
import CoreMotion

enum DeviceFrontalTilt {
case tiltedDown
case tiltedUp
case notTilted

var message: String {
switch self {
case .tiltedDown:
return "The device is tilted down"
case .tiltedUp:
return "The device is tilted up"
case .notTilted:
return ""
}
}
}

enum DeviceLateralTilt {
case tiltedLeft
case tiltedRight
case notTilted

var message: String {
switch self {
case .tiltedLeft:
return "The device is tilted left"
case .tiltedRight:
return "The device is tilted right"
case .notTilted:
return ""
}
}
}

enum DevicePosition {
case notVertical
case vertical

var message: String {
switch self {
case .notVertical:
return "The device is not vertical"
case .vertical:
return "The device is vertical"
}
}
}

class DevicePositionViewModel: NSObject {

// MARK: - Managers

private let motionManager = CMMotionManager()

// MARK: - Combine

@Published var deviceFrontalTilt: DeviceFrontalTilt?

@Published var deviceLateralTilt: DeviceLateralTilt?

@Published var devicePosition: DevicePosition?

private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()

// MARK: - ViewModel Lifecycle

override init() {
super.init()

gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)

gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)

Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)
}

// MARK: - Public Methods

func start() {
motionManager.deviceMotionUpdateInterval = Constants.deviceMotionUpdateInterval

motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motionData, error in
guard let motionData = motionData else { return }
self?.gravityPublisher.send(motionData.gravity)
}
}

func stop() {
motionManager.stopDeviceMotionUpdates()
}

}

First, we need to start receiving device motion data from our instance of CMMotionManager when the ViewController is about to appear, and stop receiving this data when the ViewController is about to disappear.
When we receive the data, we also want to retrieve the gravity vector from the data and emit it through a publisher.

class DevicePositionViewModel: NSObject {

// MARK: - Managers

private let motionManager = CMMotionManager()

// MARK: - Combine

// ...

private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()

// ...

// MARK: - Public Methods

func start() {
motionManager.deviceMotionUpdateInterval = Constants.deviceMotionUpdateInterval

motionManager.startDeviceMotionUpdates(to: .main) { [weak self] motionData, error in
guard let motionData = motionData else { return }
self?.gravityPublisher.send(motionData.gravity)
}
}

func stop() {
motionManager.stopDeviceMotionUpdates()
}

}

To achieve this, in the ViewModel we create the start and stop methods, to enable and disable data reception from the manager.
In the start method, before calling startDeviceMotionUpdates, we also set how often we want to receive updates.

In the handler passed to startDeviceMotionUpdates we access the gravity vector from the data, and emit it through gravityPublisher.

gravityPublisher is a subject; subjects are publishers that expose methods that allow to publish values and/or a completion by calling them. There are two types of subjects:

  • PassthroughSubject, that when a value is sent publishes it only to its current subscribers
  • CurrentValueSubject, that also stores the last sent value and when a new subscription is established sends this value to the new subscriber.
import UIKit
import Combine

class DevicePositionViewController: UIViewController {

// ...

// MARK: - ViewModel

lazy var viewModel = DevicePositionViewModel()

// ...

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.stop()
}

}

Now that we have created the start and stop methods in the ViewModel we just need to call them from viewWillAppear and viewWillDisappear respectively.

enum DeviceFrontalTilt {
case tiltedDown
case tiltedUp
case notTilted

var message: String {
switch self {
case .tiltedDown:
return "The device is tilted down"
case .tiltedUp:
return "The device is tilted up"
case .notTilted:
return ""
}
}
}

enum DeviceLateralTilt {
case tiltedLeft
case tiltedRight
case notTilted

var message: String {
switch self {
case .tiltedLeft:
return "The device is tilted left"
case .tiltedRight:
return "The device is tilted right"
case .notTilted:
return ""
}
}
}

enum DevicePosition {
case notVertical
case vertical

var message: String {
switch self {
case .notVertical:
return "The device is not vertical"
case .vertical:
return "The device is vertical"
}
}
}

We create some enums to represent the position of the device; for each enum we also define the message computed property: this is the message we want to show on screen when the device has that orientation.

class DevicePositionViewModel: NSObject {

// ...

// MARK: - Combine

@Published var deviceFrontalTilt: DeviceFrontalTilt?

@Published var deviceLateralTilt: DeviceLateralTilt?

@Published var devicePosition: DevicePosition?

private let gravityPublisher = PassthroughSubject<CMAcceleration, Never>()

// MARK: - ViewModel Lifecycle

override init() {
super.init()

gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)

gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)

Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)
}

// ...

}

When a value is published by gravityPublisher we want to update deviceFrontalTilt and deviceLateralTilt, assigning values representing the frontal and lateral tilt respectively, based on the gravity vector. This gravity vector we receive from CMMotionManager provides the direction of gravity relative to the device.
By checking if the x and z component of the vector are inside or outside of a certain range we can determine if the device is tilted.

        gravityPublisher
.map { gravity -> DeviceFrontalTilt in
if gravity.z < -Constants.frontalTiltThreshold {
return .tiltedUp
} else if gravity.z > Constants.frontalTiltThreshold {
return .tiltedDown
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceFrontalTilt)

So, let’s start with the chain for the frontal tilt.

Using the map operator we map every gravity vector published by gravityPublisher to the matching DeviceFrontalTilt case.

The removeDuplicates operator is used to ignore a value if it is equal to the last published value. The following operator, debounce, is used to republish a value only if no newer values has been received in the time interval we specify. Using these two operators one after the other we make sure that a value is published only if it has stayed the same for at least 0.25 seconds: since we are using data that comes from a sensor, doing this we can “smooth” the output in situations where the state alternates rapidly between tilted and not tilted.
Although it may not be a big problem in this scenario, this smoothing is crucial if we want to trigger non-instant UI updates, such as animations or playing audio, to prevent these non-instant changes from repeatedly interrupting each other.

Finally, we use assign to assign the value to the deviceFrontalTilt published property. As mentioned before, this kind of assign allows you to assign the value to a published property and does not require keeping an instance of AnyCancellable in memory to keep the subscription active. Instead, the subscription will stay active until the publisher associated with the published property is deinitialized.

        gravityPublisher
.map { gravity -> DeviceLateralTilt in
if gravity.x < -Constants.lateralTiltThreshold {
return .tiltedLeft
} else if gravity.x > Constants.lateralTiltThreshold {
return .tiltedRight
} else {
return .notTilted
}
}
.removeDuplicates()
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.assign(to: &$deviceLateralTilt)

We follow the same approach to update deviceLateralTilt.

        Publishers.CombineLatest($deviceFrontalTilt, $deviceLateralTilt)
.compactMap { frontalTilt, lateralTilt in
guard let frontalTilt = frontalTilt,
let lateralTilt = lateralTilt else {
return nil
}
return frontalTilt == .notTilted && lateralTilt == .notTilted ?
.vertical :
.notVertical
}
.assign(to: &$devicePosition)

Additionaly, we want to update devicePosition based on the other two properties: if the device is not tilted frontally nor laterally we want to assign devicePosition the vertical value, while if the device is tilted we want to assign notVertical to it.

To achieve this, we create a CombineLatest publisher: this publisher “combines” the output of two other publishers, publishing a tuple containing the last value emitted by each publisher every time one of them emits a value.
We use this approach to receive the values of the other two properties every time they are updated.

Then, using compactMap, we can map the tuple to the value that should be assigned to devicePosition.

class DevicePositionViewController: UIViewController {

// MARK: - Outlets

@IBOutlet weak var frontalTiltLabel: UILabel!
@IBOutlet weak var lateralTiltLabel: UILabel!
@IBOutlet weak var positionLabel: UILabel!

// MARK: - ViewModel

lazy var viewModel = DevicePositionViewModel()

// MARK: - Combine

var bag = Set<AnyCancellable>()

// MARK: - ViewController Lifecycle

override func viewDidLoad() {
super.viewDidLoad()

viewModel.$deviceFrontalTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: frontalTiltLabel)
.store(in: &bag)

viewModel.$deviceLateralTilt
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: lateralTiltLabel)
.store(in: &bag)

viewModel.$devicePosition
.compactMap { $0?.message }
.receive(on: DispatchQueue.main)
.assign(to: \.text, on: positionLabel)
.store(in: &bag)
}

// ...

}

To set up these pipelines, we follow the same process outlined in the Basics of Combine section of the article, with a couple of differences:

  • since the published properties in the ViewModel are not strings, we use compactMap to access the message computed property and retrieve the corresponding message to display on the screen;
  • at the end of each chain we add the instance of AnyCancellable returned by the assign method to a Set to keep it in memory, using the store method; since we need to store more than one instance of AnyCancellable, adding them to a set is more convenient than using a property for each one of them.

Conclusion

In this article we have covered the basics of Combine and how to use the framework in UIKit. Specifically, we explored how to use Combine to manage data coming from the CoreMotion framework, and update the UI based on it.
While this is just a small example of what can be achieved with Combine, it can serve as a starting point for exploring the framework’s capabilities and using it in your future projects.

--

--