One of the most challenging aspects of the modern application development paradigm is the concept of reactive programming. And 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 react to user interactions seamlessly 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 I will be giving you the Swift Combine 101 and explore the Combine framework for SwiftUI developers.
This article will define what the Combine framework is and why it is so important. Additionally, we will 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 creating a simple name validator that makes a network request, handles the result, and updates the UI accordingly.
Alright. Let’s jump right 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.
Combine 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.
Why is Combine important in SwiftUI?
As I have mentioned already, the Combine framework makes the process of making your UI reactive much easier. But that is 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 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 is pretty common on network implementations.
You can significantly simplify this once you understand how to implement publishers.
By the end of this article, you will 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 are wondering what the operators in this Publisher do, here’s a quick rundown.
- ‘map’ allows you to de-structure 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 our app view model.
Here, you will be defining the properties that will serve as our 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 () {
}
}
Computed Properties
Next, create some computed properties that will serve to 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 multi-stage chain before subscribing to the final result, which resides in the isNameValidPublisher
property.
Operators
If you are 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 only to perform its operation after a slight delay. This is very 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:
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 world that is the Combine framework introduced in 2019. I 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 is a great time to experiment and sharpen your skills with these new tools.
However, it is crucial to keep your code bug free and well tested.
Check out my other posts about the topic here.
Leave a Reply