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.