MangoByte MangoByte
 ·  Essay

Enum-driven SwiftUI TextField Part 2: Two Stage Validation System (The foundation)

In Part 1, we built a solid foundation for our input field using enum-driven state management. Now, let’s add a professional-grade validation system that’s:

  • Type-safe with compile-time checks
  • User-friendly with real-time feedback
  • Easy to extend with custom rules

Core Concept

Instead of simple valid/invalid states, we’ll implement a two-stage validation system:

  1. Real-time validation while typing (more relaxed)
  2. Complete validation on blur (more strict)

This approach gives users immediate feedback while preventing frustration from premature error messages.

Key Components

Validation Types

enum InputValidationType {
    case required
    case email
    case phone
    case custom((String) -> ValidationResult)
}

enum ValidationResult {
    case valid
    case invalid(String)
}

Why enums? Again, we get compile-time safety and clear, impossible-to-misuse validation rules.

Configuring the Configurator!

struct InputConfig {
    let placeholder: String
    let validations: [InputValidationType]
    
    init(
        placeholder: String,
        validations: [InputValidationType] = []
    ) {
        self.placeholder = placeholder
        self.validations = validations
    }
}

Two-Stage Validation Implementation

extension InputField {
    func updateState() {
        if isFocused {
            validateWhileTyping()  // Stage 1: Real-time
        } else {
            validateOnBlur()       // Stage 2: Complete (when unfocused)
        }
    }
}

Stage 1: Real-time Validation

func validateWhileTyping() {
    // Skip empty validation while typing
    guard !text.isEmpty else {
        inputState = .focused(.empty)
        return
    }
    
    // Run format validations only
    for validation in config.validations {
        switch validation {
        case .email:
            if !isValidEmail(text) {
                inputState = .focused(.invalid("Invalid email format"))
                return
            }
        case .phone:
            if !isValidPhone(text) {
                inputState = .focused(.invalid("Invalid phone number"))
                return
            }
        case .required:
            continue // Skip required check while typing
        case let .custom(validator):
            switch validator(text) {
                case .valid: break
                case let .invalid(message):
                    inputState = .focused(.invalid(message))
                    return
            }
        }
    }
    
    inputState = .focused(.valid)
}

Stage 2: Complete Validation

func validateOnBlur() {
    // 1. Check required field first
    if config.validations.contains(where: {
        if case .required = $0 { return true }
        return false
    }) && text.isEmpty {
        inputState = .inactive(.invalid("This field is required"))
        return
    }
    
    // 2. Skip other validations if empty
    guard !text.isEmpty else {
        inputState = .idle
        return
    }
    
    // 3. Run all validations
    for validation in config.validations {
        switch validation {
        case .required: continue // Already checked
        case .email:
            if !isValidEmail(text) {
                inputState = .inactive(.invalid("Invalid email format"))
                return
            }
        case .phone:
            if !isValidPhone(text) {
                inputState = .inactive(.invalid("Invalid phone number"))
                return
            }
        case let .custom(validator):
            switch validator(text) {
            case .valid: break
            case let .invalid(message):
                inputState = .inactive(.invalid(message))
                return
            }
        }
    }
    
    inputState = .inactive(.valid)
}

Usage Example

struct ContentView: View {
    @State private var email = ""
    @State private var phone = ""
    @State private var name = ""
    
    var body: some View {
        VStack(spacing: 24) {
            // Basic required field
            InputField(
                config: .init(
                    placeholder: "Name",
                    validations: [.required]
                ),
                text: $name
            )
            
            // Email with multiple validations
            InputField(
                config: .init(
                    placeholder: "Email",
                    validations: [.required, .email]
                ),
                text: $email
            )
            
            // Phone with custom error message
            InputField(
                config: .init(
                    placeholder: "Phone",
                    validations: [
                        .required,
                        .phone,
                        .custom { text in
                            guard text.hasPrefix("+") else {
                                return .invalid("Number must start with '+'")
                            }
                            return .valid
                        }
                    ]
                ),
                text: $phone
            )
        }
        .padding()
    }
}

Notes on Implementation

Why Two Stages?

The two-stage validation system serves multiple purposes:

User Experience (UX)

  • Immediate format feedback while typing
  • Delayed required field validation
  • Progressive disclosure of errors

Performance

  • Validates only what’s necessary when necessary
  • Prevents validation thrashing
  • Optimizes for common use cases

Developer Experience (DX)

  • Clear separation of validation stages
  • Type-safe validation rules
  • Easy to extend and maintain

Why Return Early?

Our validation functions use early returns for several reasons:

  1. Shows only one error at a time, avoiding overwhelming the user with error messages
  2. Prevents unnecessary validation checks
  3. Makes validation order explicit and controllable
  4. Improves performance by skipping unnecessary validations

Hello @ViewBuilder!

As our input field grows more complex with validation, accessories, and different states, using @ViewBuilder becomes crucial for several key reasons:

View Composition Efficiency

Instead of creating all subviews at once:

var body: some View {
    VStack {
        leadingIcon      // Always created
        textField        // Always created
        trailingIcon     // Always created
        errorMessage     // Always created
    }
}

@ViewBuilder allows conditional composition:

@ViewBuilder
func makeErrorMessage() -> some View {
    if case let .invalid(message) = inputState.validity {
        Text(message)  // Only created when needed
    }
}

Memory Management

  • Views marked with @ViewBuilder are constructed lazily
  • SwiftUI can better optimize memory usage
  • Prevents unnecessary view allocations

State-Dependent Views Our input field has multiple states:

enum InputState {
    case idle
    case focused(InputValidity)
    case inactive(InputValidity)
}

@ViewBuilder lets us efficiently construct different views for each state without creating unused views.

Performance Impact

//Before (Creates all views) 🐌
var errorMessage: some View {
    Group {
        if case let .invalid(message) = inputState.validity {
            Text(message)
        }
    }
}

//After (Creates only needed views) ✌🏼
@ViewBuilder
func makeErrorMessage() -> some View {
    if case let .invalid(message) = inputState.validity {
        Text(message)
    }
}

The enum-driven approach combined with @ViewBuilder gives us both type safety and view construction efficiency.

Standard Validators

// will be expanded more in is part 2-2 of the article!
extension InputField {
    func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        return NSPredicate(format: "SELF MATCHES %@", emailRegex)
            .evaluate(with: email)
    }
    
    func isValidPhone(_ phone: String) -> Bool {
        let phoneRegex = "^\\d{10}$"
        return NSPredicate(format: "SELF MATCHES %@", phoneRegex)
            .evaluate(with: phone)
    }
}

Conclusion

The complete implementation is available in the code snippets below. The validation system integrates seamlessly with our existing enum-driven input field from Part 1, maintaining the same principles of type safety and clean architecture.

This concludes Part 2 of our series. In Part 3 we’ll explore:

  • Password strength rules
  • Cross-field validation (confirm password)
  • Custom validation rules
  • Error message management
  • Format preservation
  • Validation groups
  • Each feature will maintain our enum-driven approach while adding powerful new capabilities to our input field.

Complete code

//
//
// Enum-Driven TextField by MANGO byte
//
//

import SwiftUI

struct MainView: View {
 @State private var name = ""
 @State private var email = ""
 
 var body: some View {
  ScrollView {
   VStack(spacing: 32) {
    InputField(config: .init(placeholder: "Enter your email", validations: [
     .required,
     .email
    ]),
         text: $email)
    
    InputField(config: .init(placeholder: "Enter your name"),
         text: $name)
    
   }
  }
  .contentMargins(16)
 }
}

#Preview {
 MainView()
}

//Input field and state
enum InputState: Equatable {
 case idle
 case focused(InputValidity)
 case inactive(InputValidity)
 
 enum InputValidity: Equatable {
  case empty
  case valid
  case invalid(String)
 }
 
 var validity: InputValidity {
  switch self {
  case .idle: return .empty
  case .focused(let validity): return validity
  case .inactive(let validity): return validity
  }
 }
}

// UI properties driven on input state
extension InputState {
 var tintColor: Color {
  switch self {
  case .idle: return .secondary
  case let .focused(validity):
   switch validity {
   case .empty, .valid: return .blue
   case .invalid: return .red
   }
  case let .inactive(validity):
   switch validity {
   case .empty: return .secondary
   case .valid: return .blue
   case .invalid: return .red
   }
  }
 }
}

// Input field validation
enum InputValidationType {
 case required
 case email
 case phone
 case custom((String) -> ValidationResult)
}

enum ValidationResult {
 case valid
 case invalid(String)
}

// The Configurator!
struct InputConfig {
 let placeholder: String
 let validations: [InputValidationType]
 
 init(
  placeholder: String,
  validations: [InputValidationType] = []
 ) {
  self.placeholder = placeholder
  self.validations = validations
 }
}

// Main View
struct InputField: View {
 private let config: InputConfig
 @Binding private(set) var text: String
 @FocusState private var isFocused: Bool
 @State private var inputState: InputState = .idle
 
 init(config: InputConfig, text: Binding<String>) {
  self.config = config
  self._text = text
 }
 
 var body: some View {
  VStack(alignment: .leading, spacing: 0) {
   ZStack(alignment: .leading) {
    makeTextField()
    makeFloatingLabel()
   }
   
   makeErrorMessage()
  }
  .frame(height: hasError ? 85 : 55)
  .animation(.spring(duration: 0.2), value: inputState)
  .onChange(of: text) { _, _ in updateState() }
  .onChange(of: isFocused) { _, _ in updateState() }
 }
}

// + Sub views
extension InputField {
 @ViewBuilder
 func makeTextField() -> some View {
  TextField("", text: $text)
   .focused($isFocused)
   .padding(.horizontal)
   .frame(height: 55)
   .background(
    Capsule()
     .stroke(inputState.tintColor,
       lineWidth: isFocused ? 2 : 1)
   )
 }
 
 @ViewBuilder
 func makeFloatingLabel() -> some View {
  Text(config.placeholder)
   .padding(.horizontal, 5)
   .background(.background)
   .foregroundStyle(inputState.tintColor)
   .padding(.leading)
   .offset(y: labelOffset)
   .scaleEffect(labelScale)
   .onTapGesture {
    isFocused = true
   }
 }
 
 @ViewBuilder
 func makeErrorMessage() -> some View {
  Group {
   if case let .invalid(message) = inputState.validity {
    Text(message)
     .foregroundStyle(.red)
     .font(.caption)
     .padding(.leading)
     .padding(.top, 4)
   }
  }
 }
}

// + State management
extension InputField {
 func updateState() {
  if isFocused {
   validateWhileTyping()  // Stage 1: Real-time validation while typing
  } else {
   validateOnBlur()       // Stage 2: Complete validation when field loses focus
  }
 }
 
 var labelOffset: CGFloat {
  switch inputState {
  case .idle where text.isEmpty: return 0
  default: return -32
  }
 }
 
 var labelScale: CGFloat {
  switch inputState {
  case .idle where text.isEmpty: return 1
  default: return 0.85
  }
 }
}

// + Validation
extension InputField {
 var hasError: Bool {
  switch inputState {
  case .focused(.invalid), .inactive(.invalid):
   return true
  default:
   return false
  }
 }
 
 func validateWhileTyping() {
  // Skip empty validation while typing
  guard !text.isEmpty else {
   inputState = .focused(.empty)
   return
  }
  
  for validation in config.validations {
   switch validation {
   case .email:
    if !isValidEmail(text) {
     inputState = .focused(.invalid("Invalid email format"))
     return
    }
   case .phone:
    if !isValidPhone(text) {
     inputState = .focused(.invalid("Invalid phone number"))
     return
    }
   case .required:
    continue // Skip required check while typing
   case let .custom(validator):
    switch validator(text) {
    case .valid: break
    case let .invalid(message):
     inputState = .focused(.invalid(message))
     return
    }
   }
  }
  
  inputState = .focused(.valid)
 }
 
 func validateOnBlur() {
  // Check required field first
  if config.validations.contains(where: {
   if case .required = $0 { return true }
   return false
  }) && text.isEmpty {
   inputState = .inactive(.invalid("This field is required"))
   return
  }
  
  // Skip other validations if empty
  guard !text.isEmpty else {
   inputState = .idle
   return
  }
  
  // Run all validations
  for validation in config.validations {
   switch validation {
   case .required: continue // Already checked, continue
   case .email:
    if !isValidEmail(text) {
     inputState = .inactive(.invalid("Invalid email format"))
     return
    }
   case .phone:
    if !isValidPhone(text) {
     inputState = .inactive(.invalid("Invalid phone number"))
     return
    }
   case let .custom(validator):
    switch validator(text) {
    case .valid: break
    case let .invalid(message):
     inputState = .inactive(.invalid(message))
     return
    }
   }
  }
  
  inputState = .inactive(.valid)
 }
 
 func isValidEmail(_ email: String) -> Bool {
  let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
  return NSPredicate(format: "SELF MATCHES %@", emailRegex)
   .evaluate(with: email)
 }
 
 func isValidPhone(_ phone: String) -> Bool {
  let phoneRegex = "^\\d{10}$"
  return NSPredicate(format: "SELF MATCHES %@", phoneRegex)
   .evaluate(with: phone)
 }
}
All notes and essays
© 2026 MangoByte. Built simple, on purpose.