Waldo joins Tricentis, expanding mobile testing for higher-quality mobile apps – Learn more
App Development

Combine for SwiftUi Developers: What It Is And Why You Should Use It

Juan Reyes
Juan Reyes
Combine for SwiftUi Developers: What It Is And Why You Should Use It
July 26, 2022
12
min read

Reactive programming is one of the most challenging aspects of the modern application development paradigm. Since its inception, it has dominated the development sphere despite the complexity and overhead that it added during its infancy due to the significant benefits it offers. Keeping the UI updated and having it seamlessly react to users became a core part of the development process and practically a must-have for modern applications.

However, the reactive programming paradigm didn't find its way to Swift until the introduction of the Combine framework. Before that, it was a cumbersome and convoluted nightmare of resource consumption and code overhead. So, today we'll explore the Combine framework for SwiftUI developers.

This article will define what the Combine framework for SwiftUI is and why it's so important. Additionally, we'll specify publishers, subscribers, and operations and how you can use them to construct a robust reactive UI experience in SwiftUI with a helpful example. This example will illustrate a simple name validator that makes a network request, handles the result, and updates the UI accordingly.

Alright. Let's jump in.

What Is Combine in SwiftUI?

As described by the official Swift documentation, "The Combine framework provides a declarative Swift API for processing values over time. ... Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers."

What does all this mean? In essence, Combine is a framework that handles asynchronous events and maintains states in the lifecycle of an application. It achieves this by using the publisher and subscriber protocols to handle data synchronization across the application and making the UI react to its changes. Additionally, Combine helps process values in a continuous flow by using operators that you can chain together.

Now, what are publishers and subscribers, you might be wondering? Here's what Apple has to say about them:

Publisher: "The Publisher protocol declares a type that can deliver a sequence of values over time."

Subscriber: "At the end of a chain of publishers, a Subscriber acts on elements as it receives them."

Again, not very clear or helpful.

The basic idea is that a publisher exposes values on an object that can change and can be "subscribed" to or observed. This is pretty much equivalent to what an observable is on your average React programming paradigm. A subscriber, on the other hand, receives the values provided by a publisher and creates the groundwork for handling the result asynchronously, allowing you to update the UI accordingly. This is pretty much what an observer is.

One example of a good use case for implementing the Combine framework would be performing and handling network operations or sharing state variables between classes.

One of the main advantages the Combine framework offers is streamlining asynchronous processes and simplifying the structure and maintainability of code

Why Is Combine Important in SwiftUI?

As mentioned already, the Combine framework makes the process of making your UI reactive much easier. But that's not all. One of the main advantages the Combine framework offers is streamlining asynchronous processes and simplifying the structure and maintainability of code.

One of the most common examples is the implementation of a network request handler to perform and parse the result from a server. Typically, this would require some code containing some overhead due to the validations and safeguards necessary to provide robust functionality.


enum NetworkError: Error {
  case invalidRequestError(String)
  case transportError(Error)
  case serverError(statusCode: Int)
  case noData
  case dataError(Error)
}
func validateName(name: String, completion: @escaping (Result<Bool, NetworkError>) -> Void) {
  guard let url = URL(string: "http://127.0.0.1:8080/isNameValid?name=\(name)") else {
    completion(.failure(.invalidRequestError("Invalid URL")))
    return
  }
  let networkTask = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
      completion(.failure(.transportError(error)))
      return
    }
    if let response = response as? HTTPURLResponse, (200...299).contains(response.statusCode) == false {
      completion(.failure(.serverError(statusCode: response.statusCode)))
      return
    }
    guard let data = data else {
      completion(.failure(.noData))
      return
    }
    do {
      completion(.success(data))
    } catch {
      completion(.failure(.dataError(error)))
    }
  }
  task.resume()
}

As you can see, there are a lot of validations and code that are common on network implementations.

You can significantly simplify this once you understand how to implement publishers. By the end of this article, you'll end with something like the following:


struct NameAvailableMessage: Codable {
  var isAvailable: Bool
  var name: String
}
enum NetworkError: Error {
  case invalidRequestError(String)
  case transportError(Error)
  case serverError(statusCode: Int)
  case noData
  case decodingError(Error)
  case encodingError(Error)
}
struct NetworkService {
    func checkNameAvailable(name: String) -> AnyPublisher<Bool, Never> {
        guard let url = URL(string: "http://127.0.0.1:8080/isNameValid?name=\(name)") else {
          return Just(false).eraseToAnyPublisher()
        }
        return URLSession.shared.dataTaskPublisher(for: url)
          .map(\.data)
          .decode(type: NameAvailableMessage.self,
                  decoder: JSONDecoder())
          .map(\.isAvailable)
          .replaceError(with: false)
          .eraseToAnyPublisher()
      }
}

Much better, right?

How to Use Combine in SwiftUI

The first thing you need to do to implement the name validator is to create a project. You can go ahead and create one in XCode. Then create a new class file named 'NetworkService' and add the code from above.

If you're wondering what the operators in this publisher do, here's a quick rundown.

  • 'Map' allows you to destructure a tuple using a key path and access just the attribute you need.
  • 'Decode' decodes the data from the upstream publisher into a NameAvailableMessage instance.
  • 'EraseToAnyPublisher' unwraps the result so it's not nested in multiple 'Publisher.map<>' wrappers.

Now create a new class named 'ContentViewModel.swift' representing the app view model.

Here you will define the properties that will serve as your publishing containers. The purpose of these properties is to hold and inform the subscribers of the value your publishers will update. These values will come either from user input or the network processes. However, keep in mind that publishers can handle values coming from any asynchronous source.

In this case, define the 'name' and 'nameMessage' properties and label them with the '@Publisher' wrapper directive as follows:


import Foundation
import Combine
class ContentViewModel: ObservableObject {
    @Published var name = ""
    @Published var nameMessage = ""
    init () {
    }
}

Next, create some computed properties that will evaluate the publishers' output and indicate the subscribers of these publishers.


import Foundation
import Combine
class ContentViewModel: ObservableObject {
    @Published var name = ""
    @Published var nameMessage = ""
    private var networkService = NetworkService()
    private var isNameEmptyPublisher: AnyPublisher<Bool, Never> {
        $name.debounce(for: 0.8,
                       scheduler: RunLoop.main)
            .removeDuplicates()
            .map { name in
              return name == ""
            }
            .eraseToAnyPublisher()
    }
    private lazy var isNameAvailablePublisher: AnyPublisher<Bool, Never> = {
        $name.flatMap { name -> AnyPublisher<Bool, Never> in
            self.networkService.checkNameAvailable(name: name)
          }
          .receive(on: DispatchQueue.main)
          .eraseToAnyPublisher()
    }()
    private var isNameValidPublisher: AnyPublisher<Bool, Never> {
        Publishers.CombineLatest(isNameEmptyPublisher, isNameAvailablePublisher)
              .map { nameIsEmpty, nameIsAvailable in
                return !nameIsEmpty && nameIsAvailable
              }
            .eraseToAnyPublisher()
    }
    init () {
    }
}

Notice that there are two distinct properties that check different scenarios for the publisher. One is the 'isEmpty' scenario, and the other is the 'isAvailable' scenario. They are labeled as publishers and are of type 'AnyPublisher<Bool, Never>', where 'Bool' indicates the result and 'Never' indicates that it never fails.

This arrangement allows you to combine multiple publishers into a multistage chain before subscribing to the final result, which resides in the 'isNameValidPublisher' property.

The 'debounce' operator allows the publisher to perform its operation only after a slight delay.

Operators

If you're wondering what all these operators in the publisher do, it's pretty simple. These are called operators, and they allow you to set some rules and modifications to the publisher's behaviors.

The 'debounce' operator allows the publisher to perform its operation only after a slight delay. This is useful for input-related network calls so that requests are not performed every time the user types a character but when the user is done or has paused.

The 'removeDuplicates' operator publishes events only if they differ from previous events.

The 'map' operator alters the type of the result to conform to the returning type of the publisher, in this case, a Boolean to indicate if the value is valid or not.

Validating the Publisher

To validate the publisher, you need to initialize the view model with the 'isNameValidPublisher' property as follows:


import Foundation
import Combine
class ContentViewModel: ObservableObject {
    @Published var name = ""
    @Published var nameMessage = ""
    private var networkService = NetworkService()
    private var isNameEmptyPublisher: AnyPublisher<Bool, Never> {
        $name.debounce(for: 0.8,
                       scheduler: RunLoop.main)
            .removeDuplicates()
            .map { name in
              return name == ""
            }
            .eraseToAnyPublisher()
    }
    private lazy var isNameAvailablePublisher: AnyPublisher<Bool, Never> = {
        $name.flatMap { name -> AnyPublisher<Bool, Never> in
            self.networkService.checkNameAvailable(name: name)
          }
          .receive(on: DispatchQueue.main)
          .eraseToAnyPublisher()
    }()
    private var isNameValidPublisher: AnyPublisher<Bool, Never> {
        Publishers.CombineLatest(isNameEmptyPublisher, isNameAvailablePublisher)
              .map { nameIsEmpty, nameIsAvailable in
                return !nameIsEmpty && nameIsAvailable
              }
            .eraseToAnyPublisher()
    }
    init () {
        isNameValidPublisher
              .map { $0 ? "" : "name is not cool enough. Try another one!"}
              .assign(to: \.nameMessage, on: self)
    }
}

Now you need to update the 'ContentView' class file to contain the TextField and label described in the code.


import SwiftUI
import Combine
struct ContentView: View {
    @StateObject private var viewModel = ContentViewModel()
    var body: some View {
        Form {
              // Username
              Section {
                TextField("Type a name", text: $viewModel.name)
                  .autocapitalization(.none)
                  .disableAutocorrection(true)
              } footer: {
                Text(viewModel.nameMessage)
                  .foregroundColor(.red)
              }
        }
    }
}

This code will yield the following view:

code preview

Once all this is done, you can start inputting names into the app and check how the label responds accordingly. Remember that you need to have an API to point to under the endpoint we defined on the NetworkService.

This was a brief exploration of the Combine framework introduced in 2019. We encourage you to continue expanding your experience with the framework with these resources.

Conclusion

Creating elegant and responsive applications on SwiftUI has never been easier. Thanks to the robust foundation framework and the extensive documentation you can find on the web, it's a great time to experiment and sharpen your skills with these new tools.

However, it's crucial to keep your code bug free and well tested. To alleviate all the pains that come with proper testing, we recommend you check out Waldo's code-free testing platform.

Automated E2E tests for your mobile app

Creating tests in Waldo is as easy as using your app!
Learn more about our Automate product, or try our live testing tool Sessions today.

Reproduce, capture, and share bugs fast!

Waldo Sessions helps mobile teams reproduce bugs, while compiling detailed bug reports in real time.