Building a Form in SwiftUI – Part 3 – Validation

Welcome to the final installment of our SwiftUI form-building series. In this concluding chapter, we’ll delve into implementing validation on the values of form controls stored in the form context. If you haven’t already, I recommend reading the previous articles on form creation and submission for context: 1. Building a Form in SwiftUI – Part 1 – Form Context
2. Building a Form in SwiftUI – Part 2 – External Submission.

To kickstart the validation process for a form, the initial step involves defining validation types for each form control. In this tutorial, our focus lies on text fields and their commonly needed types of validations, specifically “required” and “regex” validations.

Let’s start by defining a FormControlValidation type which contains the type of validation and the failure message:

public struct FormControlValidation: Hashable {
    public let id = UUID()

    public let type: ValidationType
    public let message: String

    public init(type: ValidationType, message: String) {
        self.type = type
        self.message = message
    }
}

public extension FormControlValidation {

    enum ValidationType: Hashable {
        case required
        case regex(String)
    }
}

Next, we need to implement how different types of validations should check the validity of a given value. For now, we focus on the 2 validation types provided and implement a validate(value:) function:

public extension FormControlValidation {

    /// validates the given value against the validation and returns a boolean indicating the success of validation
    func validate<T>(with value: T?) -> Bool {
        switch type {
        case .required:
            guard let value else { return false }

            if let string = value as? String {
                return string.isEmpty == false
            }

            return true

        case .regex(let pattern):
            if let value = value as? String {
                if let regex = try? NSRegularExpression(pattern: pattern) {
                    return regex.matches(value)
                } else {
                    return true
                }
            } else {
                return false
            }
        }
    }
}

private extension NSRegularExpression {
    func matches(_ string: String) -> Bool {
        let range = NSRange(string.startIndex..., in: string)
        return firstMatch(in: string, options: [], range: range) != nil
    }
}

Following this, our next task is to incorporate support for validations into our form view. To accomplish this, we need to maintain a dictionary of validators associated with their form control keys (IDs) in the FormContextObject. As such, we’ll introduce a new type called ValidatorsManager, tasked with managing and executing validators.

extension FormContextObject {
    typealias Validator = () -> Bool

    class ValidatorsManager {
        private var validators: [String: Validator]

        init(validators: [String: Validator] = [:]) {
            self.validators = validators
        }

        /// Adds a validator for a given key. If key is null or already exists, it will be ignored.
        /// - Parameters:
        ///   - key: The form control key associated with the validator.
        ///   - validator: The validator closure to execute for this key.
        /// - Returns: A boolean indicating if the validator has been successfully added.
        @discardableResult
        func addValidator(for key: String?, validator: Validator?) -> Bool {
            guard let key, let validator, validators.keys.contains(key) == false else {
                return false
            }

            self.validators[key] = validator
            return true
        }

        /// Executes validators as requried based on keys provided.
        /// - Parameter keys: an array of form control keys to executes their validators, or all validators if null.
        /// - Returns: A boolean indicating if the result of validation is successful or not.
        @discardableResult
        func validate(for keys: [String]? = nil) -> Bool {
            var validatorsToUse = validators.values

            if let keys {
                validatorsToUse = validators.filter({ keys.contains($0.key) }).values
            }

            // need to do this instead of allSatisfy() to ensure all validations run
            var result = true
            validatorsToUse.forEach {
                let isValidated = $0() // NOTE: ensures validation is executed
                result = result && isValidated
            }
            return result
        }
    }
}

Then, we’ll need to modify the FormContextObject as below to hold an instance of our ValidatorsManager allowing it to manage form control validators:

class FormContextObject: ObservableObject {
    @Published private(set) var context: FormContext?
    private let validatorsManager = ValidatorsManager()

    init(context: FormContext?) {
        self.context = context
    }

    func set<T: Hashable>(_ value: T?, for key: String?) {
        context?.set(value, for: key)
    }
    
    func addValidator(for key: String?, validator: Validator?) {
        validatorsManager.addValidator(for: key, validator: validator)
    }
    
    func validate(for keys: [String]? = nil) -> Bool {
        validatorsManager.validate(for: keys)
    }
}

With all the components in place, the final piece of the puzzle is triggering form validation. If you’re familiar with Building a Form in SwiftUI – Part 2 – External Submission, you’ve encountered the FormSubmitTrigger. I propose that the optimal moment to trigger form validation is when the onFormSubmitTriggered closure of the form view is activated by a form-submit-trigger. Otherwise, it can also be done in the SubmitButtonView when the button is tapped.

struct FormView<Content: View>: View {
    private let formId: String
    private let formContextObject: FormContextObject
    private let content: Content

    init(formId: String, formContext: FormContext = .init(), @ViewBuilder content: () -> Content) {
        self.formId = formId
        self.formContextObject = .init(context: formContext)
        self.content = content()
    }

    var body: some View {
        content
            .formContextObject(formContextObject)
            .onFormSubmitTriggered { formId in
                guard let formId, formId == self.formId else { return }
                
                if formContextObject.validate() {
                    print("SUCCESS!")
                } else {
                    print("FAILURE!")
                }
            }
    }
}

Finally, let’s add form validations for each form control to the FormContextObject. If you recall the text field from previous articles, we can register its validator while registering it into the form context using the formContext view modifier. Below are the updates to the TextFieldView and TextFieldViewModel to incorporate validations to the text field. These validations will be executed when the form is submitted using the FormSubmitTrigger, and an error message will be displayed for each of the text fields.

struct TextFieldView: View {
    @StateObject private var viewModel: TextFieldViewModel
    
    init(id: String, title:String) {
        self._viewModel = .init(wrappedValue: .init(id: id, title: title))
    }
    
    var body: some View {
        VStack {
            TextField(viewModel.title, text: $viewModel.text)
                .textFieldStyle(.roundedBorder)
                .formContext(formControl: viewModel, closure: viewModel.addValidator(to:))
            
            Text(viewModel.errorMessage)
                .foregroundStyle(.red)
                .font(.caption)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}

class TextFieldViewModel: ObservableObject, FormControl {
    let id: String
    let title: String
    @Published var text: String
    @Published var errorMessage: String = ""
    
    init(id: String, title:String, text: String = "") {
        self.id = id
        self.title = title
        self.text = text
    }
    
    var formKey: String? { id }
    var formValue: AnyHashable? { text }
    
    func addValidator(to formContextObject: FormContextObject) {
        formContextObject.addValidator(for: id, validator: validate)
    }
    
    let validations: [FormControlValidation] = [
        .init(type: .required, message: "This field is required!"),
        .init(type: .regex("^[a-zA-Z]+$"), message: "Only letters are allowed!")
    ]
    
    func validate() -> Bool {
        if let failedValidation = validations.first(where: { !$0.validate(with: text) }) {
            errorMessage = failedValidation.message
            return false
        }
        
        return true
    }
}
Sample Screenshot

To wrap up, in this tutorial, we explored the process of adding form validation to each form control. These validations are triggered upon form submission, displaying error messages and preventing submission if all validations are not passed.

Thank you for following along with this series of tutorials. I hope you found them helpful for your future projects.

Kamyab R. Bozorg

Software developer specializing in iOS development using Swift and SwiftUI, with experience in web development as well as in backend languages like C# and Java. My passion lies in crafting elegant solutions and pushing the boundaries of innovation in the ever-evolving world of technology.

One thought on “Building a Form in SwiftUI – Part 3 – Validation

Leave a Reply

Your email address will not be published. Required fields are marked *