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)