Contents

ryosuke1025/validationlibrary

A lightweight SwiftUI validation library that provides real-time input validation with customizable rules and behaviors.

Demo

| Quick Start| Advanced Usage| |---|---| | <img src="docs/assets/phone_number.gif" width="300"> | <img src="docs/assets/sign_up_form.gif" width="300"> |

Table of Contents

- Validation Modifier - ValidationState - ValidationContainer - ValidationRule - InvalidReason - ValidationTiming - ValidationInitialBehavior

Installation

Option 1: Xcode
  1. File → Add Package Dependencies...
  2. Enter: https://github.com/Ryosuke1025/ValidationLibrary
  3. Select a version rule
  4. Add to your target
Option 2: Package.swift
dependencies: [
    .package(url: "https://github.com/Ryosuke1025/ValidationLibrary.git", from: "1.0.0")
]

Quick Start

Minimal working example:

import SwiftUI
import ValidationLibrary

struct QuickStartView: View {
    @State private var phoneNumber = ""
    @State private var validationState = ValidationState()

    var body: some View {
        VStack {
            TextField("Phone Number", text: $phoneNumber)
                .keyboardType(.phonePad)
                .validation(
                    text: $phoneNumber,
                    state: validationState,
                    rule: .numeric(minLength: 10, maxLength: 11)
                )

            if validationState.hasBeenValidated {
                Text(validationState.isValid ? "Valid" : "Invalid")
                    .foregroundStyle(validationState.isValid ? .green : .red)
            }
        }
    }
}

<details> <summary><b>Full example (matches the demo capture)</b></summary>

import SwiftUI
import ValidationLibrary

struct PhoneNumberView: View {
    @FocusState private var isFocused: Bool

    @State private var phoneNumber = ""
    @State private var validationState = ValidationState()
    
    var body: some View {
        NavigationStack {
            VStack(alignment: .leading, spacing: 8) {
                TextField("Phone Number", text: $phoneNumber)
                    .keyboardType(.phonePad)
                    .focused($isFocused)
                    .validation(
                        text: $phoneNumber,
                        state: validationState,
                        rule: .numeric(minLength: 10, maxLength: 11)
                    )
                    .padding(16)
                    .background(in: .rect(cornerRadius: 16))

                Text("10-11 digits, numbers only")
                    .foregroundStyle(.secondary)
                    .font(.caption)
                
                if validationState.hasBeenValidated {
                    if validationState.isValid {
                        Text("Valid phone number")
                            .foregroundStyle(.green)
                            .font(.caption)
                    } else {
                        Text("Invalid phone number")
                            .foregroundStyle(.red)
                            .font(.caption)
                    }
                }
            }
            .padding(16)
            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
            .background(Color(.systemGroupedBackground))
            .onTapGesture { isFocused = false }
            .navigationTitle("Phone Number")
        }
    }
}

</details>

Advanced Usage

Use `ValidationContainer` to validate multiple fields together.


<details>
<summary><b>Full example (matches the demo capture)</b></summary>

```swift
import SwiftUI
import ValidationLibrary

struct SignUpForm: View {
    enum FocusField {
        case email
        case password
        case confirmPassword
    }
    
    @FocusState private var focusedField: FocusField?
    @State private var email = ""
    @State private var password = ""
    @State private var confirmPassword = ""
    @State private var container = ValidationContainer()
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 32) {
                    emailSection
                    passwordSection
                    confirmPasswordSection
                    statusSection
                    registerButton
                }
                .padding(16)
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            .background(Color(.systemGroupedBackground))
            .onTapGesture { focusedField = nil }
            .navigationTitle("Sign Up")
        }
    }
    
    private var emailSection: some View {
        VStack(alignment: .leading, spacing: 8) {
            TextField("Email", text: $email)
                .textInputAutocapitalization(.never)
                .keyboardType(.emailAddress)
                .focused($focusedField, equals: .email)
                .validation(
                    text: $email,
                    container: container,
                    id: "email",
                    rule: .email
                )
                .padding(16)
                .background(in: .rect(cornerRadius: 16))
                .overlay(alignment: .trailing) {
                    if let state = container.existingState(for: "email"),
                       state.hasBeenValidated {
                        if state.isValid {
                            Image(systemName: "checkmark.circle.fill")
                                .foregroundStyle(.green)
                                .padding(.trailing, 16)
                        } else {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundStyle(.red)
                                .padding(.trailing, 16)
                        }
                    }
                }
            
            Text("Enter your email address")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
    
    // Note: Using TextField instead of SecureField for demo recording purposes
    private var passwordSection: some View {
        VStack(alignment: .leading, spacing: 8) {
            TextField("Password", text: $password)
                .textInputAutocapitalization(.never)
                .focused($focusedField, equals: .password)
                .validation(
                    text: $password,
                    container: container,
                    id: "password",
                    rules: [.required, .password(minLength: 8)]
                )
                .padding(16)
                .background(in: .rect(cornerRadius: 16))
                .overlay(alignment: .trailing) {
                    if let state = container.existingState(for: "password"), state.hasBeenValidated {
                        if state.isValid {
                            Image(systemName: "checkmark.circle.fill")
                                .foregroundStyle(.green)
                                .padding(.trailing, 16)
                        } else {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundStyle(.red)
                                .padding(.trailing, 16)
                        }
                    }
                }
            
            Text("8+ characters, letters and numbers")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }
    
    // Note: Using TextField instead of SecureField for demo recording purposes
    private var confirmPasswordSection: some View {
        VStack(alignment: .leading, spacing: 8) {
            TextField("Confirm Password", text: $confirmPassword)
                .textInputAutocapitalization(.never)
                .focused($focusedField, equals: .confirmPassword)
                .validation(
                    text: $confirmPassword,
                    container: container,
                    id: "confirmPassword",
                    rule: .custom { text in
                        text == password && !text.isEmpty
                    }
                )
                .onChange(of: password) {
                    if let state = container.existingState(for: "confirmPassword"),
                       state.hasBeenValidated {
                        container.validateAll()
                    }
                }
                .padding(16)
                .background(in: .rect(cornerRadius: 16))
                .overlay(alignment: .trailing) {
                    if let state = container.existingState(for: "confirmPassword"), state.hasBeenValidated {
                        if state.isValid {
                            Image(systemName: "checkmark.circle.fill")
                                .foregroundStyle(.green)
                                .padding(.trailing, 16)
                        } else {
                            Image(systemName: "xmark.circle.fill")
                                .foregroundStyle(.red)
                                .padding(.trailing, 16)
                        }
                    }
                }

            Text("Must match password")
                .font(.caption)
                .foregroundStyle(.secondary)
        }
    }

    @ViewBuilder
    private var statusSection: some View {
        if container.hasBeenFullyValidated {
            if container.isFullyValid {
                Text("Valid Input")
                    .foregroundStyle(.green)
            } else {
                Text("Invalid Input")
                    .foregroundStyle(.red)
            }
        }
    }

    private var registerButton: some View {
        Button("Register") {
            focusedField = nil
            container.validateAll()
        }
        .buttonStyle(.borderedProminent)
    }
}
```

</details>

Debug Warnings (Misconfiguration)

In DEBUG builds, the library reports developer-facing issues to help you catch misconfigurations early:

| Type | Examples | |---|---| | <img src="docs/assets/runtime-warning.svg" width="18" height="18" alt="Runtime warning" /> Runtime warning | <ul><li>Duplicate <code>.required</code></li><li>Multiple format rules (<code>.email</code> / <code>.password</code> / <code>.numeric</code>)</li></ul> | | <img src="docs/assets/assertion-failure.svg" width="18" height="18" alt="Assertion failure" /> Assertion failure | <ul><li><code>minLength</code> &gt; <code>maxLength</code></li></ul> |

API Reference

Validation Modifier

Single State
func validation(
    text: Binding<String>,
    state: ValidationState,
    rules: [ValidationRule], // or rule: ValidationRule
    timing: ValidationTiming = .realtime(),
    behavior: ValidationInitialBehavior = .afterFirstEdit
) -> some View
With Container
func validation(
    text: Binding<String>,
    container: ValidationContainer,
    id: String,
    rules: [ValidationRule], // or rule: ValidationRule
    timing: ValidationTiming = .realtime(),
    behavior: ValidationInitialBehavior = .afterFirstEdit
) -> some View

ValidationState

| Member | Type | Notes | |---|---|---| | isValid | Bool | Current validity. | | hasBeenValidated | Bool | Whether validation has run at least once. | | hasBeenEdited | Bool | Whether the user has edited the field. | | hasBeenBlurred | Bool | Whether the field has lost focus at least once. | | invalidReason | InvalidReason? | Failure reason (nil when valid / not validated). | | reset() | () | Reset state flags and validity. |

ValidationContainer

| Member | Type | Notes | |---|---|---| | isFullyValid | Bool | False when empty; otherwise true only when all fields are valid. | | hasBeenFullyValidated | Bool | False when empty; otherwise true only when all states have been validated. | | state(for:) | ValidationState | Get or create a state for an id. | | existingState(for:) | ValidationState? | Get an existing state (nil if not created yet). | | resetAll() | () | Reset all states. | | validateAll() | () | Validate all registered states. |

ValidationRule

| Case | Notes | |---|---| | .required(minLength:maxLength:) | Optional length constraints. | | .email(minLength:maxLength:) | Optional length constraints. | | .password(minLength:maxLength:) | Must contain at least one letter and one digit. Optional length constraints. | | .numeric(minLength:maxLength:) | Optional length constraints. | | .regex(String) | Pattern string is used for validation. | | .custom(messageKey:validator:) | messageKey is optional and can help map UI messages when multiple customs exist. |

InvalidReason

| Case | Meaning | |---|---| | .empty | The input is empty (or whitespace-only). | | .emailFormat | The input is not a valid email format. | | .passwordFormat | The input is not a valid password format. | | .numericFormat | The input contains non-numeric characters. | | .regexFormat(id:regex:) | The input does not match a regex. (id is currently nil from the library.) | | .tooShort(min:actual:) | The input is shorter than the minimum length. | | .tooLong(max:actual:) | The input is longer than the maximum length. | | .custom(id:) | Custom validation failed (id can be used for UI mapping). |

ValidationTiming

| Case | Meaning | |---|---| | .realtime(debounce:) | Validate while typing (optional debounce). | | .onFocusLoss | Validate when focus leaves the field. |

ValidationInitialBehavior

| Case | Meaning | |---|---| | .afterFirstEdit | Start validating after the first edit. | | .onStart | Validate immediately on appearance. | | .afterFirstBlur | Start validating after the first blur. |

Requirements

  • iOS 17.0+ / macOS 14.0+ / watchOS 10.0+ / tvOS 17.0+
  • Swift 6.0+
  • Xcode 16.0+

License

ValidationLibrary is available under the MIT license. See the LICENSE file for more info.

Author

Ryosuke Suzaki (@Ryosuke1025)

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

Package Metadata

Repository: ryosuke1025/validationlibrary

Default branch: main

README: README.md