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<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
- 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 {
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!
Top comments (0)