Modelling change
A really common task in iOS development is to validate input from a form. In this post I wanted 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 wanted to explore how we could do it with Swift and take advantage of generics and enum associated values to hopefully create a graceful and scalable solution.
Well we can all dream I guess 😂.
Tweaking your profile
I imagine that 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 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:
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 2 characters in length
- Lastnames must be at least 2 characters in length
- Email addresses must be at least 5 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 🎉
OK, 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
}
}
}
There is a lot happening here however we can think of it in 3 sections:
- Data structures to support validation
- Changes
- Validation
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 are 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 certain subclass. Generics are used here rather than a concrete type (which arguably would be easier to read) as the .failure
case will have an associated with different types (more on this later). The ValidationResult
enum itself also conforms to Equatable
. This is necessary as we don't get equatable checks for free with enums that contain associated values so we need to explicitly define it. 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 - this 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 but contains a lot more cases to express what has actually failed.
One of the driving forces behind this was also to allow for easier unit testing - I won't include them in this post but if you are interested head over to the repo and check them out.
Changes
OK, so that's the validation part of EditProfileValidator
but how to we know if anything has actually changed? We need to track the initial values of each field and then as it's changed determine if it's final state is different from it's initial state. It's possible for the user to edit a field and then re-edit back to what it was so a simple boolean won't do, we need to track it's 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 taking advantage of didSet
to check if the value is different and I then store this information in a convenience boolean property. This boolean property isn't strictly necessary but I use it here to better by intent in 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)
}
Well 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 just 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 big code drop but we won't go through all of it, instead we will look at 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 out the initialisation of properties - there is no meaningful performance gain here and this could easily have been init'd 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 but typically a User
instance would be passed into this view controller using dependency injection to allow us to more easily test this class. 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 of course be in a different class) but instead I just how an alert with the changes. So the first thing this method does is check if any changes have actually been made and if changes have been made, we check if those changes are valid and if not we show error messages under the textfields detailing what went wrong.
Thinking about alternatives
One alternative that 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 that had more merit was throwing errors, this works well and I only choose the enum approach as I felt the level of boilerplate code required to set this obscured the purpose of the validator and resulted in a project that was harder to understand.
🤔
Looking back
There is a lot code in this post that doesn't really have to do with validating, I included it as I wanted to better show how this approach could be used against a semi-realistic form. But the beauty of this approach is that we now have a class that is independent of the UI to enforce our business rules, this should result in the business rules being easier to unit test and be clearer to understand than if we kept them in the view controller itself (please, see the GitHub repo for an implemented suite of units). It also ensures that the view controller has more cohesion as it's really only dealing manipulating the view.
You can find the completed project by heading over to https://github.com/wibosco/ModelingFormChanges-Example