LastPass re-design clone - part 2

SwiftUI Feb 23, 2020

Validation with Combine & Persistence using UserDefaults

This is part 2 of the LastPass clone series, if you haven’t read the first part, here it is.

Preparations

As always, If you are subscribed, I have sent you the  source code, otherwise, subscribe here to get it.

Validation with combine

We will use the combine framework to perform input validations. So in the Shared folder, add a file named AuthenticationManager.swift, then create a Models folder, and add a file named FormValidation.swift containing the following code:

struct FormValidation {
    var success: Bool = false
    var message: String = ""
}

This is just a simple model that we will use in the validation publishers.

Now go back in the AuthenticationManager.swift file , and add the following properties to the top of the struct:

private var cancellableSet: Set<AnyCancellable> = []
    
    @Published var email = ""
    @Published var password = ""
    @Published var confirmedPassword = ""
    
    @Published var canLogin = false
    @Published var canSignup = false
    
    @Published var emailValidation = FormValidation()
    @Published var passwordValidation = FormValidation()
    @Published var confirmedPasswordValidation = FormValidation()
    @Published var similarityValidation = FormValidation()

Notice all properties are published except the cancellableSet, this is because we will use them in the LoginView and AccountCreationView whereas the set will be used to store cancellable objects returned by subscribers that you will learn more of later on.

Now add the following below :


    private var emailPublisher: AnyPublisher<FormValidation, Never> {
        self.$email.debounce(for: 0.2, scheduler: RunLoop.main)
        .removeDuplicates()
            .map { email in
				// To be created outside
                let regEx = "[A-Z0-9a-z._%+-][email protected][A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
                let predicate = NSPredicate(format:"SELF MATCHES %@", regEx)
                
                if email.isEmpty{
                    return FormValidation(success: false, message: "")
                }
                

                if !predicate.evaluate(with: email){
                    return FormValidation(success: false, message: "Invalid email address")
                }
                
                return FormValidation(success: true, message: "")
        }.eraseToAnyPublisher()
    }

Let’s break that down:

  1. We use debounce on the published email property to wait for the specified amount of second before validating so that we don’t validate when each character is typed. The right amount is around 0.2 to 0.5 second, but you are totally free to try other values.
  2. removeDuplicates does what its name says, remembers what was previously sent in the pipeline, and only passes forward values that don’t match the current value.
  3. Using the map operator gives us the current value that’s in the textfield to perform whatever computation we want.
  4. In the map closure, we first check whether the textfield is empty then return false with en empty message, otherwise, we move to then next condition where we check if the email format is correct using the predicate we created earlier.. If all conditions fail meaning the input is valid, we return a successful FormValidation object.
  5. Last we erase to anypublisher to simplify the return type returned by the map operator.

That’s the email publisher created. Let’s move to the password. Below that code , add the following:

private var passwordPublisher: AnyPublisher<FormValidation, Never> {
        self.$password.debounce(for: 0.2, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { password in
                
                if password.isEmpty{
                    return FormValidation(success: false, message: "")
                }
                if password.count < PasswordConfig.recommendedLength{
                    return FormValidation(success: false, message: "The password length must be greater than \(PasswordConfig.recommendedLength) ")
                }
                
                let regEx = "^(?=.*[A-Z])(?=.*[[email protected]#$&*])(?=.*[0-9])(?=.*[a-z]).{8,}$"
                let predicate = NSPredicate(format:"SELF MATCHES %@", regEx)
                
                if !predicate.evaluate(with: password){
                    return FormValidation(success: false, message: "The password is must contain numbers, uppercase and special characters")
                }
                
                return FormValidation(success: true, message: "")
        }.eraseToAnyPublisher()
    }

private var confirmPasswordPublisher: AnyPublisher<FormValidation, Never> {
         self.$confirmedPassword.debounce(for: 0.2, scheduler: RunLoop.main)
                   .removeDuplicates()
                    
                   .map { password in
                       
                       if password.isEmpty{
                           return FormValidation(success: false, message: "")
                       }
                    
                       if password.count < PasswordConfig.recommendedLength{
                           return FormValidation(success: false, message: "The password length must be greater than \(PasswordConfig.recommendedLength) ")
                       }
                       
                       let regEx = "^(?=.*[A-Z])(?=.*[[email protected]#$&*])(?=.*[0-9])(?=.*[a-z]).{8,}$"
                       let predicate = NSPredicate(format:"SELF MATCHES %@", regEx)
                       
                       if !predicate.evaluate(with: password){
                           return FormValidation(success: false, message: "The password is must contain numbers, uppercase and special characters")
                       }
                    
                       
                       return FormValidation(success: true, message: "")
               }.eraseToAnyPublisher()
    }

We do the same thing for both the password and confirmPassword.

The above code does almost exactly the same thing, the only part that differs is the validation which goes like this:

  1. We check whether the password is empty first
  2. Then check the count
  3. Last, we validate the password against the password predicate to check whether the password contains at least 1 uppercase letter, 1 lowercase letter , at least one special character and at least 1 digit

If all goes well, we return true with an empty msg.

Next up, let’s check the similarity:

 private var similarityPublisher: AnyPublisher<FormValidation, Never> {
        Publishers.CombineLatest($password, $confirmedPassword)
            .map { password, confirmedPassword in
                
                if password.isEmpty || confirmedPassword.isEmpty{
                     return FormValidation(success: false, message: "")
                }
                
                if password != confirmedPassword{
                     return FormValidation(success: false, message: "Passwords do not match!")
                }
                 return FormValidation(success: true, message: "")
        }.eraseToAnyPublisher()
    }
    

The above code just checks whether the password and confirmPassword are equal.

Now, create the constructor if it's not there yet...

func init() {}

This is where we are going to subscribe to the publishers we’ve just created.

In the init(), add the following code:

 	  emailPublisher
            .assign(to: \.emailValidation, on: self)
            .store(in: &self.cancellableSet)

Here, we subscribe to the email publisher using the assign operator. Assign is a subscriber that’s used to set the value returned from a publisher to a property. As you can see in the above code we the set the emailValidation property that’s on self which is the AuthenticationManager. Then we store the cancellable into the cancellableSet

Next, add the following code below that:

       passwordPublisher
            .assign(to: \.passwordValidation, on: self)
            .store(in: &self.cancellableSet)
        
        confirmPasswordPublisher
            .assign(to: \.confirmedPasswordValidation, on: self)
            .store(in: &self.cancellableSet)
        
        similarityPublisher
            .assign(to: \.similarityValidation, on: self)
            .store(in: &self.cancellableSet)

We do the same thing for the password, confirmPassword and similarityPublisher.

Note: It is important to store the cancellable objects, failure to do so will  will result in publishers being cancelled prematurely.

There are two boolean properties to the top of file that we will use to activate and deactivate the login and createAccount buttons which will then allow us to login or create an account.

In the init method, add the following block of code:


        Publishers.CombineLatest(emailPublisher, passwordPublisher)
            .map { emailValidation, passwordValidation  in
                emailValidation.success && passwordValidation.success
        }.assign(to: \.canLogin, on: self)
            .store(in: &self.cancellableSet)

Using combine’s CombineLatest , we are able to combine the emailPublisher and passwordPublisher because combine is amazing. We then map through the tuple output by CombineLatest to check whether the validation has been successful.

Below that code , add the following:

 Publishers.CombineLatest4(emailPublisher, passwordPublisher, confirmPasswordPublisher, similarityPublisher)
            .map { emailValidation, passwordValidation, confirmedPasswordValidation, similarityValidation  in
                emailValidation.success && passwordValidation.success && confirmedPasswordValidation.success && similarityValidation.success
        }.assign(to: \.canSignup, on: self)
            .store(in: &self.cancellableSet)
       

For the sign up button to activate, we will need all four publishers to return true. We use CombineLastest4 for this task. CombineLatest4 works similar to CombineLatest.

EnvironmentObject

We will use the AuthenticationManager in a couple of places, it’s seems logical to put it in the environment object.

Open the SceneDelegate.swift file, and add the following line at the top of the class:

let authManager = AuthenticationManager()`

Then add this to the ContentView initialisation like this:

   let contentView = ContentView()
         .environment(\.managedObjectContext, context)
        .environmentObject(authManager)

This code will make the authenticationManager instance available to all of the views that need to use it. Pretty cool, right?

Login View

Open the LoginView.swift file, and add the following property at the top of the file:

@EnvironmentObject private var authManager: AuthenticationManager

This line of code just get the environment object we set earlier, so that we can use it in this view.

Now, you will need to delete the email and password properties at the top, then replace where they are being used with the following respectively on SharedTextfield and PasswordField:

self.$authManager.email
self.$authManager.password

Next, replace the empty errorMessage value on both SharedTextfield and PasswordField with the following respectively:

authManager.emailValidation.message
authManager.passwordValidation.message

So what are we doing here? We are linking the published values in the AuthenticationManager with the LoginView’s text fields. Run the app, add start typing in any of the textfields, you should see appropriate error message showing up.

You should delete the local email and password properties in the login view

Let’s deactivate the login button if there are still errors. Replace the backgroundColor with the following: self.authManager.canLogin ? Color.accent : Color.gray. Here we say if there are errors, set the color to gray otherwise, set it to the accent color.

Last add the following to the same login button:

.disabled(!self.authManager.canLogin)

We use the canLogin published property to enable or disable the button depending on the validation state.

AccountCreationView

Here we do almost the same thing as what we did in the login view.

Get the AuthenticationManager object:

@EnvironmentObject private var authManager: AuthenticationManager

Replace the email, password and confirmPassword with the following:

self.$authManager.email
self.$authManager.password
self.$authManager.confirmedPassword

Add the error validation message to each field respectively,:

authManager.emailValidation.message
authManager.passwordValidation.message
authManager.confirmedPasswordValidation.message

Replace the backgroundColor with the following:

self.authManager.canSignup ? Color.accent : Color.gray

Here we use canSignup instead of canLogin

Then add this to the sign up button:

.disabled(!self.authManager.canSignup)

The last thing to add here is the similarity validation message, so add the following in the VStack below the password confirmation textfield:

                      Text(self.authManager.similarityValidation.message).foregroundColor(Color.red)

Run the app, add start playing with both forms to see the validation in action.

This is for this part folks. The main focus for this part was the validation and we did just that. If you have any issue understanding, correction or addition? Feel free to email me. Share this article and don’t forget to subscribe for more.

If you want to support my work, do so by becoming my patreon here

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder