Introduction to Reactive Programming with Combine
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, theFailure
type is specified asNever
.
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.
- First we create a publisher that will publish the
textDidChangeNotification
notification whenever the text inside oftextField
changes.
Publishers can be created from various preexisting classes, and creating a publisher fromNotificationCenter
is just one example of this. - Next, we call the
compactMap
operator on the newly created publisher. This operator creates a new publisher that works similarly to thecompactMap
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 theobject
property of the notification toUITextField
, and optionally access itstext
property.
If the value returned by the function is nil (meaning theobject
property of the notification did not refer to an instance ofUITextField
, or thetext
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. - 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. - Using the
sink
method we assign the received string to thetext
property of the text field. Thesink
method creates a subscriber that executes the closure passed with thereceiveValue
parameter for every value it receives, and the closure passed with thereceiveCompletion
parameter when the completion is received.
The method can be called without thereceiveCompletion
parameter if the publisher has an error type ofNever
(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 thereceiveCompletion
parameter because the initial publisher has aFailure
type ofNever
and none of the operators change theFailure
type. - The
sink
method does not return the subscriber it creates, but it returns an instance ofAnyCancellable
instead.AnyCancellable
is a class used to represent a cancellable operation, and thisAnyCancellable
object 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 itscancel()
method) when we want to cancel the subscription. In order to keep theAnyCancellable
object in memory even after theviewDidLoad
finishes executing, we store it in thecancellable
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 subscribersCurrentValueSubject
, 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 enum
s 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 themessage
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 theassign
method to aSet
to keep it in memory, using thestore
method; since we need to store more than one instance ofAnyCancellable
, 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.