-
Notifications
You must be signed in to change notification settings - Fork 0
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
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
}| 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 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 | Token | Default |
|---|---|---|
.outlined |
DFOutlinedTextFieldStyle |
Yes |
.filled |
DFFilledTextFieldStyle |
No |
.glass |
DFGlassTextFieldStyle |
No — iOS 26+ / macOS 26+ |
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 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 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 is the composable validation building block.
public protocol DFFieldValidator: Sendable {
func validate(_ value: String) -> DFValidationState
}| 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")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
}
}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 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.
@State private var loginForm = DFFormState()Or with initial values:
@State private var editForm = DFFormState(initialValues: [
"displayName": user.displayName,
"email": user.email,
"bio": user.bio ?? "",
])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"),
]
)| 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)DFTextField("Email", text: formState.binding(for: "email"))
// Or let DFValidatedTextField handle it
DFValidatedTextField("Email", fieldId: "email", formState: formState)| 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 |
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
}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)
}
}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 */ }
}| 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 |
- Show
.erroronly after the user has interacted with the field (checkform.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
DFValidatedTextField and the validationState parameter on DFTextField/DFSecureField automatically:
- Apply
.accessibilityValuewith the error string - Set
.accessibilityHintto prompt the user to correct the field - 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