An Introduction to Combine
Replace the Delegate pattern with Combine
Using Combine to Replace the Delegate Pattern
Preface
There are now a lot of articles and books about Combine. You’ve probably tried it yourself. Maybe you thought it was just for SwiftUI and since you haven’t migrated apps to SwiftUI, you’ve ignored it for now. Well, I decided to hop on the Combine bandwagon at the urging of a co-working, and it was quite the revelation. Making me think in a whole new way. SwiftUI makes you think differently about writing UI code, and relies on Combine, but extracting Combine and using it with a UIKit app, can really drive home its power.
So I’ve cooked up a little example to show how you can replace a very well used design pattern, the Delegate Pattern, with Combine. I will skip the Combine intro since you can find that in many places now. Just suffice it to say that you have publishers and subscribers and I’ll show how these are used in place of delegates.
Set Up
Let’s set up a simple project to illustrate how to replace the Delegate Pattern with Combine publishers and subscribers.
I’m calling the project FoodFacts
because I will expand on this in a future article and use the foodfacts.org API. But for now, the concentration will be on an example to show how delegates can be replaced with Combine.
In this example we’ll have a blank screen with a User Profile button that calls up a sheet with fields for a user’s first and last names along with a Save button. The idea is to transfer the data entered into the fields back to the calling UIViewController
.
Your first reaction to doing something like this would be to create a UserProfileViewController
and a UserProfileViewControllerDelegate
. The main UIViewController
would implement the delegate and when the Save button on the UserProfileViewController
sheet was picked, invoke the delete’s function to transfer the values back. Here’s how you would do the same thing with Combine.
The code below shows the ViewController
(the main controller) and the UserProfileViewController
. There is a storyboard that goes with this but that is not particularly germane to this example.
class ViewController: UIViewController { @IBOutlet weak var yourNameHere: UILabel! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. title = "Food Facts" } } class UserProfileViewController: UIViewController { @IBOutlet weak var firstName: UITextField! @IBOutlet weak var lastName: UITextField! override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } @IBAction func saveProfile(_ sender: Any) { // to do dismiss(animated: true, completion: nil) } }
You can see in the UserProfileViewController
the action for the button as saveProfile(_:Any)
which right now, just dismisses the sheet.
Now Combine
The first thing we’re going to do to UserProfileViewController
is
import Combine
This brings in the Combine framework so you can begin using it. The idea is that when the Save button is picked, rather than calling upon a delegate and using its functions, we’re going to publish the values and the main ViewController
is going to receive them and use them.
Essentially, the class you would normally use to implement a delegate’s method is the subscriber while the user of the delegate is the publisher.
Now we need something to publish. I created a simple UserProfile
struct that contains the first and last name values and this is what will get published.
struct UserProfile { let firstName: String let lastName: String }
Switching back to UserProfileViewController
, add this line below the @IBOutlet
s:
public var userDataPublisher = PassthroughSubject<UserProfile, Never>()
This line creates a PassthroughSubject
publisher that simply passes on whatever values it is given to send. Values that are UserProfile
type and this publisher never produces any errors (ie Never
).
Down in the saveProfile
function, add these lines:
if let firstName = firstName.text, let lastName = lastName.text { let profile = UserProfile(firstName: firstName, lastName: lastName) userDataPublisher.send(profile) // done with this controller dismiss(animated: true, completion: nil) }
Once the text from the first and last name UITextField
s are extracted, a UserProfile
is created from them and then sent through the userDataPublisher
. That’s it. There is no delegate to access, you are just sending out a UserProfile
to whatever is listening for it.
To that end, switch over the ViewController
and import Combine
into this file, too. Once you’ve done that, we need to subscribe to the userDataPublisher
in UserProfileViewController
.
I set the project up to use segues right in the storyboard, so we need to intercept the segue. If you were doing this with a delegate you would probably do that is the same place.
Override a function in ViewController
called prepare(for:UIStoryboardSegue,sender:Any?)
like this:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? UserProfileViewController { destination.userDataPublisher .sink(receiveValue: { (userProfile) in print("Received profile: \(userProfile)") self.yourNameHere.text = "Welcome, \(userProfile.firstName) \(userProfile.lastName)" }) } }
Once you’ve secured the destination UserProfileViewController
, you set up the subscription. That’s done using the .sink
function provided by the publisher interface. The value being received is the UserProfile
created in the saveProfile
function of UserProfileViewController
. In this example I’m printing it out as well as setting the UILabel
.
But wait, there’s a problem: the compiler is flagging the .sink
with a warning that its return value is not being used.
One important thing about Combine publishers is that they don’t work without subscribers (which you created with the .sink
). And subscribers do not exist unless you keep them someplace. The result of the .sink
is something called a Cancellable
which allows you to programmatically cancel a subscription which then means the publisher will no longer publisher.
At the top of the file, below the @IBOutlet
for the UILabel
, add this line:
private var userProfileSubscription: AnyCancellable?
This will hold the result of .sink
. Here is the completed prepare
function code:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? UserProfileViewController { userProfileSubscription = destination.userDataPublisher .sink(receiveValue: { (userProfile) in print("Received profile: \(userProfile)") self.yourNameHere.text = "Welcome, \(userProfile.firstName) \(userProfile.lastName)" }) } }
So what’s happening?
When the button in the UI is tapped to trigger the segue,
prepare
is called.The
UserProfileViewController
is assigned to thedestination
.A subscription is created to the
userDataPublisher
inUserProfileViewController
.The subscription is stored in the
userProfileSubscription
so it can be cancelled but more important, it will not disappear when theprepare
function ends.
So now you can run the app.
Tap on the User Profile button. The sheet slides up.
Fill in the first and last name fields.
Tap Save.
The first and last name are printed and appear in the
UILabel
.
Instead of using a delegate, you have used Combine. You published the data you wanted to transfer and picked that up by the ViewController
.
The benefits to this are not obvious at first, except maybe that there is a good separation of the two parts (ViewController
and UserProfileViewController
). Any part of the app that has access to the publisher can subscribe to it. If the user’s profile is something that could change at any time from anywhere, you might consider moving the publisher out of UserProfileViewController
to some place more universal and the view controller just becomes the interface to publish the changes and multiple places could pick it up. One place might change a greeting on the main navigation bar. Another place might store the profile in a database locally while another part of the app sends it off to a remote system. That just is not easily achieved using delegates.
Completion
The thing about publishers is that they keep publishing while there are subscribers and while they haven’t completed. Right now, the userDataPublisher
sends out the UserProfile
and then the UserProfileViewController
is dismissed. A better way is to send all the data and when finished, send a completion. Open UserProfileViewController
and go to the saveProfile
function and add this line right below the send
call:
userDataPublisher.send(completion: .finished)
What this does is send a notice of completion to all subscribers (aka, ViewController
) that says “this is all I’m going to send, I’m done.” Now back in ViewController
, change that .sink
expression to this:
.sink(receiveCompletion: { finalResult in print("Final Result is: \(finalResult)") }, receiveValue: { userProfile in print("Received profile: \(userProfile)") self.yourNameHere.text = "Welcome, \(userProfile.firstName) \(userProfile.lastName)" })
The .sink
now has two parts: one to handle the completion event and one to handle the received value. In the receiveValue
part, the UserProfile
is put to use while in the receiveCompletion
the finalResult
is just printed. If you run this now, fill in the first and last names and pick the Save button, not only does the UserProfile
get picked up, but the send(completion: .finished)
is picked up in the receiveCompletion
part and “finished”
is printed.
This is a little more complex, but you’ll see below how useful this can be. In the meantime, remove the dismiss
call from the saveProfile
function of UserProfileViewController
and put it inside the receiveCompletion
handler.
Since the ViewController
is responsible for presenting the User Profile sheet, it should also be responsible for taking it away. A great place to do that is in this completion handler of the .sink
.
.sink(receiveCompletion: { finalResult in print("Final Result is: \(finalResult)") self.dismiss(animated: true, completion: nil) }, receiveValue: { userProfile in print("Received profile: \(userProfile)") self.yourNameHere.text = "Welcome, \(userProfile.firstName) \(userProfile.lastName)" })
If you run the app now, it works just as it did before, but the Save button code won’t dismiss the sheet; it gets dismissed as a result of sending the completion. Think about this for a second: the sink’s receiveValue
is called each time the publisher has something to send. The receiveCompletion
is called when a finish
(or a failure
- more on that below) is sent; this also closes the publisher and prevents any more things from being sent. It gives you an opportunity for clean up: close a dialog (like here), clear memory, save data, etc.
Improvements
While things are working, this is not the best way to implement it. One problem is that any object that gets hold of the userDataPublisher
can publish UserProfile
values. You really do not want that. Instead, for this example, you want only the UserProfileViewController
to publish and ViewController
to subscribe but not have the ability to publish.
Open UserProfileViewController
again. You’re going to make a bunch of small changes which ultimately hide the actual publisher and offer a proxy instead which can only be used to listen for events.
First, rename userDataPublisher
to userDataSubject
and make it private
as in:
private let userDataSubject = PassthroughSubject<UserProfile, Never>()
Ignore the compiler warnings and errors and add a new userDataPublisher
:
public var userDataPublisher: AnyPublisher<UserProfile, Never> {
userDataSubject.eraseToAnyPublisher()
}
From ViewController
’s point of view, nothing has changed. The userDataPublisher
is still a publisher. It’s a ‘generic’ publisher that sends out UserProfile
s and never fails. The benefit here is only UserProfileViewController
can send events due to userDataSubject
being private. The generic AnyPublisher
can only publish.
One more thing: the saveProfile
has to use userDataSubject
and not userDataPublisher
so change that over, too.
When you run the app, it behaves just as it did, but now the publisher is more secure.
Errors
Up until now, the publisher sent out UserProfile
s, but not errors. But what if the stuff you are having the user enter has an error in it? For example, they forget to enter the last name.
When you use delegates, you can handle this a number of ways: allow empty strings or nils to be sent through and let the ViewController
worry about it. That’s not really good. A better way is to make use of the Error portion of Combine publishers.
Go back to UserProfile
and add in this custom Error:
enum UserProfileError: LocalizedError { case firstNameMissing case lastNameMissing var errorDescription: String? { switch self { case .firstNameMissing: return "First Name is missing" case .lastNameMissing: return "Last Name is missing" } } }
Go back into UserProfileViewController
and change both the userDataSubject
and userDataPublisher
lines by replacing the Never
error type with UserProfileError
as the error type:
private let userDataSubject = PassthroughSubject<UserProfile, UserProfileError>() public var userDataPublisher: AnyPublisher<UserProfile, UserProfileError> { userDataSubject.eraseToAnyPublisher() }
You have to tell what you are publishing: the type of data (UserProfile
) and the type of Errors (UserProfileError
). Now make these changes to the saveProfile
function:
@IBAction func saveProfile(_ sender: Any) { // create a UserProfile from the text fields and publish it if let firstName = firstName.text, let lastName = lastName.text { if firstName.isEmpty { userDataSubject.send(completion: .failure(.firstNameMissing)) } else if lastName.isEmpty { userDataSubject.send(completion: .failure(.lastNameMissing)) } else { let profile = UserProfile(firstName: firstName, lastName: lastName) userDataSubject.send(profile) userDataSubject.send(completion: .finished) } } }
Rather than just blindly send out whatever is entered into the first and last name fields, we’re going to check for validity and if either one is missing (blank), a failure is sent rather than a UserProfile
or the .finished
completion.
Think about this for a minute: when the data is good, you
send
it. But if there’s a problem, youfail
it. And you can fail it with a customError
that can be as simple as static cases or it can include more data and information; whatever you need to convey the problem.
This gets handled in the .sink
’s receiveCompletion
handler back in ViewController
:
if case .failure(let error) = finalResult { let alert = UIAlertController(title: "Missing Data", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alert, animated: true, completion: nil) }
I think if case
is a new construct for Swift so its syntax is a little strange, but it lets you test enum values which are not Equatable
without a full switch
statement. If the finalResult
is a failure
, this will post an alert with the message from the error. If you run the app again and leave out one of the fields from the sheet, you’ll see the Alert pop up.
Summary
What I’ve done is abolish the Delegate Pattern, at least for simple things. If you have controllers with complex delegates, Combine might not be the best thing, but keep in mind that Combine can do some powerful stuff (like filter
and map
) or perhaps you will need several publishers. Making your app more reactive should also make it less vulnerable to problems and more easily scalable.
This is the complete ViewController.swift
file. You can see that in the .sink
completion, the dismiss
function’s completion handler is used to examine the failure. This makes sure the sheet has been removed before posting the Alert; iOS gets touchy about having multiple view controllers showing up at the same time.
import UIKit import Combine class ViewController: UIViewController { @IBOutlet weak var yourNameHere: UILabel! // must retain the subscription otherwise when `prepare` function exits the // subscription will cancel. private var userProfileSubscription: AnyCancellable? override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. title = "Food Facts" } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { if let destination = segue.destination as? UserProfileViewController { // when the segue is about to begin, set up the subscription to the publisher // to get the user profile data userProfileSubscription = destination.userDataPublisher .sink(receiveCompletion: { finalResult in self.dismiss(animated: true) { // using a custom Error you can give very specific feeback to the user if case .failure(let error) = finalResult { let alert = UIAlertController(title: "Missing Data", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self.present(alert, animated: true, completion: nil) } } // get rid of this dependency self.userProfileSubscription = nil }, receiveValue: { userProfile in self.yourNameHere.text = "Welcome, \(userProfile.firstName) \(userProfile.lastName)" }) } } }
This is the complete UserProfileViewController.swift
file.
import UIKit import Combine class UserProfileViewController: UIViewController { @IBOutlet weak var firstName: UITextField! @IBOutlet weak var lastName: UITextField! // keep the publishing subject private so nothing else can publish data to it. private let userDataSubject = PassthroughSubject<UserProfile, UserProfileError>() // make this generic publisher availble instead and it can only be used to // listen for changes public var userDataPublisher: AnyPublisher<UserProfile, UserProfileError> { userDataSubject.eraseToAnyPublisher() } override func viewDidLoad() { super.viewDidLoad() // Do any additional setup after loading the view. } @IBAction func saveProfile(_ sender: Any) { // create a UserProfile from the text fields and publish it if let firstName = firstName.text, let lastName = lastName.text { if firstName.isEmpty { userDataSubject.send(completion: .failure(.firstNameMissing)) } else if lastName.isEmpty { userDataSubject.send(completion: .failure(.lastNameMissing)) } else { let profile = UserProfile(firstName: firstName, lastName: lastName) userDataSubject.send(profile) userDataSubject.send(completion: .finished) } } } }
This is the complete UserProfile.swift
file containing both the UserProfileError
and the UserProfile
itself.
struct UserProfile { let firstName: String let lastName: String } enum UserProfileError: LocalizedError { case firstNameMissing case lastNameMissing var errorDescription: String? { switch self { case .firstNameMissing: return "First Name is missing" case .lastNameMissing: return "Last Name is missing" } } }