DEV Community

Cover image for Form validation in SwiftUI using Formidable
Adriano Souza Costa
Adriano Souza Costa

Posted on

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)
    }

}
Enter fullscreen mode Exit fullscreen mode

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."
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        ]
    }

}
Enter fullscreen mode Exit fullscreen mode

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)!
    }

}
Enter fullscreen mode Exit fullscreen mode

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)
    ]

    ...
}

...
Enter fullscreen mode Exit fullscreen mode

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
    }

}
Enter fullscreen mode Exit fullscreen mode

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
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Now, go to update the SignUpForm:

...

var languageField: FormField<Language>

...

init() {
...

self.languageField = .init(.none)

...
}

Enter fullscreen mode Exit fullscreen mode

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
    }

}

Enter fullscreen mode Exit fullscreen mode

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
     )
}

...
Enter fullscreen mode Exit fullscreen mode

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)
    }

}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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)
}

...
Enter fullscreen mode Exit fullscreen mode

14 - Complete code

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

If you like, give it a star!

Image of Docusign

🛠️ Bring your solution into Docusign. Reach over 1.6M customers.

Docusign is now extensible. Overcome challenges with disconnected products and inaccessible data by bringing your solutions into Docusign and publishing to 1.6M customers in the App Center.

Learn more

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs