Skip to content

Forms and Validation

Daniel S edited this page Jul 2, 2026 · 1 revision

Forms and Validation

DesignFoundation ships a complete form validation system: DFValidationState for per-field feedback, DFFieldValidator for composable validation rules, and DFFormState — an @Observable class that ties fields, validators, and submission logic together. This page documents the full API and shows three complete form examples.

Related pages: Text-Fields-and-Inputs · Buttons · Layout · Getting-Started


DFValidationState

The unit of per-field feedback. All input components that accept a validationState argument read this type.

public enum DFValidationState: Sendable, Equatable {
    case none              // No feedback shown; initial state
    case valid             // Green border; no message
    case error(String)     // Red border + inline error message below the field
}

Surfacing Rules

State Border color Message shown When to use
.none theme.colors.border None Before the user has touched the field
.valid theme.colors.success None Field passes all validators
.error("msg") theme.colors.error Yes — the associated string Field fails at least one validator

Prefer .none over .error until the user has interacted with the field to avoid prematurely flagging empty required fields.


DFTextField — Validation-Focused Reference

DFTextField accepts DFValidationState directly. All style and initializer details are in Text-Fields-and-Inputs.

// Four initializer signatures — all share the same validationState parameter
DFTextField("Email", text: $email, validationState: emailState)

DFTextField("Email", text: $email, validationState: emailState) {
    Image(systemName: "envelope")
}

DFTextField("Email", text: $email, validationState: emailState) {
    Image(systemName: "envelope")
} trailing: {
    clearButton
}

Style Variants

Style Token Default
.outlined DFOutlinedTextFieldStyle Yes
.filled DFFilledTextFieldStyle No
.glass DFGlassTextFieldStyle No — iOS 26+ / macOS 26+

Validation Expressions vs. DFFormState

Use a computed property for simple, single-field validation. Use DFFormState when you need cross-field interaction (e.g., confirm-password matching) or when form-wide isValid drives the submit button.

// Simple computed property (adequate for most single-field cases)
private var emailState: DFValidationState {
    guard !email.isEmpty else { return .none }
    return email.contains("@") ? .valid : .error("Enter a valid email address")
}

DFSecureField — Validation-Focused Reference

DFSecureField accepts DFValidationState identically to DFTextField. It adds a show/hide toggle on the trailing edge.

DFSecureField("Password", text: $password, validationState: passwordState)
DFSecureField("Confirm Password", text: $confirm, validationState: confirmState)

DFValidatedTextField

DFValidatedTextField is a thin convenience wrapper that connects a field to a DFFormState registration. It is the preferred way to render fields when using DFFormState.

public struct DFValidatedTextField: View {
    public init(
        _ label:    String,
        fieldId:    String,
        formState:  DFFormState,
        placeholder: String = ""
    )
}

DFValidatedTextField reads the DFValidationState for fieldId from formState automatically — you do not need to pass it manually.


DFFieldValidator Protocol

DFFieldValidator is the composable validation building block.

public protocol DFFieldValidator: Sendable {
    func validate(_ value: String) -> DFValidationState
}

Built-in Validators

Validator Initializer Behavior
DFRequiredValidator DFRequiredValidator(message:) .error when value is empty
DFEmailValidator DFEmailValidator(message:) .error when value is not a valid email
DFMinLengthValidator DFMinLengthValidator(min:message:) .error when count < min
DFMaxLengthValidator DFMaxLengthValidator(max:message:) .error when count > max
DFRegexValidator DFRegexValidator(pattern:message:) .error when NSRegularExpression doesn't match
let emailValidator   = DFEmailValidator(message: "Enter a valid email address")
let minLengthVal     = DFMinLengthValidator(min: 8, message: "Must be at least 8 characters")
let maxLengthVal     = DFMaxLengthValidator(max: 128, message: "Must be 128 characters or fewer")
let uppercaseVal     = DFRegexValidator(pattern: ".*[A-Z].*", message: "Must contain an uppercase letter")
let lowercaseVal     = DFRegexValidator(pattern: ".*[a-z].*", message: "Must contain a lowercase letter")
let digitVal         = DFRegexValidator(pattern: ".*[0-9].*", message: "Must contain a number")
let specialCharVal   = DFRegexValidator(pattern: ".*[^A-Za-z0-9].*", message: "Must contain a special character")
let phoneVal         = DFRegexValidator(pattern: #"^\+?[1-9]\d{1,14}$"#, message: "Enter a valid phone number")

Custom Validators

struct UniqueUsernameValidator: DFFieldValidator, Sendable {
    let existingUsernames: [String]

    func validate(_ value: String) -> DFValidationState {
        guard !value.isEmpty else { return .none }
        return existingUsernames.contains(value.lowercased())
            ? .error("That username is already taken")
            : .valid
    }
}
struct URLValidator: DFFieldValidator, Sendable {
    func validate(_ value: String) -> DFValidationState {
        guard !value.isEmpty else { return .none }
        guard let url = URL(string: value),
              url.scheme != nil,
              url.host != nil
        else {
            return .error("Enter a valid URL including https://")
        }
        return .valid
    }
}
struct PasswordStrengthValidator: DFFieldValidator, Sendable {
    func validate(_ value: String) -> DFValidationState {
        guard !value.isEmpty else { return .none }
        var score = 0
        if value.count >= 8           { score += 1 }
        if value.range(of: "[A-Z]", options: .regularExpression) != nil { score += 1 }
        if value.range(of: "[0-9]", options: .regularExpression) != nil { score += 1 }
        if value.range(of: "[^A-Za-z0-9]", options: .regularExpression) != nil { score += 1 }
        return score < 3
            ? .error("Password too weak — add uppercase, numbers, or symbols")
            : .valid
    }
}

Combining Validators

DFFormState accepts an array of validators per field and runs them in order, stopping at the first .error.

formState.register(
    fieldId: "password",
    validators: [
        DFRequiredValidator(message: "Password is required"),
        DFMinLengthValidator(min: 8, message: "Must be at least 8 characters"),
        PasswordStrengthValidator(),
    ]
)

To combine two validators into a single one:

struct CombinedValidator: DFFieldValidator, Sendable {
    let validators: [any DFFieldValidator]

    func validate(_ value: String) -> DFValidationState {
        for validator in validators {
            let result = validator.validate(value)
            if case .error = result { return result }
        }
        return value.isEmpty ? .none : .valid
    }
}

DFFormState

DFFormState is an @Observable class that coordinates multi-field forms. It maintains a dictionary of field values, validation states, and touched states. The isValid property is true only when all registered fields are in the .valid state.

Initialization

@State private var loginForm = DFFormState()

Or with initial values:

@State private var editForm = DFFormState(initialValues: [
    "displayName": user.displayName,
    "email":       user.email,
    "bio":         user.bio ?? "",
])

Field Registration

Register fields before using them. Registration happens in .onAppear or directly in the view body when using DFValidatedTextField.

loginForm.register(
    fieldId:    "email",
    validators: [
        DFRequiredValidator(message: "Email is required"),
        DFEmailValidator(message: "Enter a valid email address"),
    ]
)

loginForm.register(
    fieldId:    "password",
    validators: [
        DFRequiredValidator(message: "Password is required"),
        DFMinLengthValidator(min: 8, message: "Must be at least 8 characters"),
    ]
)

Validation Lifecycle

Method Description
formState.touch(fieldId:) Mark a field as touched. Run its validators.
formState.touchAll() Mark all fields as touched and run all validators.
formState.validate() Run all validators; returns true if all pass.
formState.reset() Clear all values, states, and touched flags.

Typically, call touchAll() on submit-button tap before checking isValid:

DFButton("Create Account") {
    if formState.validate() {
        createAccount(formState.values)
    }
}
.disabled(!formState.isValid)

Generating Bindings

DFTextField("Email", text: formState.binding(for: "email"))

// Or let DFValidatedTextField handle it
DFValidatedTextField("Email", fieldId: "email", formState: formState)

Full API Reference

Property/Method Type Description
isValid Bool true when all registered fields are .valid
values [String: String] Current value for each field ID
states [String: DFValidationState] Current validation state per field ID
register(fieldId:validators:) Void Register a field and its validators
binding(for:) Binding<String> Two-way binding that triggers validation on change
state(for:) DFValidationState Current state for a specific field
touch(fieldId:) Void Marks field as touched and validates it
touchAll() Void Marks all fields as touched and validates all
validate() Bool Validates all fields; returns isValid
reset() Void Clears all values and resets all states to .none
setValue(_:for:) Void Programmatically set a field value

Complete Form Examples

1. Login Form

import SwiftUI
import DesignFoundation

struct LoginView: View {
    @State private var form = DFFormState()
    @State private var isSubmitting = false
    @State private var loginError: String? = nil

    var body: some View {
        VStack(spacing: 24) {
            DFText("Sign In", style: .title)

            VStack(spacing: 16) {
                DFValidatedTextField("Email", fieldId: "email", formState: form, placeholder: "you@example.com")
                DFValidatedTextField("Password", fieldId: "password", formState: form, placeholder: "Password")
                // Note: DFSecureField variant:
                // DFSecureField("Password", text: form.binding(for: "password"), validationState: form.state(for: "password"))
            }

            if let error = loginError {
                DFText(error, style: .caption)
                    .foregroundColor(theme.colors.error)
            }

            DFButton(isSubmitting ? "Signing in…" : "Sign In", isLoading: isSubmitting) {
                submit()
            }
            .frame(maxWidth: .infinity)
            .disabled(!form.isValid || isSubmitting)

            DFButton("Forgot password?") { resetPassword() }
                .dfButtonStyle(.ghost)
        }
        .padding(theme.spacing.xl)
        .onAppear { registerFields() }
    }

    private func registerFields() {
        form.register(fieldId: "email",    validators: [DFRequiredValidator(), DFEmailValidator()])
        form.register(fieldId: "password", validators: [DFRequiredValidator(), DFMinLengthValidator(min: 8)])
    }

    private func submit() {
        guard form.validate() else { return }
        isSubmitting = true
        Task {
            do {
                try await AuthService.signIn(email: form.values["email"]!, password: form.values["password"]!)
            } catch {
                loginError = error.localizedDescription
            }
            isSubmitting = false
        }
    }

    private func resetPassword() { /* navigate */ }

    @Environment(\.dfTheme) private var theme
}

2. Registration Form

struct RegistrationView: View {
    @State private var form = DFFormState()
    @Environment(\.dfTheme) private var theme

    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                DFText("Create Account", style: .title)

                DFValidatedTextField("Display name",      fieldId: "name",     formState: form)
                DFValidatedTextField("Email address",     fieldId: "email",    formState: form, placeholder: "you@example.com")
                DFValidatedTextField("Username",          fieldId: "username", formState: form, placeholder: "@handle")

                // Password with secure field and manual binding
                DFSecureField(
                    "Password",
                    text: form.binding(for: "password"),
                    validationState: form.state(for: "password")
                )

                DFSecureField(
                    "Confirm password",
                    text: form.binding(for: "confirm"),
                    validationState: form.state(for: "confirm")
                )

                DFButton("Create Account") {
                    if form.validate() { createAccount() }
                }
                .frame(maxWidth: .infinity)
                .disabled(!form.isValid)
            }
            .padding(theme.spacing.xl)
        }
        .onAppear { registerFields() }
    }

    private func registerFields() {
        form.register(fieldId: "name",     validators: [DFRequiredValidator(message: "Name is required")])
        form.register(fieldId: "email",    validators: [DFRequiredValidator(), DFEmailValidator()])
        form.register(fieldId: "username", validators: [
            DFRequiredValidator(),
            DFMinLengthValidator(min: 3, message: "At least 3 characters"),
            DFMaxLengthValidator(max: 20, message: "20 characters maximum"),
            DFRegexValidator(pattern: "^[a-z0-9_]+$", message: "Lowercase letters, numbers, and underscores only"),
        ])
        form.register(fieldId: "password", validators: [
            DFRequiredValidator(),
            DFMinLengthValidator(min: 8),
            PasswordStrengthValidator(),
        ])
        form.register(fieldId: "confirm", validators: [
            DFRequiredValidator(message: "Please confirm your password"),
            MatchesFieldValidator(fieldId: "password", form: form, message: "Passwords don't match"),
        ])
    }

    private func createAccount() { /* … */ }
}

struct MatchesFieldValidator: DFFieldValidator, Sendable {
    let fieldId: String
    let form: DFFormState
    let message: String

    func validate(_ value: String) -> DFValidationState {
        guard !value.isEmpty else { return .none }
        return value == form.values[fieldId] ? .valid : .error(message)
    }
}

3. Profile Edit Form

struct ProfileEditView: View {
    let user: User
    @State private var form: DFFormState
    @Environment(\.dfTheme) private var theme

    init(user: User) {
        self.user = user
        _form = State(initialValue: DFFormState(initialValues: [
            "displayName": user.displayName,
            "bio":         user.bio ?? "",
            "website":     user.website ?? "",
            "location":    user.location ?? "",
        ]))
    }

    var body: some View {
        ScrollView {
            VStack(spacing: 20) {
                DFCard {
                    VStack(spacing: 16) {
                        DFText("Profile Information", style: .headline)

                        DFValidatedTextField("Display name", fieldId: "displayName", formState: form)
                        DFValidatedTextField("Bio",          fieldId: "bio",         formState: form, placeholder: "Tell the world about yourself")
                        DFValidatedTextField("Website",      fieldId: "website",     formState: form, placeholder: "https://")
                        DFValidatedTextField("Location",     fieldId: "location",    formState: form, placeholder: "City, Country")
                    }
                }

                HStack(spacing: 12) {
                    DFButton("Cancel") { dismiss() }
                        .dfButtonStyle(.outlined)

                    DFButton("Save Changes") {
                        if form.validate() { saveProfile() }
                    }
                    .disabled(!form.isValid)
                }
            }
            .padding(theme.spacing.lg)
        }
        .onAppear { registerFields() }
    }

    private func registerFields() {
        form.register(fieldId: "displayName", validators: [
            DFRequiredValidator(message: "Display name is required"),
            DFMaxLengthValidator(max: 50, message: "50 characters maximum"),
        ])
        form.register(fieldId: "bio", validators: [
            DFMaxLengthValidator(max: 280, message: "Bio must be 280 characters or fewer"),
        ])
        form.register(fieldId: "website", validators: [
            URLValidator(),
        ])
        form.register(fieldId: "location", validators: [
            DFMaxLengthValidator(max: 100, message: "100 characters maximum"),
        ])
    }

    private func saveProfile() { /* persist */ }
    private func dismiss()     { /* navigate back */ }
}

Validation Patterns

When to Validate

Trigger Pattern
User tabs out of a field .onChange on focus + form.touch(fieldId:)
User types into a field Real-time via DFFormState's binding
User taps submit form.validate() — marks all fields as touched
Async check (e.g., unique username) Debounce in a Task + form.setValue + manual state update

Avoiding Annoyance

  • Show .error only after the user has interacted with the field (check form.isTouched(fieldId:))
  • Don't show password strength errors until the user has left the field
  • For username uniqueness checks, debounce by at least 500 ms

Accessible Error Messaging

DFValidatedTextField and the validationState parameter on DFTextField/DFSecureField automatically:

  1. Apply .accessibilityValue with the error string
  2. Set .accessibilityHint to prompt the user to correct the field
  3. Move VoiceOver focus to the first invalid field when touchAll() is called

See also: Text-Fields-and-Inputs · Buttons · Overlays-and-Feedback · Layout · Getting-Started

Clone this wiki locally