DEV Community

Peter Friese
Peter Friese

Posted on • Originally published at peterfriese.dev on

SwiftUI + Combine = ❤️

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
}
Enter fullscreen mode Exit fullscreen mode

For the sign-up screen, we use a Form with several Sections 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()
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

screenshot dark2

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
}
Enter fullscreen mode Exit fullscreen mode

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>)
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
  }
Enter fullscreen mode Exit fullscreen mode

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()
  }
Enter fullscreen mode Exit fullscreen mode

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()
  }
Enter fullscreen mode Exit fullscreen mode

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.

we need to go deeper

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()
  }
Enter fullscreen mode Exit fullscreen mode

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()
  }
Enter fullscreen mode Exit fullscreen mode

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()
  }
Enter fullscreen mode Exit fullscreen mode

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)
  }
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)) {
        // ...
      }
      // ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

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!

Where to go from here?

Top comments (1)

Collapse
 
yourdevguy profile image
Mike Haslam

Thanks for sharing Super Cool