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
}
}
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.
One thought on “Building a Form in SwiftUI – Part 3 – Validation”