How to validate field using Combine in SwiftUI

DevTechie Inc
Dec 7, 2022


Photo by Jerin J on Unsplash

In scenarios where we ask users to fill out information, we also want to make sure that the data entered by the user is correct. Which is where field validation comes into picture.

Fields can vary based on the requested information so does the logic for validation.

If we take simple sign-up page with username, password and confirm password fields as an example. All three fields would require different validation rules.

In this article, we will use Apple’s Combine framework to add validations. Here is what our end product will look like:

Let’s get started. We will start with the brain of this project, meaning combine logic for validation.

Start by creating a new “Swift” file and name it “DevTechieSignUpVM”

We will import combine framework with the line below:

import Combine
This file will contain ViewModel for sign up page and it will be a final class as we are not planning to subclass. We also want to conform to the ObservableObject protocol, which will help us publish events to our UI.

final class DevTechieSignUpVM: ObservableObject
Next, we need three published variables for view binding as well as to attach publishers to these variables.

@Published var username = ""
@Published var password = ""
@Published var confirmPassword = ""
We also need a published variable to keep state of validation:

@Published var isValid = false
We need three published properties to store validation error messages:

@Published var usernameErrorMessage = ""
@Published var passwordErrorMessage = ""
@Published var confirmPasswordErrorMessage = ""
One last variable to store cancellable tokens:

private var cancellable: Set<AnyCancellable> = []
Here is how our view model looks like

final class DevTechieSignUpVM: ObservableObject {
    @Published var username = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    
    @Published var isValid = false
    @Published var usernameErrorMessage = ""
    @Published var passwordErrorMessage = ""
    @Published var confirmPasswordErrorMessage = ""
    
    private var cancellable: Set<AnyCancellable> = []
    
}
Time to write publishers. We will start with username publisher in a computed property to validate username field. This publisher will return boolean type and will not throw error. We also want to debounce input so it only publishes elements after a specified time interval elapses between events. We will remove duplicates and make sure that the user entry is 8 chars or long.

private var validUsernamePublisher: AnyPublisher<Bool, Never> {
    $username
        .debounce(for: 0.5, scheduler: RunLoop.main)
        .removeDuplicates()
        .map { $0.count >= 8 }
        .eraseToAnyPublisher()
}
Note: .debounce() function only sends events if they happen in the interval and are passed as parameters; that way, the subscriber is not bombarded by events.Note: eraseToAnyPublisher() is used to flatten publishers into a generic Publisher type.
Similarly, we will write a publisher for password length validation:

private var passLengthPublisher: AnyPublisher<Bool, Never> {
    $password
        .debounce(for: 0.5, scheduler: RunLoop.main)
        .removeDuplicates()
        .map { $0.count >= 12 }
        .eraseToAnyPublisher()
}
In order to check if the password is strong or not, we will create a computed property as a String extension so create a new file with name “String+Extensions” and add following code:

extension String {
    var isPasswordStrong: Bool {
        containsCharSet(set: .uppercaseLetters) &&
        containsCharSet(set: .lowercaseLetters) &&
        containsCharSet(set: .decimalDigits) &&
        containsCharSet(set: .alphanumerics.inverted)
    }
    
    private func containsCharSet(set: CharacterSet) -> Bool {
        rangeOfCharacter(from: set) != nil
    }
}
Back in the view model file, we will add password strength validation publisher and use this newly created extension of string.

private var passStrengthPublisher: AnyPublisher<Bool, Never> {
    $password
        .debounce(for: 0.2, scheduler: RunLoop.main)
        .removeDuplicates()
        .map {password in password.isPasswordStrong }
        .eraseToAnyPublisher()
}
As we can see that, we have two publishers for password handling two different validation cases. We want to combine them so they both can work side by side so let’s create another publisher which will combine these two validation publishers.

Before we start creating our publisher, we need to determine as what will it return. If password is weak, we want to indicate that password is weak and if password’s length is not per our requirement, we want to indicate that password is not long enough. This situation calls for another data structure which will help us return status based upon the case so let’s add an enum to handle three cases, valid, invalidLength and weakPassword:

enum PasswordValidationCase {
    case valid
    case invalidLength
    case weakPassword
}
Now, we are ready for a combined publisher. This publisher will return PasswordValidationCase and will not throw error:

private var validPasswordPublisher: AnyPublisher<PasswordValidationCase, Never> {
    Publishers
        .CombineLatest(passLengthPublisher, passStrengthPublisher)
        .map { validLength, validStrength in
            if !validLength {
                return .invalidLength
            }
            if !validStrength {
                return .weakPassword
            }return .valid
        }
        .eraseToAnyPublisher()
}
We need validation publisher for confirm password so let’s create that next. This validation requires values from both password and confirm password fields and since both of them are publishers too the validation publisher will combine them and return boolean if they have same values.

private var validConfirmPasswordPublisher: AnyPublisher<Bool, Never> {
    Publishers
        .CombineLatest($password, $confirmPassword)
        .debounce(for: 0.2, scheduler: RunLoop.main)
        .map { pass, confirmPass in
            pass == confirmPass
        }
        .eraseToAnyPublisher()
}
Last but not the least, we need a publisher, which will combine all three publishers (validUsername, validPassword, validConfirmPassword) and will return a boolean to indicate if the form is valid or not:

private var validFormPublisher: AnyPublisher<Bool, Never> {
    Publishers
        .CombineLatest3(validUsernamePublisher, validPasswordPublisher, validConfirmPasswordPublisher)
        .map { usernameValid, passwordValid, confirmPassValid in
            usernameValid && (passwordValid == .valid) && confirmPassValid
        }
        .eraseToAnyPublisher()
}
Our view model code should look like this:

import Combinefinal class DevTechieSignUpVM: ObservableObject {
    @Published var username = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    
    @Published var isValid = false
    @Published var usernameErrorMessage = ""
    @Published var passwordErrorMessage = ""
    @Published var confirmPasswordErrorMessage = ""
    
    private var cancellable: Set<AnyCancellable> = []
    
    private var validUsernamePublisher: AnyPublisher<Bool, Never> {
        $username
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { $0.count >= 8 }
            .eraseToAnyPublisher()
    }
    
    private var passLengthPublisher: AnyPublisher<Bool, Never> {
        $password
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { $0.count >= 12 }
            .eraseToAnyPublisher()
    }
    
    private var passStrengthPublisher: AnyPublisher<Bool, Never> {
        $password
            .debounce(for: 0.2, scheduler: RunLoop.main)
            .removeDuplicates()
            .map {password in password.isPasswordStrong }
            .eraseToAnyPublisher()
    }
    
    private var validPasswordPublisher: AnyPublisher<PasswordValidationCase, Never> {
        Publishers
            .CombineLatest(passLengthPublisher, passStrengthPublisher)
            .map { validLength, validStrength in
                if !validLength {
                    return .invalidLength
                }
                if !validStrength {
                    return .weakPassword
                }
                
                return .valid
            }
            .eraseToAnyPublisher()
    }
    
    private var validConfirmPasswordPublisher: AnyPublisher<Bool, Never> {
        Publishers
            .CombineLatest($password, $confirmPassword)
            .debounce(for: 0.2, scheduler: RunLoop.main)
            .map { pass, confirmPass in
                pass == confirmPass
            }
            .eraseToAnyPublisher()
    }
    
    private var validFormPublisher: AnyPublisher<Bool, Never> {
        Publishers
            .CombineLatest3(validUsernamePublisher, validPasswordPublisher, validConfirmPasswordPublisher)
            .map { usernameValid, passwordValid, confirmPassValid in
                usernameValid && (passwordValid == .valid) && confirmPassValid
            }
            .eraseToAnyPublisher()
    }
    
}
Once we have all the publishers ready, add publisher subscriptions and assign them to apt variables in init for our view model class.

validUsernamePublisher
    .receive(on: RunLoop.main)
    .map { $0 ? "" : "Username must be 8 or more characters long."}
    .assign(to: \.usernameErrorMessage, on: self)
    .store(in: &cancellable)validPasswordPublisher
    .receive(on: RunLoop.main)
    .map { passValidationCase in
        switch passValidationCase {
        case .invalidLength:
            return "Password must be 12 or more characters long."
        case .weakPassword:
            return "Password is weak."
        default:
            return ""
        }
    }
    .assign(to: \.passwordErrorMessage, on: self)
    .store(in: &cancellable)validConfirmPasswordPublisher
    .receive(on: RunLoop.main)
    .map { $0 ? "" : "Passwords don't match."}
    .assign(to: \.confirmPasswordErrorMessage, on: self)
    .store(in: &cancellable)validFormPublisher
    .receive(on: RunLoop.main)
    .assign(to: \.isValid, on: self)
    .store(in: &cancellable)
Complete class should look like this:

final class DevTechieSignUpVM: ObservableObject {
    @Published var username = ""
    @Published var password = ""
    @Published var confirmPassword = ""
    
    @Published var isValid = false
    @Published var usernameErrorMessage = ""
    @Published var passwordErrorMessage = ""
    @Published var confirmPasswordErrorMessage = ""
    
    private var cancellable: Set<AnyCancellable> = []
    
    private var validUsernamePublisher: AnyPublisher<Bool, Never> {
        $username
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { $0.count >= 8 }
            .eraseToAnyPublisher()
    }
    
    private var passLengthPublisher: AnyPublisher<Bool, Never> {
        $password
            .debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { $0.count >= 12 }
            .eraseToAnyPublisher()
    }
    
    private var passStrengthPublisher: AnyPublisher<Bool, Never> {
        $password
            .debounce(for: 0.2, scheduler: RunLoop.main)
            .removeDuplicates()
            .map {password in password.isPasswordStrong }
            .eraseToAnyPublisher()
    }
    
    private var validPasswordPublisher: AnyPublisher<PasswordValidationCase, Never> {
        Publishers
            .CombineLatest(passLengthPublisher, passStrengthPublisher)
            .map { validLength, validStrength in
                if !validLength {
                    return .invalidLength
                }
                if !validStrength {
                    return .weakPassword
                }
                
                return .valid
            }
            .eraseToAnyPublisher()
    }
    
    private var validConfirmPasswordPublisher: AnyPublisher<Bool, Never> {
        Publishers
            .CombineLatest($password, $confirmPassword)
            .debounce(for: 0.2, scheduler: RunLoop.main)
            .map { pass, confirmPass in
                pass == confirmPass
            }
            .eraseToAnyPublisher()
    }
    
    private var validFormPublisher: AnyPublisher<Bool, Never> {
        Publishers
            .CombineLatest3(validUsernamePublisher, validPasswordPublisher, validConfirmPasswordPublisher)
            .map { usernameValid, passwordValid, confirmPassValid in
                usernameValid && (passwordValid == .valid) && confirmPassValid
            }
            .eraseToAnyPublisher()
    }
    
    init() {
        validUsernamePublisher
            .receive(on: RunLoop.main)
            .map { $0 ? "" : "Username must be 8 or more characters long."}
            .assign(to: \.usernameErrorMessage, on: self)
            .store(in: &cancellable)
        
        validPasswordPublisher
            .receive(on: RunLoop.main)
            .map { passValidationCase in
                switch passValidationCase {
                case .invalidLength:
                    return "Password must be 12 or more characters long."
                case .weakPassword:
                    return "Password is weak."
                default:
                    return ""
                }
            }
            .assign(to: \.passwordErrorMessage, on: self)
            .store(in: &cancellable)
        
        validConfirmPasswordPublisher
            .receive(on: RunLoop.main)
            .map { $0 ? "" : "Passwords don't match."}
            .assign(to: \.confirmPasswordErrorMessage, on: self)
            .store(in: &cancellable)
        
        validFormPublisher
            .receive(on: RunLoop.main)
            .assign(to: \.isValid, on: self)
            .store(in: &cancellable)
    }
}

The View
We will have a simple view with DevTechieSignUp StateObject.

struct ContentView: View {
    @StateObject private var dtSignupVM = DevTechieSignUpVM()
    
    var body: some View {
        NavigationStack {
            VStack(alignment: .leading) {
                VStack {
                    TextField("Enter username", text: $dtSignupVM.username)
                        .textFieldStyle(.roundedBorder)
                    Text(dtSignupVM.usernameErrorMessage)
                        .foregroundColor(.red)
                        .opacity(dtSignupVM.usernameErrorMessage.isEmpty ? 0.0 : 1.0)
                }
                
                VStack {
                    SecureField("Enter password", text: $dtSignupVM.password)
                        .textFieldStyle(.roundedBorder)
                    Text(dtSignupVM.passwordErrorMessage)
                        .foregroundColor(.red)
                        .opacity(dtSignupVM.passwordErrorMessage.isEmpty ? 0.0 : 1.0)
                }
                
                VStack {
                    SecureField("Confirm password", text: $dtSignupVM.confirmPassword)
                        .textFieldStyle(.roundedBorder)
                    Text(dtSignupVM.confirmPasswordErrorMessage)
                        .foregroundColor(.red)
                        .opacity(dtSignupVM.confirmPasswordErrorMessage.isEmpty ? 0.0 : 1.0)
                }
                
                HStack {
                    Spacer()
                    Button {
                        print("Login")
                    } label: {
                        Label("Login", systemImage: "lock.shield")
                    }
                    .buttonStyle(.bordered)
                    .disabled(!dtSignupVM.isValid)
                }}
            .padding()
            .navigationTitle("DevTechie.com")
        }
    }
}
With that we have reached the end of this article. Thank you once again for reading. Don’t forget to follow 😍. Also subscribe our newsletter at https://www.devtechie.com