Why SwiftUI and Combine will help you to build better apps
One of the biggest announcements at WWDC 2019 was SwiftUI - its declarative approach makes building UIs a breeze, and it’s easy to see why people are so excited about it. The hidden gem, however, was the Combine framework, which didn’t get as much fanfare as I think it deserves.
In this article, we will take a closer look at how to use SwiftUI and Combine together, build better apps, and have more fun along the way.
Ready? Let’s go!
What you are going to learn
- What the Combine framework is, and how to integrate it with SwiftUI
- What publishers, subscribers, and operators are, and how to use them
- How to organise your code
In the Beginning…
To help us reason about SwiftUI and Combine, we’re going to use a simple sign-up screen which lets users enter a username and password to create a new account in an application. In a later article, we will add a login screen to demonstrate some of the additional benefits of using Combine.
The data model for the sign-up screen is simple enough:
- Users need to enter their desired username
- They also need to pick a password
The requirements for username and password are pretty straight forward:
- The username must contain at least 3 characters
- The password must be non-empty and strong enough
- Also, to make sure the user didn’t accidentally mistype, they need to type their password a second time, and both of these passwords need to match up
Let’s put this down in code!
I’ve decided to use an MVVM architecture - this results in a clean code base and will make it easier to add new functionality to the app. First, let’s define the ViewModel which has a couple of properties taking the user’s input (such as the username and passwords), and - for the time being - a property to expose the result of any business logic we’re going to implement shortly.
class UserViewModel: ObservableObject {
// Input
@Published var username = ""
@Published var password = ""
@Published var passwordAgain = ""
// Output
@Published var isValid = false
}
For the sign-up screen, we use a Form
with several Section
s for the various input fields, which gives us a clean look and feel. It gets the job done, but doesn’t look very exciting. In the next episode, we’re going to brush it up to demonstrate how SwiftUI and Combine make it possible to make changes to your UI without having to modify the underlying business logic.
struct ContentView: View {
@ObservedObject private var userViewModel = UserViewModel()
var body: some View {
Form {
Section {
TextField("Username", text: $userViewModel.username)
.autocapitalization(.none)
}
Section {
SecureField("Password", text: $userViewModel.password)
SecureField("Password again", text: $userViewModel.passwordAgain)
}
Section {
Button(action: { }) {
Text("Sign up")
}.disabled(!userViewModel.valid)
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Notice how we’re using SwiftUI bindings to access the properties of our view model. The Sign up button is bound to the isValid
output property of the view model. As this defaults to false
, the button is initially disabled, which is what we want - after all, the user shouldn’t be able to create an account with an empty username and password!
This is how the UI looks so far:
Introducing Combine
Before implementing the validation logic for our sign-up form, let’s spend some time understanding how the Combine framework works.
According to the Apple documentation:
The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers. (source)
Let’s take a closer look at a couple of key concepts here to understand what this means and how it helps us.
Publishers
Publishers send values to one or more subscribers. They conform to the Publisher
protocol, and declare the type of output and any error they produce:
public protocol Publisher {
associatedtype Output
associatedtype Failure : Error
func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input
}
A publisher can send any number of values over time, or fail with an error. The associated type Output
defines which kinds of values a publisher can send, whereas the associated type Failure
defines the type of error it may fail with. A publisher can declare it never fails by specifying the Never
associated type.
Subscribers
Subscribers, on the other hand, subscribe to one specific publisher instance, and receive a stream of values, until the subscription is cancelled.
They conform to the Subscriber
protocol. In order to subscribe to a publisher, the subscriber’s associated Input
and Failure
types must conform to the publisher’s associated Output
and Failure
types.
public protocol Subscriber : CustomCombineIdentifierConvertible {
associatedtype Input
associatedtype Failure : Error
func receive(subscription: Subscription)
func receive(_ input: Self.Input) -> Subscribers.Demand
func receive(completion: Subscribers.Completion<Self.Failure>)
}
Operators
Publishers and subscribers are the backbone of SwiftUI’s two-way synchronisation between the UI and the underlying model. I think you will agree that it has never been easier to keep your UI and model in sync than with SwiftUI, and this is all thanks to this part of the Combine framework.
Operators, however, are Combine’s superpower. They are methods that operate on a Publisher
, perform some computation, and produce another Publisher
in return.
- For example, you can use the
filter
operator to ignore values based on certain conditions. - Or, if you need to perform an expensive task (such as fetching information across the network), you could use the
debounce
operator to wait until the user stops typing - The
map
operator allows you to transform input values of a certain type into output values of a different type
Validating the Username
With this in mind, let’s implement a simple validation to make sure the user entered a name that has at least three characters.
All properties on our view model are wrapped with the @Published
property wrapper. This means each property has its own publisher, which we can subscribe to.
To indicate whether a username is valid, we transform the user’s input from String
to Bool
using the map
operator:
$username
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { input in
return input.count >= 3
}
.assign(to: \.valid, on: self)
.store(in: &cancellableSet)
The result of this transformation is then consumed by the assign
subscriber, which - as the name implies - assigns the received value to the valid
output property of our view model.
Thanks to the binding we configured earlier in ContentView.swift
, SwiftUI will automatically update the UI whenever this property changes. We will later see why this approach is a bit problematic, but for now, it works just fine.
You might wonder what’s this fancy business with the debounce
and removeDuplicate
operators? Well, these are part of what makes Combine such a useful tool for connecting UIs to the underlying business logic. In all user interfaces, we have to deal with the fact that the user might type faster than we can fetch the information they’re requesting. For example, when typing their username, it is not necessary to check whether the username is valid or available for every single letter the user types. It is sufficient to perform this check only once they stop typing (or pause for a moment).
The debounce
operator lets us specify that we want to wait for a pause in the delivery of events, for example when the user stops typing.
Similarly, the removeDuplicates
operator will publish events only if they are different from any previous events. For example, if the user first types john
, then joe
, and then john
again, we will only receive john
once. This helps make our UI work more efficiently.
The result of this chain of calls is a Cancellable
, which we can use to cancel processing if required (useful for longer-running chains). We’ll store this (and all the others that we will create later on) into a Set<AnyCancellable>
, so we can clean up upon deinit
.
Validating the Password(s)
Let’s now switch gears and look into how we can perform multi-staged validation logic. This is required as the password fields on our form need to meet multiple requirements: they must not be empty, they must match up, and (most importantly) the chosen password must be strong enough. In addition to transforming the input values into a Bool
to indicate whether the passwords meet our requirements, we also want to provide some guidance for the user by returning an appropriate warning message.
Let’s take this one step at a time and begin by implementing the pipeline for validating the passwords the user entered.
private var isPasswordEmptyPublisher: AnyPublisher<Bool, Never> {
// (1)
$password
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { password in
return password == ""
}
.eraseToAnyPublisher()
}
Checking whether the password is empty is pretty straightforward, and you will notice this method is very similar to our implementation of the username validation. However, instead of directly assigning the result of the transformation to the isValid
output property, we return an AnyPublisher<Bool, Never>
. This is so we can later combine multiple publishers into a multi-stage chain before we subscribe to the final result (valid or not).
To verify if two separate properties contain equal strings, we make use of the CombineLatest
operator. Remember that the properties bound to the respective SecureField
fire each time the user enters a character, and we want to compare the latest value for each of those fields. CombineLatest
lets us do that.
private var arePasswordsEqualPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest($password, $passwordAgain)
.debounce(for: 0.2, scheduler: RunLoop.main)
.map { password, passwordAgain in
return password == passwordAgain
}
.eraseToAnyPublisher()
}
To compute the password strength, we use Navajo Swift, a Swift port of https://twitter.com/mattt?lang=en excellent Navajo library (2)., and convert the resulting enum into a Bool
by chaining on another publisher (isPasswordStrongEnoughPublisher
). This is the first time we subscribe to one of our own publishers, and very nicely shows how we can combine multiple publishers to produce the required output.
private var passwordStrengthPublisher: AnyPublisher<PasswordStrength, Never> {
$password
.debounce(for: 0.2, scheduler: RunLoop.main)
.removeDuplicates()
.map { input in
return Navajo.strength(ofPassword: input) // (2)
}
.eraseToAnyPublisher()
}
private var isPasswordStrongEnoughPublisher: AnyPublisher<Bool, Never> {
passwordStrengthPublisher
.map { strength in
switch strength {
case .reasonable, .strong, .veryStrong:
return true
default:
return false
}
}
.eraseToAnyPublisher()
}
In case you’re wondering why we need to call eraseToAnyPublisher()
at the end of each chain: this performs some type erasure that makes sure we don’t end up with some crazy nested return types.
Great - so we now know a lot about the passwords the user entered, let’s boil this down to the single thing we really want to know: is this a valid password?
As you might have guessed, we will need to use the CombineLatest
operator, but as this time around we have three parameters, we’ll use CombineLatest3
, which takes three input parameters.
enum PasswordCheck {
case valid
case empty
case noMatch
case notStrongEnough
}
private var isPasswordValidPublisher: AnyPublisher<PasswordCheck, Never> {
Publishers.CombineLatest3(isPasswordEmptyPublisher, arePasswordsEqualPublisher, isPasswordStrongEnoughPublisher)
.map { passwordIsEmpty, passwordsAreEqual, passwordIsStrongEnough in
if (passwordIsEmpty) {
return .empty
}
else if (!passwordsAreEqual) {
return .noMatch
}
else if (!passwordIsStrongEnough) {
return .notStrongEnough
}
else {
return .valid
}
}
.eraseToAnyPublisher()
}
The main reason why we map the three booleans to a single enum is that we want to be able to produce a suitable warning message depending on the result of the validation. Telling the user that their password is no good is not very helpful, is it? Much better if we tell them why it’s not valid.
Putting it All Together
To compute the final result of the validation, we need to combine the result of the username validation with the result of the password validation. However, before we can do this, we need to refactor the username validation so it also returns a publisher that we include in our validation chain.
private var isUsernameValidPublisher: AnyPublisher<Bool, Never> {
$username
.debounce(for: 0.8, scheduler: RunLoop.main)
.removeDuplicates()
.map { input in
return input.count >= 3
}
.eraseToAnyPublisher()
}
With this done, we can implement the final stage of the form validation:
private var isFormValidPublisher: AnyPublisher<Bool, Never> {
Publishers.CombineLatest(isUsernameValidPublisher, isPasswordValidPublisher)
.map { usernameIsValid, passwordIsValid in
return usernameIsValid && (passwordIsValid == .valid)
}
.eraseToAnyPublisher()
}
By now, this should look rather familiar to you.
Updating the UI
None of this would be very useful without connecting it to the UI. To drive the state of the Sign up button, we need to update the isValid
output property on our view model.
To do so, we simply subscribe to the isFormValidPublisher
and assign the values it publishes to the isValid
property:
init() {
isFormValidPublisher
.receive(on: RunLoop.main)
.assign(to: \.isValid, on: self)
.store(in: &cancellableSet)
}
As this code interfaces with the UI, it needs to run on the UI thread. We can tell SwiftUI to execute this code on the UI thread by calling receive(on: RunLoop.main)
.
Let’s finish off with binding the warning message output properties to the UI to help guide the user through filling out the sign-up form.
First, we subscribe to the respective publishers to learn when the username
vs. password
properties are invalid. Again, we need to make sure this happens on the UI thread, so we’ll call receive(on:)
and pass the main run loop.
init() {
// ...
isUsernameValidPublisher
.receive(on: RunLoop.main)
.map { valid in
valid ? "" : "username must at leat have 3 characters"
}
.assign(to: \.usernameMessage, on: self)
.store(in: &cancellableSet)
isPasswordValidPublisher
.receive(on: RunLoop.main)
.map { passwordCheck in
switch passwordCheck {
case .empty:
return "Password must not be empty"
case .noMatch:
return "Passwords don't match"
case .notStrongEnough:
return "Password not strong enough"
default:
return ""
}
}
.assign(to: \.passwordMessage, on: self)
.store(in: &cancellableSet)
}
Finally, we need to bind the output properties usernameMessage
and passwordMessage
to the UI. The section footers are a convenient place to show error messages, and we can make them stand out nicely by colouring them in red:
var body: some View {
Form {
Section(footer: Text(userModel.usernameMessage).foregroundColor(.red)) {
// ...
}
Section(footer: Text(userModel.passwordMessage).foregroundColor(.red)) {
// ...
}
// ...
}
}
And here is the result of our hard work in all its glory:
Conclusion
Building UIs with SwiftUI is a breeze. Apple has sweated the details to give us the tools that make building UIs more productive than ever before. On top of that, SwiftUI follows Apple’s Human Interface Guidelines , automatically adapts to dark mode and has accessibility built right in. All of this helps to build better, and more inclusive apps in less time - what’s not to like?
Using Combine results in a cleaner and more modular code that (as we will see in the next episode) is more maintainable and easier to extend.
Of course, like every new paradigm, there is a learning curve, and it will take some time to get to grips with functional reactive programming. But I am convinced it’s worth the effort. By releasing SwiftUI and Combine, Apple have put their sign of approval on Functional Reactive Programming, and soon it will no longer be a technique that only a few development teams use. We will see more and more learning resources to help people get started. Also (and this has been a bit of a sore point in the latest beta releases of Xcode), tooling will get better over time, helping developers to be more productive.
Now is a great time to get started with SwiftUI and Combine - try using them in one of your next projects to get a head start!
Top comments (1)
Thanks for sharing Super Cool