An Introduction to 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 @IBOutlets:

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 UITextFields 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?

  1. When the button in the UI is tapped to trigger the segue, prepare is called.

  2. The UserProfileViewController is assigned to the destination.

  3. A subscription is created to the userDataPublisher in UserProfileViewController.

  4. The subscription is stored in the userProfileSubscription so it can be cancelled but more important, it will not disappear when the prepare function ends.

So now you can run the app.

  1. Tap on the User Profile button. The sheet slides up.

  2. Fill in the first and last name fields.

  3. Tap Save.

  4. 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 UserProfiles 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 UserProfiles, 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, you fail it. And you can fail it with a custom Error 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"
        }
    }
}
Previous
Previous

SwiftUI Modifiers

Next
Next

Using Targets to structure your IOS app