Form Validation with Combine

In WWDC 2019 session Combine in Practice we learned how to apply the Combine Framework to perform validation on a basic sign up form built with UIKit. Now we want to apply the same solution to the SwiftUI version of that form which requires some adaptation.

We begin by declaring a simple form model separate from the view…

class SignUpFormModel: ObservableObject {
  @Published var username: String = ""
  @Published var password: String = ""
  @Published var passwordAgain: String = ""
}

And link each property to the corresponding TextField control…

struct SignUpForm: View {

  @ObservedObject var model = SignUpFormModel()

  var body: some View {
    
    TextField("Username", text: $model.username)
    
    TextField("Password 'secreto'", text: $model.password)
    
    TextField("Password again", text: $model.passwordAgain)
    

Now we can begin declaring the publishers in our SignUpFormModel. First we want to make sure the password has more than six characters, and that it matches the confirmation field. For simplicity we will not use an error type, we will instead return invalid when the criteria is not met…

var validatedPassword: AnyPublisher<String?, Never> {
  $password.combineLatest($passwordAgain) { password, passwordAgain in
    guard password == passwordAgain, password.count > 6 else {
      return "invalid"
    }
    return password
  }
  .map { $0 == "password" ? "invalid" : $0 }
  .eraseToAnyPublisher()
}

For the user name we want to simultate an asynchronous network request that checks whether the chosen moniker is already taken…

func usernameAvailable(_ username: String, completion: @escaping (Bool) -> ()) -> () {
  DispatchQueue.main .async {
    if (username == "foobar") {
      completion(true)
    } else {
      completion(false)
    }
  }
}

As you can see, the only available name in our fake server is foobar.

We don’t want to hit our API every second the user types into the name field, so we leverage debounce() to avoid this…

var validatedUsername: AnyPublisher<String?, Never> {
  return $username
    .debounce(for: 0.5, scheduler: RunLoop.main)
    .removeDuplicates()
    .flatMap { username in
      return Future { promise in
        usernameAvailable(username) { available in
          promise(.success(available ? username : nil))
        }
      }
  }
  .eraseToAnyPublisher()
}

Now to make use of this publisher we need some kind of indicator next to the text box to tell us whether we are making an acceptable choice. The indicator should be backed by a private @State variable in the view and outside the model.

To connect the indicator to the model’s publisher we leverage the onReceive() modifier. On the completion block we manually update the form’s current state…

Text(usernameAvailable ? "✅" : "❌")
.onReceive(model.validatedUsername) {
  self.usernameAvailable = $0 != nil
}

An analog indicator can be declared for the password fields.

Finally, we want to combine our two publishers to create an overall validation of the form. For this we create a new publisher…

var validatedCredentials: AnyPublisher<(String, String)?, Never> {
  validatedUsername.combineLatest(validatedPassword) { username, password in
    guard let uname = username, let pwd = password else { return nil }
    return (uname, pwd)
  }
  .eraseToAnyPublisher()
}

We can then hook this validation directly into our Sign Up button and its disabled state.

  Button("Sign up") {  }
  .disabled(signUpDisabled)
  .onReceive(model.validatedCredentials) {
    guard let credentials = $0 else {
      self.signUpDisabled = true
      return
    }
    let (validUsername, validPassword) = credentials
    guard validPassword != "invalid"  else {
      self.signUpDisabled = true
      return
    }
    self.signUpDisabled = false
  }
}

For sample code featuring this and other techniques please checkout our working examples repo.

FEATURED EXAMPLE
Fake Signup
Validate your new credentials