Greetings, traveler!
When working with forms in SwiftUI, validation logic can quickly become tangled—especially as the number of fields grows. In this article, I’ll walk you through a demo implementation that focuses on clean, modular validation for text fields, using a flexible architecture that scales well.
Although the code also supports debounced validation and custom focus handling, we won’t dive into those here. If you’re curious, I’ve broken those topics down in separate posts:
👉 Custom Focus Handling
👉 Debounced Input in SwiftUI
Let’s focus on what matters in this demo: validation.
Centralized Validation Logic
All validation rules are collected in a single struct named Validator
, making it easy to define and reuse logic:
struct Validator {
static func email(_ email: String) -> ValidationResult {
email.isValidEmail ? .success : .failure(message: "Invalid Email")
}
static func nonEmpty(_ string: String) -> ValidationResult {
!string.isEmpty ? .success : .failure(message: "This field must not be empty")
}
}
The result of each validation is encapsulated in a ValidationResult
struct:
struct ValidationResult: Equatable {
let isValid: Bool
let failureMessage: String
static let success = Self(isValid: true, failureMessage: "")
static func failure(message: String) -> Self {
.init(isValid: false, failureMessage: message)
}
}
This approach keeps results structured and makes UI updates predictable and easy to manage.
Field Abstraction and Rules
The Field
enum defines all available inputs. Each case provides its own validation rule:
enum Field: String, CaseIterable, Identifiable {
case email
case regular
var id: String { rawValue }
var validationRule: (String) -> ValidationResult {
switch self {
case .email: Validator.email
case .regular: Validator.nonEmpty
}
}
var placeholder: String {
switch self {
case .email: "Enter email"
case .regular: "Enter some text"
}
}
}
This abstraction makes it easy to dynamically generate fields, attach rules, and present UI labels—all in one place.
Binding Form State to Field Definitions
Form state is stored using @State
properties. Each field’s value and validation result is bound using helper methods that map the field enum to the correct property:
private func text(for field: Field) -> Binding<String> {
switch field {
case .email: $email
case .regular: $someText
}
}
Validation results are stored in a dictionary, initialized with all fields set to .success
.
Reusable Validation Modifier
A key part of the architecture is the .validate()
view modifier. It’s applied to each TextField
, and handles:
- When validation is triggered (onChange, onSubmit, onFocusLost)
- Optional debounce interval
- Updating results via a callback
extension View {
func validate<Field: Equatable>(
_ field: Field,
with text: Binding<String>,
rule: @escaping (String) -> ValidationResult,
options: ValidationOptions,
debounce: Duration?,
focusedField: Binding<Field?>,
onValidation: @escaping (ValidationResult) -> Void
) -> some View {
modifier(
TextFieldValidator(
text: text,
focusedField: focusedField,
validationRule: rule,
options: options,
debounce: debounce,
currentField: field,
onValidation: onValidation
)
)
}
}
This allows each field to define its own validation trigger and behavior, without duplicating code.
To make the validation logic truly modular and adaptable, this demo introduces a custom OptionSet
called ValidationOptions
. It allows each text field to specify when exactly validation should occur — whether on submit, on text change, on focus change, or any combination of these.
Here’s how it’s defined:
struct ValidationOptions: OptionSet {
let rawValue: Int
static let onSubmit = ValidationOptions(rawValue: 1 << 0)
static let onChange = ValidationOptions(rawValue: 1 << 1)
static let onEditingChanged = ValidationOptions(rawValue: 1 << 2)
static let all: ValidationOptions = [.onSubmit, .onChange, .onEditingChanged]
}
Using this bitmask-based structure (much like UIView.AnimationOptions
or SwiftUI.Alignment
), it becomes easy to enable or disable validation triggers with a single line:
.validate(
field,
with: text(for: field),
rule: field.validationRule,
options: .all,
...
)
Or you can use only a subset:
options: [.onSubmit, .onEditingChanged]
Note
By the way, you can read more about OptionSet here.
Custom Modifier: TextFieldValidator
The core of this validation system is the TextFieldValidator
view modifier. It encapsulates the logic needed to observe and respond to user input and focus changes in a modular way:
private struct TextFieldValidator<Field: Equatable>: ViewModifier {
@Binding var text: String
@Binding var focusedField: Field?
let validationRule: (String) -> ValidationResult
let options: ValidationOptions
let debounce: Duration?
let currentField: Field
let onValidation: (ValidationResult) -> Void
func body(content: Content) -> some View {
content
.modify { view in
if options.contains(.onSubmit) {
view.onSubmit {
onValidation(validationRule(text))
}
} else {
view
}
}
.modify { view in
if options.contains(.onChange) {
view.onChangeDebounced(of: text, for: debounce ?? .zero) { _, newValue in
onValidation(validationRule(newValue))
}
} else {
view
}
}
.modify { view in
if options.contains(.onEditingChanged) {
view.onChange(of: focusedField) { oldValue, newValue in
if oldValue == currentField && newValue != currentField {
onValidation(validationRule(text))
}
}
} else {
view
}
}
}
}
Each supported validation trigger is added conditionally, based on the options passed to the modifier. This avoids hardcoding specific behavior into the view and allows the calling context to decide when validation should occur.
This level of configurability ensures the modifier remains reusable and easy to test or extend.
Cleaner Code with .modify
SwiftUI doesn’t natively support fluent chaining of conditional view modifications. To solve this, a small but powerful modify
extension is introduced:
extension View {
func modify(@ViewBuilder _ transform: (Self) -> some View) -> some View {
transform(self)
}
}
This helper enables you to write conditional .onChange
, .onSubmit
, or any other modifiers in a cleaner, readable style without deeply nesting logic or duplicating code.
For example:
content
.modify { view in
if condition {
view.onChange(of: value) { ... }
} else {
view
}
}
This makes the TextFieldValidator
not only more readable, but also more maintainable in the long run.
UI Feedback
Validation results are used to drive UI updates with simple logic:
RoundedRectangle(cornerRadius: 12)
.stroke(validations[field, default: .success].isValid ? Color.secondary : Color.red, lineWidth: 1)
Text(validations[field]?.failureMessage ?? "")
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .leading)
Final Thoughts
This architecture separates validation logic from the UI and keeps everything clean and modular. Adding a new field or rule requires minimal effort—just extend the enum and the validator. You can also change triggers or debounce behavior per field without affecting the others.
If you’re working with SwiftUI forms and want better structure around validation, I hope this approach provides a useful blueprint.
If you’re interested in the debounce and focus-related mechanics, feel free to check out my other posts linked above.
Full code:
import SwiftUI
struct Validator {
static func email(_ email: String) -> ValidationResult {
email.isValidEmail ? .success : .failure(message: "Invalid Email")
}
static func nonEmpty(_ string: String) -> ValidationResult {
!string.isEmpty ? .success : .failure(message: "This field must not be empty")
}
}
enum Field: String, CaseIterable, Identifiable {
case email
case regular
var id: String {
rawValue
}
var validationRule: (String) -> ValidationResult {
switch self {
case .email: Validator.email
case .regular: Validator.nonEmpty
}
}
var placeholder: String {
switch self {
case .email: "Enter email"
case .regular: "Enter some text"
}
}
}
struct ValidationResult: Equatable {
let isValid: Bool
let failureMessage: String
static let success: Self = .init(isValid: true, failureMessage: "")
static func failure(message: String) -> Self {
.init(isValid: false, failureMessage: message)
}
}
struct ValidationDemo: View {
@State private var email = ""
@State private var someText = ""
@State private var focusedField: Field?
@State private var validations: [Field: ValidationResult] = Dictionary(
uniqueKeysWithValues: Field.allCases.map { ($0, .success) }
)
var body: some View {
ScrollView {
VStack {
ForEach(Field.allCases) { field in
textField(field)
}
}
.padding()
}
}
private func textField(_ field: Field) -> some View {
VStack {
TextField(field.placeholder, text: text(for: field))
.isFocused($focusedField, equals: field)
.validate(
field,
with: text(for: field),
rule: field.validationRule,
options: .all,
debounce: .milliseconds(400),
focusedField: $focusedField
) { result in
guard validations[field] != result else { return }
withAnimation {
validations[field] = result
}
}
.padding()
.background {
RoundedRectangle(cornerRadius: 12)
.fill(.clear)
.stroke(
validations[field, default: .success].isValid ? Color.secondary : Color.red,
lineWidth: 1
)
}
Text(validations[field]?.failureMessage ?? "")
.foregroundColor(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func text(for field: Field) -> Binding<String> {
switch field {
case .email: $email
case .regular: $someText
}
}
}
struct ValidationOptions: OptionSet {
let rawValue: Int
static let onSubmit = ValidationOptions(rawValue: 1 << 0)
static let onChange = ValidationOptions(rawValue: 1 << 1)
static let onEditingChanged = ValidationOptions(rawValue: 1 << 2)
static let all: ValidationOptions = [.onSubmit, .onChange, .onEditingChanged]
}
extension View {
func validate<Field: Equatable>(
_ field: Field,
with text: Binding<String>,
rule: @escaping (String) -> ValidationResult,
options: ValidationOptions,
debounce: Duration?,
focusedField: Binding<Field?>,
onValidation: @escaping (ValidationResult) -> Void
) -> some View {
modifier(
TextFieldValidator(
text: text,
focusedField: focusedField,
validationRule: rule,
options: options,
debounce: debounce,
currentField: field,
onValidation: onValidation
)
)
}
}
private struct TextFieldValidator<Field: Equatable>: ViewModifier {
@Binding var text: String
@Binding var focusedField: Field?
let validationRule: (String) -> ValidationResult
let options: ValidationOptions
let debounce: Duration?
let currentField: Field
let onValidation: (ValidationResult) -> Void
func body(content: Content) -> some View {
content
.modify { view in
if options.contains(.onSubmit) {
view.onSubmit {
onValidation(validationRule(text))
}
} else {
view
}
}
.modify { view in
if options.contains(.onChange) {
view.onChangeDebounced(of: text, for: debounce ?? .zero) { _, newValue in
onValidation(validationRule(newValue))
}
} else {
view
}
}
.modify { view in
if options.contains(.onEditingChanged) {
view.onChange(of: focusedField) { oldValue, newValue in
if oldValue == currentField && newValue != currentField {
onValidation(validationRule(text))
}
}
} else {
view
}
}
}
}
extension View {
func modify(@ViewBuilder _ transform: (Self) -> some View) -> some View {
transform(self)
}
}
extension View {
func isFocused<T: Hashable>(
_ binding: Binding<T?>,
equals value: T
) -> some View {
modifier(
FocusModifier(
binding: binding,
value: value
)
)
}
}
private struct FocusModifier<T: Hashable>: ViewModifier {
@Binding var binding: T?
let value: T
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focused($focused)
.onChange(of: binding) { _, newValue in
focused = (newValue == value)
}
.onChange(of: focused) { _, newValue in
if newValue {
binding = value
} else if binding == value {
binding = nil
}
}
}
}
extension String {
var isValidEmail: Bool {
guard !self.lowercased().hasPrefix("mailto:") else {
return false
}
guard let emailDetector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return false
}
let matches = emailDetector.matches(
in: self,
options: NSRegularExpression.MatchingOptions.anchored,
range: NSRange(location: 0, length: self.count)
)
guard matches.count == 1 else {
return false
}
return matches[0].url?.scheme == "mailto"
}
}
If you enjoyed this article, please feel free to follow me on my social media: