When Apple added the Observable protocol to iOS 17 it looked like the end for the Combine framework. Here’s my gentle introduction if you still need it.
Last updated: Feb 20, 2024
Getting Started with Combine
There are a lot of good resources to help you get started with Combine but what worked for me was the plain old WWDC 2019 sessions (Introducing Combine and Combine in Practice). I recommend watching the two sessions back to back.
I’m going to assume you’ve watched those sessions but even if you haven’t you’ll probably get the idea from the sample code below.
A Practical Example - Validating User Input
I wanted a simple example that would give me some practise in applying Combine without getting lost in detail. I thought I would follow the approach from the Apple WWDC sessions and apply it to validating user input. Here’s my user interface:
The challenge is to only enable the Submit button when the user has activated both switches and entered their name in the text field. My starting point is a view controller with outlets wired up to the storyboard controls:
// TermsViewController.swift
import UIKit
final class TermsViewController: UIViewController {
@IBOutlet private var termsSwitch: UISwitch!
@IBOutlet private var privacySwitch: UISwitch!
@IBOutlet private var nameField: UITextField!
@IBOutlet private var submitButton: UIButton!
}
The first step is add some properties to hold the internal state:
private var acceptedTerms: Bool = false
private var acceptedPrivacy: Bool = false
private var name: String = ""
Then in the target-action methods for the two switches and the text field we set the properties when the user changes something:
@IBAction private func acceptTerms(_ sender: UISwitch) {
acceptedTerms = sender.isOn
}
@IBAction private func acceptPrivacy(_ sender: UISwitch) {
acceptedPrivacy = sender.isOn
}
@IBAction private func nameChanged(_ sender: UITextField) {
name = sender.text ?? ""
}
What I would normally do at this point is create a method (probably called configureView
) that would enable or disable the button based on the values of my three properties. I would call that method from a didSet
observer for each property:
private var acceptedTerms: Bool = false {
didSet { configureView() }
}
I might not normally even bother with the properties but I know I’m going to need them in a minute. There’s nothing wrong with this approach, but I’m trying to learn Combine. Let’s walk through my attempt.
Import the framework
Baby steps first:
import Combine
Make properties into publishers
I need to convert my simple properties into publishers. We do that by adding the @Published
keyword to each property:
@Published private var acceptedTerms: Bool = false
@Published private var acceptedPrivacy: Bool = false
@Published private var name: String = ""
The @Published
keyword is an example of property wrapper added with Swift 5.1. It creates a publisher for any property of a class (you can’t use it with value types like struct). You can still set and get the normal property but you can also access the publisher wrapper using a $
prefix.
For example, to subscribe to the publisher for the acceptedTerms
property:
// Keep a reference to the subscription so it's
// only cancelled when we are deallocated.
private var termsStream: AnyCancellable?
// Simple sink subscription to print value
termsStream = $acceptedTerms.sink { value in
print("Received \(value)")
}
The type of the two boolean publishers is Published<Bool>.Publisher
and the name publisher is of type Published<String>.Publisher
. Anytime the user enters text or moves a switch the new value is available to any subscriber of these publishers.
Combine Operators
This is where we get to the combine bit of using combine. To determine when we can enable the submit button we need several things to be true:
- The name must not be blank.
- Both switches should be on.
The combineLatest
operator is useful here. It gives us a new publisher that receives and combines the latest values from other publishers. There are variations to combine two, three or four publishers. Using the version that accepts three publishers:
private var validToSubmit: AnyPublisher<Bool, Never> {
return Publishers.CombineLatest3($acceptedTerms, $acceptedPrivacy, $name)
.map { terms, privacy, name in
terms && privacy && !name.isBlank
}.eraseToAnyPublisher()
}
There’s a lot going on here so let’s break it down
CombineLatest
First we combine the latest values of our three publishers (note we are using the $
prefix to get the wrapped version of each property):
Publishers.CombineLatest3($acceptedTerms, $acceptedPrivacy, $name)
This gives us a tuple of type (Bool, Bool, String)
. A subscriber to this publisher receives the new tuple of latest values when any of the properties changes.
Map Operator
Next we use a map operation to convert our tuple to a single Bool indicating if we can enable the submit button:
.map { terms, privacy, name in
terms && privacy && !name.isBlank
}
The closure takes the three values from the tuple and returns true if both the boolean properties are true and the name string is not blank. I’m using a small String extension to test a String for whitespace - see Empty Strings in Swift.
Type Erasure
Finally I am using the type erasure method eraseToAnyPublisher()
to wrap the real type of this publisher so that any subscriber only sees the generic type AnyPublisher<Bool, Never>
:
}.eraseToAnyPublisher()
All a subscriber should care about is that we publish a Bool and never return an error. They should not care how we produce that result. The publisher type will change depending on how we combine the upstream publishers. In this case the real type of validToSubmit
is this unwieldy mouthful:
Publishers.Map<Publishers.CombineLatest3<Published<Bool>.Publisher,
Published<Bool>.Publisher, Published<String>.Publisher>, Bool>
You can attempt to read this as we map the combined latest values of two Bool and a String publisher to a Bool. Life is easier for everybody if we hide the internal details and reset the type to AnyPublisher<Bool, Never>
.
Keypath Assign Subscription
Almost done. Finally we create the subscription to receive from our publisher and set the state of the submit button. We need a property to keep a reference to the subscription stream so it doesn’t get cancelled until the view controller goes away:
private var stream: AnyCancellable?
Then in viewDidLoad
we create the subscription to our publisher:
stream = validToSubmit
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: submitButton)
Since we’re interacting with UIKit, we use receive(on:)
to make sure we receive from our publisher on the main run loop. The assign(to:)
operator assigns the received value to a keypath of an object. We use it to assign the received Bool
to the isEnabled
property of the submit button.
The final result with both switches on and some non-blank text in the name field. The submit button is now showing it’s green enabled state:
It takes some getting use to but it’s not much more code. I’m not going to rewrite everything just to use Combine but this is a gentle way to learn the basics.
Here’s the final view controller code:
// TermsViewController.swift
import Combine
import UIKit
final class TermsViewController: UIViewController {
@IBOutlet private var termsSwitch: UISwitch!
@IBOutlet private var privacySwitch: UISwitch!
@IBOutlet private var nameField: UITextField!
@IBOutlet private var submitButton: UIButton!
@Published private var acceptedTerms: Bool = false
@Published private var acceptedPrivacy: Bool = false
@Published private var name: String = ""
private var stream: AnyCancellable?
override func viewDidLoad() {
super.viewDidLoad()
stream = validToSubmit
.receive(on: RunLoop.main)
.assign(to: \.isEnabled, on: submitButton)
}
@IBAction private func acceptTerms(_ sender: UISwitch) {
acceptedTerms = sender.isOn
}
@IBAction private func acceptPrivacy(_ sender: UISwitch) {
acceptedPrivacy = sender.isOn
}
@IBAction private func nameChanged(_ sender: UITextField) {
name = sender.text ?? ""
}
@IBAction private func submitAction(_ sender: UIButton) {
print("Submit... \(name)")
}
private var validToSubmit: AnyPublisher<Bool, Never> {
return Publishers.CombineLatest3($acceptedTerms, $acceptedPrivacy, $name)
.map { terms, privacy, name in
terms && privacy && !name.isBlank
}.eraseToAnyPublisher()
}
}
Get the Code
The full code for this post is in my GitHub repository:
Learn More
I found the two Apple sessions from WWDC 2019 to be an excellent introduction to Combine: