Modelling Change

21 Dec 2016 12 min read

A really common task in iOS development is to validate input from a form. In this post, I want to look at one possible implementation for doing this by extracting the form validation (business logic) from the view controller. It's not a new concept, but I want to explore how we can leverage Generics and enum associated values to create a graceful and scalable solution.

Well, we can all dream, I guess 😂.

Photo showing a sign saying "Time for change"

Tweaking your profile

Most of us will have used social media before and so will have created an online profile. Being social creatures, from time to time, we want to change the details on these profiles - we are going to use this edit profile functionality as the basis of this post. Just to give you some eye candy, let's look at the form we will use for editing:

Edit Profile Form

Before we can jump into the code, we need to discuss some of the requirements (business rules) of editing a profile:

  • All fields are required
  • Firstnames must be at least two characters in length
  • Lastnames must be at least two characters in length
  • Email addresses must be at least five characters in length
  • Age must be between 13 and 124 years old
  • Error messages should be shown when any validation rules are broken
  • Updating should only occur when the values in the profile have been changed
  • Only fields that have changed should be updated

Validating your way to social success 🎉

Okay, so we have our requirements, but before we start building the UI, let's see how we can take those rules and produce a class responsible for enforcing them.

enum ValidationResult<E: Equatable>: Equatable {
    case success
    case failure(E)
}

func ==<E: Equatable>(lhs: ValidationResult<E>, rhs: ValidationResult<E>) -> Bool {
    switch (lhs, rhs) {
    case (let .failure(failureLeft), let .failure(failureRight)):
        return failureLeft == failureRight
    case (.success, .success):
        return true
    default:
        return false
    }
}

struct EditProfileErrorMessages: Equatable {
    let firstNameLocalizedErrorMessage: String?
    let lastNameLocalizedErrorMessage: String?
    let emailLocalizedErrorMessage: String?
    let ageLocalizedErrorMessage: String?
}

func ==(lhs: EditProfileErrorMessages, rhs: EditProfileErrorMessages) -> Bool {
    return lhs.emailLocalizedErrorMessage == rhs.emailLocalizedErrorMessage &&
        lhs.lastNameLocalizedErrorMessage == rhs.lastNameLocalizedErrorMessage &&
        lhs.emailLocalizedErrorMessage == rhs.emailLocalizedErrorMessage &&
        lhs.ageLocalizedErrorMessage == rhs.ageLocalizedErrorMessage
}

class EditProfileValidator: NSObject {

    private let user: User
    private var firstNameChanged = false
    private var lastNameChanged = false
    private var emailChanged = false
    private var ageChanged = false

    var firstName: String? {
        didSet {
            firstNameChanged = (firstName != user.firstName)
        }
    }

    var lastName: String? {
        didSet {
           lastNameChanged = (lastName != user.lastName)
        }
    }

    var email: String? {
        didSet {
           emailChanged = (email != user.email)
        }
    }

    var age: Int? {
        didSet {
            ageChanged = (age != user.age)
        }
    }

    // MARK: - Init

    init(user: User) {
        self.user = user
        super.init()

        firstName = user.firstName
        lastName = user.lastName
        email = user.email
        age = user.age
    }

    // MARK: - Change

    func hasMadeChanges() -> Bool {
        return firstNameChanged || lastNameChanged || emailChanged || ageChanged
    }

    func changes() -> [String: Any] {
        var changes = [String: Any]()

        if firstNameChanged {
            changes["firstname"] = firstName
        }

        if lastNameChanged {
            changes["lastname"] = lastName
        }

        if emailChanged {
            changes["email"] = email
        }

        if ageChanged {
            changes["age"] = age
        }

        return changes
    }

    // MARK: - Validators

    func validateAccountDetails() -> ValidationResult<EditProfileErrorMessages> {
        var firstNameError: String? = nil
        var lastNameError: String? = nil
        var emailError: String? = nil
        var ageError: String? = nil

        switch validateFirstName() {
        case .success:
            break
        case .failure(let message):
            firstNameError = message
        }

        switch validateLastName() {
        case .success:
            break
        case .failure(let message):
            lastNameError = message
        }

        switch validateEmail() {
        case .success:
            break
        case .failure(let message):
            emailError = message
        }

        switch validateAge() {
        case .success:
            break
        case .failure(let message):
            ageError = message
        }

        if firstNameError != nil || lastNameError != nil || emailError != nil || ageError != nil {
            let editProfileErrorMessages = EditProfileErrorMessages(firstNameLocalizedErrorMessage: firstNameError, lastNameLocalizedErrorMessage: lastNameError, emailLocalizedErrorMessage: emailError, ageLocalizedErrorMessage: ageError)

            return .failure(editProfileErrorMessages)
        }

        return .success
    }

    func validateFirstName() -> ValidationResult<String> {
        guard let firstName = firstName else {
            return .failure("Firstname can not be empty")
        }

        if firstName.characters.count < 2 {
            return .failure("Firstname is too short")
        } else {
            return .success
        }
    }

    func validateLastName() -> ValidationResult<String> {
        guard let lastName = lastName else {
            return .failure("Lastname can not be empty")
        }

        if lastName.characters.count < 2 {
            return .failure("Lastname is too short")
        } else {
            return .success
        }
    }

    func validateEmail() -> ValidationResult<String> {
        guard let email = email else {
            return .failure("Email can not be empty")
        }

        if email.characters.count < 5 {
            return .failure("Email is too short")
        } else {
            return .success
        }
    }

    func validateAge() -> ValidationResult<String> {
        guard let age = age else {
            return .failure("Age can not be empty")
        }

        let minimumAge = 13
        let maximumAge = 124

        if age < minimumAge || age > maximumAge {
            return .failure("Must be older than \(minimumAge) and younger than \(maximumAge)")
        } else {
            return .success
        }
    }
}

A lot is happening here; however, we can think of it in three sections:

  1. Data structures to support validation
  2. Changes
  3. Validation

1. Data structures to support validation

The data structures are necessary as they allow us to express the outcome of the validation without returning dictionaries or simple booleans.

enum ValidationResult<E: Equatable>: Equatable {
    case success
    case failure(E)
}

func ==<E: Equatable>(lhs: ValidationResult<E>, rhs: ValidationResult<E>) -> Bool {
    switch (lhs, rhs) {
    case (let .failure(failureLeft), let .failure(failureRight)):
        return failureLeft == failureRight
    case (.success, .success):
        return true
    default:
        return false
    }
}

Here we have an enum ValidationResult which has two values/cases:

  • Success
  • Failure

The .success case is simple enough; however, .failure has an associated value which we will use for populating the error message when validation fails. We are using generics to allow this enum to be used with different concrete types, the only constraint that we place on those types is that they must conform to the Equatable protocol - in Swift, this form of constraining is called type constraint and could also be used to enforce that the type is of a specific subclass. Generics are used here rather than a concrete type (which would arguably be easier to read), as the .failure case will need to be associated with different types (more on this later). The ValidationResult enum also conforms to Equatable. This is necessary because we don't get equatable checks for free with enums that contain associated values, so we will need to define this behaviour explicitly. The == method under the enum is implementing this equality check. Let's look at EditProfileErrorMessages:

struct EditProfileErrorMessages: Equatable {
    let firstNameLocalizedErrorMessage: String?
    let lastNameLocalizedErrorMessage: String?
    let emailLocalizedErrorMessage: String?
    let ageLocalizedErrorMessage: String?
}

func ==(lhs: EditProfileErrorMessages, rhs: EditProfileErrorMessages) -> Bool {
    return lhs.emailLocalizedErrorMessage == rhs.emailLocalizedErrorMessage &&
        lhs.lastNameLocalizedErrorMessage == rhs.lastNameLocalizedErrorMessage &&
        lhs.emailLocalizedErrorMessage == rhs.emailLocalizedErrorMessage &&
        lhs.ageLocalizedErrorMessage == rhs.ageLocalizedErrorMessage
}

In the above code snippet, we have the EditProfileErrorMessages struct, which will be used to hold the errors returned from our overall form validation check. We could have avoided this struct by choosing to return the error messages in a dictionary; however, then we would have needed to either expose the keys as static values or use magic strings between the validator and any class which used it. EditProfileErrorMessages conforms to the Equatable protocol so that it can be used as one possible associated value for the .failure case.

Validation

Let's look at one of the validation methods:

func validateAge() -> ValidationResult<String> {
    guard let age = age else {
        return .failure("Age can not be empty")
    }

    let minimumAge = 13
    let maximumAge = 124

    if age < minimumAge || age > maximumAge {
        return .failure("Must be older than \(minimumAge) and younger than \(maximumAge)")
    } else {
        return .success
    }
}

In the above code snippet, we validate that the age value conforms to our business rules. The most interesting part is that we use ValidationResult as our return type. For this method, the ValidationResult instances will use String as it's associated value type. We can see this in action:

return .failure("Must be older than \(minimumAge) and younger than \(maximumAge)")

These individual validation checks are then combined in:

func validateAccountDetails() -> ValidationResult<EditProfileErrorMessages> {
    var firstNameError: String? = nil
    var lastNameError: String? = nil
    var emailError: String? = nil
    var ageError: String? = nil

    switch validateFirstName() {
    case .success:
        break
    case .failure(let message):
        firstNameError = message
    }

    switch validateLastName() {
    case .success:
        break
    case .failure(let message):
        lastNameError = message
    }

    switch validateEmail() {
    case .success:
        break
    case .failure(let message):
        emailError = message
    }

    switch validateAge() {
    case .success:
        break
    case .failure(let message):
        ageError = message
    }

    if firstNameError != nil || lastNameError != nil || emailError != nil || ageError != nil {
        let editProfileErrorMessages = EditProfileErrorMessages(firstNameLocalizedErrorMessage: firstNameError, lastNameLocalizedErrorMessage: lastNameError, emailLocalizedErrorMessage: emailError, ageLocalizedErrorMessage: ageError)

        return .failure(editProfileErrorMessages)
    }

    return .success
}

In validateAccountDetails, we are also using ValidationResult as the return type, like we did with the validateAge method. However, here we associate the .failure case with EditProfileErrorMessages rather than a String like we did before. It's in this instance that we see the true power of using generics with enums, as it allows us to express two similar but distinct interpretations of failure without polluting our codebase with conceptually-equal enum cases, such as:

enum ValidationResult<E: Equatable>: Equatable {
    case success
    case firstNameFailure
    case lastNameFailure
    case emailFailure
    case ageFailure
    case combinedFailure
}

The above enum covers all the cases. As you can see, there are more cases to express failure states than success states. One of the driving forces behind this was also to enable easier unit testing. I won't include them in this post, but if you are interested, head over to the repository and take a look.

Changes

Okay, so that's the validation part of EditProfileValidator, but how do we know if anything has actually changed? We need to track the initial values of each field and then, as it changes, determine if its final state is different from its initial state. The user can edit a field and then revert to its original value, so a simple boolean won't suffice; we need to track its actual value. We could do this by adding a new suite of properties called something like: firstNameInitialValue but we don't need to as we already have the initial values in the User instance that we pass into this class during initialisation - as User is immutable we know that it will contain the values as they were before any editing, we can then use this to determine if any changes have made:

private let user: User
private var firstNameChanged = false
private var lastNameChanged = false
private var emailChanged = false
private var ageChanged = false

var firstName: String? {
    didSet {
        firstNameChanged = (firstName != user.firstName)
    }
}

var lastName: String? {
    didSet {
       lastNameChanged = (lastName != user.lastName)
    }
}

var email: String? {
    didSet {
       emailChanged = (email != user.email)
    }
}

var age: Int? {
    didSet {
        ageChanged = (age != user.age)
    }
}

In the above code snippet, you can see that I am utilising didSet to check if the value has changed, and I then store this information in a convenience boolean property. This boolean property isn't strictly necessary, but I feel it improves the readability of the hasMadeChanges method:

func hasMadeChanges() -> Bool {
    return firstNameChanged || lastNameChanged || emailChanged || ageChanged
}

is clearer than

func hasMadeChanges() -> Bool {
    return (firstName != user.firstName) || (lastName != user.lastName) || (email != user.email) || (age != user.age)
}

In my opinion, at least 😉.

As we will see in the EditProfileViewController class, this method will be used to determine if we call validateAccountDetails when the user presses the update button. Now we have one more requirement to satisfy, then we can move onto the UI:

Only fields that have changed should be updated

Here we need to assume that the edit profile update process will trigger an API call and send the changes as json, to support this, we want the validator to return a dictionary containing the changes that can then be passed to the API endpoint.

func changes() -> [String: Any] {
    var changes = [String: Any]()

    if firstNameChanged {
        changes["firstname"] = firstName
    }

    if lastNameChanged {
        changes["lastname"] = lastName
    }

    if emailChanged {
        changes["email"] = email
    }

    if ageChanged {
        changes["age"] = age
    }

    return changes
}

In the above code snippet, we check if each field has changed, and if it has, we add a new key-value pair to the changes dictionary.

UI

We've looked at the class that will enforce our business; now we need to look at our view controller:

class EditProfileViewController: UIViewController {

    @IBOutlet weak var scrollView: UIScrollView!

    @IBOutlet weak var firstNameTextField: UITextField!
    @IBOutlet weak var lastNameTextField: UITextField!
    @IBOutlet weak var emailTextField: UITextField!
    @IBOutlet weak var ageTextField: UITextField!

    @IBOutlet weak var firstNameErrorLabel: UILabel!
    @IBOutlet weak var lastNameErrorLabel: UILabel!
    @IBOutlet weak var emailErrorLabel: UILabel!
    @IBOutlet weak var ageErrorLabel: UILabel!

    @IBOutlet weak var updateButton: UIButton!

    @IBOutlet weak var scrollViewBottomConstraint: NSLayoutConstraint!

    lazy var validator: EditProfileValidator = {
        let validator = EditProfileValidator(user: self.user)
        return validator
    }()

    lazy var user: User = {
        let user = User(firstName: "Tom", lastName: "Smithson", email: "tom.smithson@somewhere.com", age: 27)
        return user
    }()

    // MARK: - ViewLifecycle

    override func viewDidLoad() {
        super.viewDidLoad()

        NotificationCenter.default.addObserver(self, selector: ###selector(keyboardWillShow), name: NSNotification.Name.UIKeyboardWillShow, object: nil)
        NotificationCenter.default.addObserver(self, selector: ###selector(keyboardWillHide), name: NSNotification.Name.UIKeyboardWillHide, object: nil)

        clearAllErrors()

        firstNameTextField.text = user.firstName
        lastNameTextField.text = user.lastName
        emailTextField.text = user.email
        ageTextField.text = "\(user.age)"
    }

    // MARK: - ButtonActions

    @IBAction func updateButtonPressed(_ sender: Any) {
        view.endEditing(true)

        if validator.hasMadeChanges() {
            switch validator.validateAccountDetails() {
            case .success:
                let alertController = UIAlertController(title: "Save Changes", message: "Can successfully save changes: \n \(validator.changes())", preferredStyle: .alert)
                let dismissAction = UIAlertAction(title: "OK", style: .default, handler: nil)
                alertController.addAction(dismissAction)

                present(alertController, animated: true, completion: nil)
            case .failure(let accountValidationErrorMessages):
                if accountValidationErrorMessages.firstNameLocalizedErrorMessage != nil {
                    showError(textField: firstNameTextField, messagelabel: firstNameErrorLabel, message: accountValidationErrorMessages.firstNameLocalizedErrorMessage!)
                }

                if accountValidationErrorMessages.lastNameLocalizedErrorMessage != nil {
                    showError(textField: lastNameTextField, messagelabel: lastNameErrorLabel, message: accountValidationErrorMessages.lastNameLocalizedErrorMessage!)
                }

                if accountValidationErrorMessages.emailLocalizedErrorMessage != nil {
                    showError(textField: emailTextField, messagelabel: emailErrorLabel, message: accountValidationErrorMessages.emailLocalizedErrorMessage!)
                }

                if accountValidationErrorMessages.ageLocalizedErrorMessage != nil {
                    showError(textField: ageTextField, messagelabel: ageErrorLabel, message: accountValidationErrorMessages.ageLocalizedErrorMessage!)
                }
            }
        } else {
            let alertController = UIAlertController(title: "No Changes", message: "You haven't made any changes to update", preferredStyle: .alert)
            let dismissAction = UIAlertAction(title: "Dismiss", style: .default, handler: nil)
            alertController.addAction(dismissAction)

            present(alertController, animated: true, completion: nil)
        }
    }

    // MARK: - Error

    func showError(textField: UITextField, messagelabel: UILabel, message: String) {
        textField.textColor = UIColor.red
        messagelabel.text = message
        messagelabel.superview?.isHidden = false
    }

    func hideErrorMessage(messagelabel: UILabel) {
        messagelabel.superview?.isHidden = true
    }

    func clearAllErrors() {
        hideErrorMessage(messagelabel: firstNameErrorLabel)
        hideErrorMessage(messagelabel: lastNameErrorLabel)
        hideErrorMessage(messagelabel: emailErrorLabel)
        hideErrorMessage(messagelabel: ageErrorLabel)
    }

    // MARK: - Keyboard

    func keyboardWillShow(notification: NSNotification) {
        let info = notification.userInfo
        let rect = (info![UIKeyboardFrameEndUserInfoKey] as AnyObject).cgRectValue
        let duration = (info![UIKeyboardAnimationDurationUserInfoKey] as AnyObject).doubleValue
        let option = UIViewAnimationOptions(rawValue: UInt((notification.userInfo![UIKeyboardAnimationCurveUserInfoKey]! as AnyObject).integerValue << 16))

        UIView.animate(withDuration: duration!, delay: 0, options: option, animations: {
            let keyboardHeight = self.view.convert(rect!, to: nil).size.height
            self.scrollViewBottomConstraint?.constant = keyboardHeight
            self.scrollView.layoutIfNeeded()
        }, completion: nil)
    }

    func keyboardWillHide(notification: NSNotification) {
        let info = notification.userInfo
        let duration = (info![UIKeyboardAnimationDurationUserInfoKey] as AnyObject).doubleValue

        let option = UIViewAnimationOptions(rawValue: UInt((notification.userInfo![UIKeyboardAnimationCurveUserInfoKey]! as AnyObject).integerValue << 16))
        UIView.animate(withDuration: duration!, delay: 0, options: option, animations: {
            self.scrollViewBottomConstraint?.constant = 0
            self.scrollView.layoutIfNeeded()
        }, completion: nil)
    }

    // MARK: - Gesture


    @IBAction func backgroundTapped(_ sender: Any) {
        view.endEditing(true)
    }
}

extension EditProfileViewController: UITextFieldDelegate {

    // MARK: - UITextFieldDelegate

    func textFieldDidBeginEditing(_ textField: UITextField) {
        textField.textColor = UIColor.black

        scrollView.setContentOffset(CGPoint.init(x: 0, y: textField.superview!.frame.origin.y), animated: true)
    }

    func textFieldDidEndEditing(_ textField: UITextField) {
        if textField == firstNameTextField {
            validator.firstName = textField.text

            switch validator.validateFirstName() {
            case .success:
                hideErrorMessage(messagelabel: firstNameErrorLabel)
                break
            case .failure(let localizedErrorMessage):
                showError(textField: textField, messagelabel: firstNameErrorLabel, message: localizedErrorMessage)
            }
        } else if textField == lastNameTextField {
            validator.lastName = textField.text

            switch validator.validateLastName() {
            case .success:
                hideErrorMessage(messagelabel: lastNameErrorLabel)
                break
            case .failure(let localizedErrorMessage):
                showError(textField: textField, messagelabel: lastNameErrorLabel, message: localizedErrorMessage)
            }
        } else if textField == emailTextField {
            validator.email = textField.text

            switch validator.validateEmail() {
            case .success:
                hideErrorMessage(messagelabel: emailErrorLabel)
                break
            case .failure(let localizedErrorMessage):
                showError(textField: textField, messagelabel: emailErrorLabel, message: localizedErrorMessage)
            }
        } else if textField == ageTextField {
            var age = "0"

            if textField.text != nil {
                age = textField.text!
            }

            validator.age = Int(age)

            switch validator.validateAge() {
            case .success:
                hideErrorMessage(messagelabel: ageErrorLabel)
                break
            case .failure(let localizedErrorMessage):
                showError(textField: textField, messagelabel: ageErrorLabel, message: localizedErrorMessage)
            }
        }
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
        if textField == firstNameTextField {
            lastNameTextField.becomeFirstResponder()
        } else if textField == lastNameTextField {
            emailTextField.becomeFirstResponder()
        } else if textField == emailTextField {
            ageTextField.becomeFirstResponder()
        } else {
            ageTextField.resignFirstResponder()
        }

        return true
    }
}

Another sizable code drop, but we won't go through all of it; instead, we will focus on the more interesting parts.

Yes, the 'age' field would be safer as a UIPickerView, but implementing that goes outside of the scope of this post.

lazy var validator: EditProfileValidator = {
    let validator = EditProfileValidator(user: self.user)
    return validator
}()

Here we lazy load the validator that we explored above. In this case, I have used lazy loading as a design approach to separate the initialisation of properties. There is no meaningful performance gain here, and this could easily have been initialised in viewDidLoad.

lazy var user: User = {
    let user = User(firstName: "Tom", lastName: "Smithson", email: "tom.smithson@somewhere.com", age: 27)
    return user
}()

We need a User instance to populate the edit profile, so we are lazy loading it here. Typically, a User instance would be passed into this view controller using dependency injection to allow us to test this class more easily. The next really interesting part is what happens when the user presses that "Update" button:

@IBAction func updateButtonPressed(_ sender: Any) {
    view.endEditing(true)

    if validator.hasMadeChanges() {
        switch validator.validateAccountDetails() {
        case .success:
            let alertController = UIAlertController(title: "Save Changes", message: "Can successfully save changes: \n \(validator.changes())", preferredStyle: .alert)
            let dismissAction = UIAlertAction(title: "OK", style: .default, handler: nil)
            alertController.addAction(dismissAction)

            present(alertController, animated: true, completion: nil)
        case .failure(let accountValidationErrorMessages):
            if accountValidationErrorMessages.firstNameLocalizedErrorMessage != nil {
                showError(textField: firstNameTextField, messagelabel: firstNameErrorLabel, message: accountValidationErrorMessages.firstNameLocalizedErrorMessage!)
            }

            if accountValidationErrorMessages.lastNameLocalizedErrorMessage != nil {
                showError(textField: lastNameTextField, messagelabel: lastNameErrorLabel, message: accountValidationErrorMessages.lastNameLocalizedErrorMessage!)
            }

            if accountValidationErrorMessages.emailLocalizedErrorMessage != nil {
                showError(textField: emailTextField, messagelabel: emailErrorLabel, message: accountValidationErrorMessages.emailLocalizedErrorMessage!)
            }

            if accountValidationErrorMessages.ageLocalizedErrorMessage != nil {
                showError(textField: ageTextField, messagelabel: ageErrorLabel, message: accountValidationErrorMessages.ageLocalizedErrorMessage!)
            }
        }
    } else {
        let alertController = UIAlertController(title: "No Changes", message: "You haven't made any changes to update", preferredStyle: .alert)
        let dismissAction = UIAlertAction(title: "Dismiss", style: .default, handler: nil)
        alertController.addAction(dismissAction)

        present(alertController, animated: true, completion: nil)
    }
}

Due to the scope of this example, I didn't implement an actual API call (which would be in a separate class); instead, I display an alert with the changes. The first thing this method does is check if any changes have actually been made. If changes have been made, we then verify if those changes are valid. If not, we display error messages under the text fields detailing what went wrong.

Thinking about alternatives

One alternative I explored was using simple Boolean returns to indicate failure; however, this doesn't work with the level of detail we need for the failures in validateAccountDetails. The other alternative, which had more merit, was throwing errors. This approach works well, but I chose the enum approach because the level of boilerplate code required to set up the throwing approach obscured the purpose of the validator, making the project harder to understand.

🤔

Looking back

There is a lot of code in this post that isn't directly related to validation; I included it to illustrate more clearly how this approach can be applied to a semi-realistic form. However, the beauty of this approach is that we now have a class that is independent of the UI. This separation results in the business rules being easier to unit test, as we no longer need to set up the UI to test them. It also ensures that the view controller has more cohesion as it's really only dealing with manipulating the view.

To see the above code snippets together in a working example, head over to the repository and clone the project.

What do you think? Let me know by getting in touch on Mastodon or Bluesky.