Form validation in SwiftUI using Formidable

Ensure the rules and data consistency in your forms is the most important part of your app. This task is usually very tiring because of the lack of "resources" already available in Swift. With that in mind, Formidable brings various resources to help you validate all data in your forms and ensure data consistency in your application. 1 - Create a new project Create a new project named FormidableDemo with Tests enabled. 2 - Project structure Create the following folders: Views Models Extensions Enums 3 - Setup SignUpFormView Create a folder called SignUp inside of Views. Rename ContentView to SignUpFormView. Move SignUpFormView to inside of SignUp folder. In RegisterApp.swift, change ContentView to SignUpFormView. 4 - Adding Formidable to the project Go to File > Add Package Dependencies. Input https://github.com/didisouzacosta/Formidable in the search bar. Touch in Add Package. In the App Target, select FormidableDemo. 5 - Creating the SignUpForm Create a new empty file called SignUpForm inside of Views > SignUp. Import the Foundation and Formidable frameworks. Create a final class called SignUpForm, it needs to the extend te Formidable protocol. Add the fields and the initializer: import Foundation import Formidable @Observable final class SignUpForm: Formidable { // MARK: - Public Variables var nameField: FormField var emailField: FormField var passwordField: FormField var birthField: FormField var languageField: FormField var agreeTermsField: FormField // MARK: - Initializer init() { self.nameField = .init("") self.emailField = .init("") self.passwordField = .init("") self.birthField = .init(.now) self.languageField = .init("") self.agreeTermsField = .init(false) } } 6 - Creating validation errors Inside of Enums folder, create a new file called ValidationErrors with the following code: import Foundation enum ValidationError: LocalizedError { case isRequired case validEmail case alreadyExists case toBeLegalAge case minLengthPassword case agreeTerms var errorDescription: String? { switch self { case .isRequired: "This field cannot be left empty." case .validEmail: "Please enter a valid email address." case .alreadyExists: "This entry already exists." case .toBeLegalAge: "You need to be of legal age." case .minLengthPassword: "Password must be at least 3 characters long." case .agreeTerms: "You must accept the terms." } } } 7 - Applying rules In SignUpForm, create a new private method called setupRules, and call it in the initializer. Apply the following rules: import Foundation import Formidable @Observable final class SignUpForm: Formidable { ... init() { ... setupRules() } // MARK: - Private Methods private func setupRules() { nameField.rules = [ RequiredRule(ValidationError.isRequired) ] emailField.rules = [ EmailRule(ValidationError.validEmail), RequiredRule(ValidationError.isRequired) ] passwordField.rules = [ RequiredRule(ValidationError.isRequired), MinLengthRule(3, error: ValidationError.minLengthPassword) ] languageField.rules = [ RequiredRule(ValidationError.isRequired), ] agreeTermsField.rules = [ RequiredRule(ValidationError.agreeTerms) ] } } 8 - Validate birth field In the Extensions folder, create an empty file called Date+Extension and add the code bellow: import Foundation extension Date { func remove(years: Int, calendar: Calendar = .current) -> Date { calendar.date(byAdding: .year, value: -years, to: self)! } func zeroSeconds(_ calendar: Calendar = .current) -> Date { let dateComponents = calendar.dateComponents( [.year, .month, .day, .hour, .minute], from: self ) return calendar.date(from: dateComponents)! } } Now, back to the file SignUpForm and add the validation for birthField: ... init(_ user: User) { ... self.birthField = .init(.now, transform: { $0.zeroSeconds() }) ... } ... private func setupRules() { ... birthField.rules = [ LessThanOrEqualRule(Date.now.remove(years: 18).zeroSeconds(), error: ValidationError.toBeLegalAge) ] ... } ... 9 - Improving the languageField Currently the language field type is String, therefore him accept anything text, but we need limit it in none, portuguese, english and spanish. For it, we will can use an enum, so create an empty file in Enum folder called Language and add the code below: enum Language: String, CaseIterable { case none, portuguese, english, spanish } extension Language {

Feb 12, 2025 - 14:54
 0
Form validation in SwiftUI using Formidable

Ensure the rules and data consistency in your forms is the most important part of your app. This task is usually very tiring because of the lack of "resources" already available in Swift. With that in mind, Formidable brings various resources to help you validate all data in your forms and ensure data consistency in your application.

1 - Create a new project

Create a new project named FormidableDemo with Tests enabled.

First step to create a new project
Configuring the app identifier

2 - Project structure

Create the following folders:

  • Views
  • Models
  • Extensions
  • Enums

Project structure

3 - Setup SignUpFormView

  1. Create a folder called SignUp inside of Views.
  2. Rename ContentView to SignUpFormView.
  3. Move SignUpFormView to inside of SignUp folder.
  4. In RegisterApp.swift, change ContentView to SignUpFormView.

4 - Adding Formidable to the project

  1. Go to File > Add Package Dependencies.
  2. Input https://github.com/didisouzacosta/Formidable in the search bar.
  3. Touch in Add Package.
  4. In the App Target, select FormidableDemo.

Formidable in the project

5 - Creating the SignUpForm

  1. Create a new empty file called SignUpForm inside of Views > SignUp.
  2. Import the Foundation and Formidable frameworks.
  3. Create a final class called SignUpForm, it needs to the extend te Formidable protocol.
  4. Add the fields and the initializer:
import Foundation
import Formidable

@Observable
final class SignUpForm: Formidable {

    // MARK: - Public Variables

    var nameField: FormField<String>
    var emailField: FormField<String>
    var passwordField: FormField<String>
    var birthField: FormField<Date>
    var languageField: FormField<String>
    var agreeTermsField: FormField<Bool>

    // MARK: - Initializer

    init() {
        self.nameField = .init("")
        self.emailField = .init("")
        self.passwordField = .init("")
        self.birthField = .init(.now)
        self.languageField = .init("")
        self.agreeTermsField = .init(false)
    }

}

6 - Creating validation errors

Inside of Enums folder, create a new file called ValidationErrors with the following code:

import Foundation

enum ValidationError: LocalizedError {
    case isRequired
    case validEmail
    case alreadyExists
    case toBeLegalAge
    case minLengthPassword
    case agreeTerms

    var errorDescription: String? {
        switch self {
        case .isRequired: "This field cannot be left empty."
        case .validEmail: "Please enter a valid email address."
        case .alreadyExists: "This entry already exists."
        case .toBeLegalAge: "You need to be of legal age."
        case .minLengthPassword: "Password must be at least 3 characters long."
        case .agreeTerms: "You must accept the terms."
        }
    }
}

7 - Applying rules

  1. In SignUpForm, create a new private method called setupRules, and call it in the initializer.
  2. Apply the following rules:
import Foundation
import Formidable

@Observable
final class SignUpForm: Formidable {

    ...

    init() {
        ...
        setupRules()
    }

    // MARK: - Private Methods

    private func setupRules() {
nameField.rules = [
            RequiredRule(ValidationError.isRequired)
        ]

        emailField.rules = [
            EmailRule(ValidationError.validEmail),
            RequiredRule(ValidationError.isRequired)
        ]

        passwordField.rules = [
            RequiredRule(ValidationError.isRequired),
            MinLengthRule(3, error: ValidationError.minLengthPassword)
        ]

        languageField.rules = [
            RequiredRule(ValidationError.isRequired),
        ]

        agreeTermsField.rules = [
            RequiredRule(ValidationError.agreeTerms)
        ]
    }

}

8 - Validate birth field

In the Extensions folder, create an empty file called Date+Extension and add the code bellow:

import Foundation

extension Date {

    func remove(years: Int, calendar: Calendar = .current) -> Date {
        calendar.date(byAdding: .year, value: -years, to: self)!
    }

    func zeroSeconds(_ calendar: Calendar = .current) -> Date {
        let dateComponents = calendar.dateComponents(
            [.year, .month, .day, .hour, .minute],
            from: self
        )
        return calendar.date(from: dateComponents)!
    }

}

Now, back to the file SignUpForm and add the validation for birthField:

...

init(_ user: User) {
...

self.birthField = .init(.now, transform: { $0.zeroSeconds() })

...
}

...

private func setupRules() {
    ...

    birthField.rules = [
        LessThanOrEqualRule(Date.now.remove(years: 18).zeroSeconds(), error: ValidationError.toBeLegalAge)
    ]

    ...
}

...

9 - Improving the languageField

Currently the language field type is String, therefore him accept anything text, but we need limit it in none, portuguese, english and spanish. For it, we will can use an enum, so create an empty file in Enum folder called Language and add the code below:

enum Language: String, CaseIterable {
    case none, portuguese, english, spanish
}

extension Language {

    var detail: String {
        rawValue.capitalized
    }

}

Originally enums can't accepted in the form fields, but we can implement the protocol Emptable for it to be compatible with the rule RequiredRule and to be accepted for the form field.

import Formidable

enum Language: String, CaseIterable {
    case none, portuguese, english, spanish
}

extension Language {

    var detail: String {
        rawValue.capitalized
    }

}

extension Language: Emptable {

    var isEmpty: Bool {
        switch self {
        case .none: true
        default: false
        }
    }

}

Now, go to update the SignUpForm:

...

var languageField: FormField<Language>

...

init() {
...

self.languageField = .init(.none)

...
}

10 - Validating form

Now, we will improve the SignUpForm by adding the submit method, this method will validate the form, if successful, it will return a user object, otherwise will throw the form error.

The Formidable form by default already contains a method called validation that analyzes all the fields and return a error if it exists, so we will take a advantage of this.

Inside of Models folder, create a file named User and add the code below:

import Foundation

struct User {

    let name: String
    let email: String
    let password: String
    let birthday: Date
    let language: Language

    init(
        _ name: String,
        email: String,
        password: String,
        birthday: Date,
        language: Language
    ) {
        self.name = name
        self.email = email
        self.password = password
        self.birthday = birthday
        self.language = language
    }

}

Now, go back to SignUpForm and add this method:

...

// MARK: - Public Methods

func submit() throws -> User {
     try validate()

     return .init(
         nameField.value,
         email: emailField.value,
         password: passwordField.value,
         birthday: birthField.value,
         language: languageField.value
     )
}

...

11 - Testing form

Now, create a file called SignUpFormTests inside of the tests folder, and add the code below:

import Testing
import Foundation

@testable import Example

struct SignUpFormTests {

    @Test func nameFieldMustBeRequired() async throws {
        let form = SignUpForm()

        form.nameField.value = ""

        #expect(form.nameField.isValid == false)

        form.nameField.value = "Orlando"

        #expect(form.nameField.isValid)
    }

    @Test func emailFieldMustContainAValidEmail() async throws {
        let form = SignUpForm()

        form.emailField.value = "invalid_email"

        #expect(form.emailField.isValid == false)

        form.emailField.value = "orlando@gmail.com"

        #expect(form.emailField.isValid)
    }

    @Test func passwordFieldMustBeRequired() async throws {
        let form = SignUpForm()

        form.passwordField.value = ""

        let requiredDescription = ValidationError.isRequired.errorDescription

        #expect(form.passwordField.errors.contains(where: { $0.localizedDescription == requiredDescription }))
        #expect(form.passwordField.isValid == false)

        form.passwordField.value = "123"

        #expect(form.passwordField.isValid)
    }

    @Test func passwordFieldMustContainAtLeastTreeCharacters() async throws {
        let form = SignUpForm()

        form.passwordField.value = "12"

        let minLengthPasswordDescription = ValidationError.minLengthPassword.errorDescription

        #expect(form.passwordField.errors.contains(where: { $0.localizedDescription == minLengthPasswordDescription }))
        #expect(form.passwordField.isValid == false)

        form.passwordField.value = "123"

        #expect(form.passwordField.isValid)
        #expect(form.passwordField.errors.count == 0)
    }

    @Test func languageFieldMustBeRequired() async throws {
        let form = SignUpForm()

        form.languageField.value = .none

        #expect(form.languageField.isValid == false)

        form.languageField.value = .english

        #expect(form.languageField.isValid)
    }

    @Test func birthFieldShouldNotBeLessThan18Years() async throws {
        let form = SignUpForm()

        form.birthField.value = Date.now.remove(years: 17)

        #expect(form.birthField.isValid == false)

        form.birthField.value = Date.now.remove(years: 18)

        #expect(form.birthField.isValid)
    }

    @Test func agreeTermsFieldMustBeRequired() async throws {
        let form = SignUpForm()

        form.agreeTermsField.value = false

        #expect(form.agreeTermsField.isValid == false)

        form.agreeTermsField.value = true

        #expect(form.agreeTermsField.isValid)
    }

    @Test func formShouldThrowAnErrorWhenAnyFieldIsInvalid() throws {
        let form = SignUpForm()

        #expect(throws: ValidationError.isRequired) {
            try form.submit()
        }
    }

    @Test func formMustReturnUserWhenItsValid() throws {
        let form = SignUpForm()
        form.nameField.value = "Adriano"
        form.emailField.value = "adriano@gmail.com"
        form.passwordField.value = "123"
        form.languageField.value = .portuguese
        form.agreeTermsField.value = true

        let user = try form.submit()

        #expect(user.name == "Adriano")
        #expect(user.email == "adriano@gmail.com")
        #expect(user.password == "123")
        #expect(user.birthday == Date.now.remove(years: 18).zeroSeconds())
        #expect(user.language == .portuguese)
    }

}

12 - Creating the SignUpFormView

Finally, with the form tested, we can forward the the form view, then within Views > SignUp update the SignUpFormView with the code below:

import SwiftUI
import Formidable

struct SignUpFormView: View {

    @State private var form = SignUpForm()

    var body: some View {
        NavigationStack {
            Form {
                Section {
                    TextField(
                        "Name",
                        text: $form.nameField.value
                    )
                    .field($form.nameField)

                    TextField(
                        "E-mail",
                        text: $form.emailField.value
                    )
                    .textInputAutocapitalization(.never)
                    .keyboardType(.emailAddress)
                    .field($form.emailField)

                    SecureField(
                        "Password",
                        text: $form.passwordField.value
                    )
                    .field($form.passwordField)
                }

                Section {
                    DatePicker(
                        "Birth",
                        selection: $form.birthField.value,
                        displayedComponents: .date
                    )
                    .field($form.birthField)

                    Picker(
                        "Language",
                        selection: $form.languageField.value
                    ) {
                        ForEach(Language.allCases, id: \.self) { language in
                            Text(language.detail)
                        }
                    }
                    .field($form.languageField)
                }

                Section {
                    Toggle("Terms", isOn: $form.agreeTermsField.value)
                        .field($form.agreeTermsField)
                }
            }
            .navigationTitle("SignUp")
            .toolbar {
                ToolbarItemGroup() {
                    Button(action: reset) {
                        Text("Reset")
                    }
                    .disabled(form.isDisabled)

                    Button(action: save) {
                        Text("Save")
                    }
                }
            }
            .onAppear {
                UITextField.appearance().clearButtonMode = .whileEditing
            }
        }
    }

    // MARK: - Private Methods

    private func reset() {
        form.reset()
    }

    private func save() {
        do {
            let user = try form.submit()
            print(user)
        } catch {}
    }

}

#Preview {
    SignUpFormView()
}

Done! We have a complete form with all the business rules and fully tested.

13 - Bonus

Make a folder called Components, inside of Components create a file called RequirementsView and add the code below:

import SwiftUI

struct RequirementsView: View {

    // MARK: - Private Properties

    private let nameIsValid: Bool
    private let emailIsValid: Bool
    private let passwordIsValid: Bool
    private let birthIsValid: Bool
    private let languageIsValid: Bool
    private let agreeTerms: Bool

    private var requirements: [(label: String, status: Bool)] {
        [
            (label: "Valid name.", status: nameIsValid),
            (label: "Valid e-mail.", status: emailIsValid),
            (label: "Valid password.", status: passwordIsValid),
            (label: "To be legal age.", status: birthIsValid),
            (label: "Select a language.", status: languageIsValid),
            (label: "Agree terms.", status: agreeTerms)
        ]
    }

    // MARK: - Public Properties

    var body: some View {
        VStack(alignment: .leading) {
            ForEach(requirements, id: \.label) { requirement in
                HStack {
                    ZStack {
                        Circle()
                            .stroke(lineWidth: 2)
                            .fill(requirement.status ? .green : .gray)
                            .frame(width: 8, height: 8)
                        Circle()
                            .fill(requirement.status ? .green : .clear)
                            .frame(width: 8, height: 8)
                    }
                    Text(requirement.label)
                        .strikethrough(requirement.status)
                }
            }
        }
    }

    // MARK: - Initializers

    init(
        nameIsValid: Bool,
        emailIsValid: Bool,
        passwordIsValid: Bool,
        birthIsValid: Bool,
        languageIsValid: Bool,
        agreeTerms: Bool
    ) {
        self.nameIsValid = nameIsValid
        self.emailIsValid = emailIsValid
        self.passwordIsValid = passwordIsValid
        self.birthIsValid = birthIsValid
        self.languageIsValid = languageIsValid
        self.agreeTerms = agreeTerms
    }

}

#Preview {
    RequirementsView(
        nameIsValid: true,
        emailIsValid: false,
        passwordIsValid: false,
        birthIsValid: false,
        languageIsValid: false,
        agreeTerms: false
    )
}

Now, update the SignUpFormView adding the RequirementsView with a child of terms section.

...

Section {
     Toggle("Terms", isOn: $form.agreeTermsField.value)
            .field($form.agreeTermsField)
} footer: {
     RequirementsView(
          nameIsValid: form.nameField.isValid,
          emailIsValid: form.emailField.isValid,
          passwordIsValid: form.passwordField.isValid,
          birthIsValid: form.birthField.isValid,
          languageIsValid: form.languageField.isValid,
          agreeTerms: form.agreeTermsField.isValid
     )
     .padding(.top, 4)
}

...

14 - Complete code

You can view and download this project at https://github.com/didisouzacosta/Formidable.

If you like, give it a star!