SwiftUI バリデーション

SwiftUIフレームワークを使用したリアルタイムフォームバリデーションとユーザーインターフェース統合

SwiftUI バリデーションとは

SwiftUIは、Appleの宣言的UIフレームワークで、フォームバリデーションを直感的かつ効率的に実装できます。@Stateや@Bindingなどの状態管理機能と組み合わせることで、リアルタイムバリデーション、視覚的フィードバック、ユーザビリティの高いフォームを作成できます。

主な特徴

  • 宣言的UI: SwiftUIの宣言的構文による直感的なバリデーション実装
  • リアルタイム処理: 入力と同時にバリデーション結果を表示
  • 状態管理: @State、@Binding、@ObservableObjectによる効率的な状態管理
  • 視覚的フィードバック: 色、アニメーション、アイコンによる分かりやすいエラー表示
  • アクセシビリティ: VoiceOverなどのアクセシビリティ機能との統合
  • Combineサポート: 非同期バリデーションとPublisherパターンの活用

基本的なSwiftUIバリデーション

基本的なテキストフィールドバリデーション

import SwiftUI

struct BasicValidationView: View {
    @State private var email = ""
    @State private var isEmailValid = true
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("メールアドレス")
                .font(.headline)
            
            TextField("メールアドレスを入力", text: $email)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(isEmailValid ? Color.gray : Color.red, lineWidth: 1)
                )
                .onChange(of: email) { newValue in
                    isEmailValid = isValidEmail(newValue)
                }
            
            if !isEmailValid && !email.isEmpty {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundColor(.red)
                    Text("有効なメールアドレスを入力してください")
                        .foregroundColor(.red)
                        .font(.caption)
                }
            }
        }
        .padding()
    }
    
    private func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}

パスワード強度バリデーション

struct PasswordValidationView: View {
    @State private var password = ""
    @State private var confirmPassword = ""
    
    var body: some View {
        VStack(alignment: .leading, spacing: 15) {
            // パスワード入力
            VStack(alignment: .leading, spacing: 5) {
                Text("パスワード")
                    .font(.headline)
                
                SecureField("パスワードを入力", text: $password)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                // パスワード強度インジケーター
                PasswordStrengthIndicator(password: password)
            }
            
            // パスワード確認
            VStack(alignment: .leading, spacing: 5) {
                Text("パスワード確認")
                    .font(.headline)
                
                SecureField("パスワードを再入力", text: $confirmPassword)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .overlay(
                        RoundedRectangle(cornerRadius: 8)
                            .stroke(passwordsMatch ? Color.green : Color.red, lineWidth: 1)
                    )
                
                if !confirmPassword.isEmpty && !passwordsMatch {
                    Label("パスワードが一致しません", systemImage: "xmark.circle.fill")
                        .foregroundColor(.red)
                        .font(.caption)
                }
            }
        }
        .padding()
    }
    
    private var passwordsMatch: Bool {
        !password.isEmpty && !confirmPassword.isEmpty && password == confirmPassword
    }
}

struct PasswordStrengthIndicator: View {
    let password: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            // 強度バー
            HStack(spacing: 4) {
                ForEach(0..<4, id: \.self) { index in
                    Rectangle()
                        .frame(height: 4)
                        .foregroundColor(strengthColor(for: index))
                        .cornerRadius(2)
                }
            }
            
            // 強度レベル表示
            Text(strengthText)
                .font(.caption)
                .foregroundColor(strengthColor(for: 0))
            
            // 要件チェックリスト
            VStack(alignment: .leading, spacing: 4) {
                ChecklistItem(
                    text: "8文字以上",
                    isValid: password.count >= 8
                )
                ChecklistItem(
                    text: "大文字を含む",
                    isValid: password.rangeOfCharacter(from: .uppercaseLetters) != nil
                )
                ChecklistItem(
                    text: "小文字を含む",
                    isValid: password.rangeOfCharacter(from: .lowercaseLetters) != nil
                )
                ChecklistItem(
                    text: "数字を含む",
                    isValid: password.rangeOfCharacter(from: .decimalDigits) != nil
                )
                ChecklistItem(
                    text: "特殊文字を含む",
                    isValid: password.rangeOfCharacter(from: CharacterSet(charactersIn: "!@#$%^&*()_+-=[]{}|;:,.<>?")) != nil
                )
            }
        }
    }
    
    private var strengthLevel: Int {
        var level = 0
        if password.count >= 8 { level += 1 }
        if password.rangeOfCharacter(from: .uppercaseLetters) != nil { level += 1 }
        if password.rangeOfCharacter(from: .lowercaseLetters) != nil { level += 1 }
        if password.rangeOfCharacter(from: .decimalDigits) != nil { level += 1 }
        if password.rangeOfCharacter(from: CharacterSet(charactersIn: "!@#$%^&*()_+-=[]{}|;:,.<>?")) != nil { level += 1 }
        return min(level, 4)
    }
    
    private var strengthText: String {
        switch strengthLevel {
        case 0...1: return "弱い"
        case 2: return "普通"
        case 3: return "強い"
        case 4: return "とても強い"
        default: return ""
        }
    }
    
    private func strengthColor(for index: Int) -> Color {
        if index < strengthLevel {
            switch strengthLevel {
            case 1: return .red
            case 2: return .orange
            case 3: return .yellow
            case 4: return .green
            default: return .gray
            }
        }
        return .gray.opacity(0.3)
    }
}

struct ChecklistItem: View {
    let text: String
    let isValid: Bool
    
    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: isValid ? "checkmark.circle.fill" : "circle")
                .foregroundColor(isValid ? .green : .gray)
            Text(text)
                .font(.caption)
                .foregroundColor(isValid ? .primary : .secondary)
        }
    }
}

フォームバリデーション

包括的なユーザー登録フォーム

struct UserRegistrationForm: View {
    @StateObject private var viewModel = RegistrationViewModel()
    @State private var showingAlert = false
    
    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("個人情報")) {
                    // 名前
                    VStack(alignment: .leading, spacing: 4) {
                        TextField("名前", text: $viewModel.fullName)
                        if let error = viewModel.fullNameError {
                            ErrorText(error)
                        }
                    }
                    
                    // メールアドレス
                    VStack(alignment: .leading, spacing: 4) {
                        TextField("メールアドレス", text: $viewModel.email)
                            .keyboardType(.emailAddress)
                            .autocapitalization(.none)
                        if let error = viewModel.emailError {
                            ErrorText(error)
                        }
                    }
                    
                    // 電話番号
                    VStack(alignment: .leading, spacing: 4) {
                        TextField("電話番号", text: $viewModel.phoneNumber)
                            .keyboardType(.phonePad)
                        if let error = viewModel.phoneError {
                            ErrorText(error)
                        }
                    }
                }
                
                Section(header: Text("生年月日")) {
                    DatePicker(
                        "生年月日",
                        selection: $viewModel.birthDate,
                        in: ...Date(),
                        displayedComponents: .date
                    )
                    .datePickerStyle(WheelDatePickerStyle())
                    
                    if let error = viewModel.ageError {
                        ErrorText(error)
                    }
                }
                
                Section(header: Text("パスワード")) {
                    SecureField("パスワード", text: $viewModel.password)
                    SecureField("パスワード確認", text: $viewModel.confirmPassword)
                    
                    if let error = viewModel.passwordError {
                        ErrorText(error)
                    }
                }
                
                Section(header: Text("利用規約")) {
                    Toggle("利用規約に同意する", isOn: $viewModel.termsAccepted)
                    
                    if let error = viewModel.termsError {
                        ErrorText(error)
                    }
                }
            }
            .navigationTitle("ユーザー登録")
            .navigationBarItems(
                leading: Button("キャンセル") {
                    // キャンセル処理
                },
                trailing: Button("登録") {
                    viewModel.submitRegistration()
                    showingAlert = true
                }
                .disabled(!viewModel.isFormValid)
            )
            .alert("登録完了", isPresented: $showingAlert) {
                Button("OK") { }
            } message: {
                Text("ユーザー登録が完了しました")
            }
        }
    }
}

struct ErrorText: View {
    let text: String
    
    init(_ text: String) {
        self.text = text
    }
    
    var body: some View {
        HStack {
            Image(systemName: "exclamationmark.triangle.fill")
                .foregroundColor(.red)
            Text(text)
                .font(.caption)
                .foregroundColor(.red)
        }
    }
}

ViewModel with Combine

import Foundation
import Combine

class RegistrationViewModel: ObservableObject {
    @Published var fullName = ""
    @Published var email = ""
    @Published var phoneNumber = ""
    @Published var birthDate = Date()
    @Published var password = ""
    @Published var confirmPassword = ""
    @Published var termsAccepted = false
    
    @Published var fullNameError: String?
    @Published var emailError: String?
    @Published var phoneError: String?
    @Published var ageError: String?
    @Published var passwordError: String?
    @Published var termsError: String?
    
    @Published var isFormValid = false
    
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        setupValidation()
    }
    
    private func setupValidation() {
        // 名前バリデーション
        $fullName
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { name in
                if name.isEmpty {
                    return "名前を入力してください"
                } else if name.count < 2 {
                    return "名前は2文字以上で入力してください"
                } else if name.count > 50 {
                    return "名前は50文字以内で入力してください"
                }
                return nil
            }
            .assign(to: \.fullNameError, on: self)
            .store(in: &cancellables)
        
        // メールバリデーション
        $email
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { email in
                if email.isEmpty {
                    return "メールアドレスを入力してください"
                } else if !self.isValidEmail(email) {
                    return "有効なメールアドレスを入力してください"
                }
                return nil
            }
            .assign(to: \.emailError, on: self)
            .store(in: &cancellables)
        
        // 電話番号バリデーション
        $phoneNumber
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { phone in
                if phone.isEmpty {
                    return "電話番号を入力してください"
                } else if !self.isValidPhoneNumber(phone) {
                    return "有効な電話番号を入力してください"
                }
                return nil
            }
            .assign(to: \.phoneError, on: self)
            .store(in: &cancellables)
        
        // 年齢バリデーション
        $birthDate
            .map { birthDate in
                let age = Calendar.current.dateComponents([.year], from: birthDate, to: Date()).year ?? 0
                if age < 13 {
                    return "13歳以上である必要があります"
                } else if age > 120 {
                    return "有効な生年月日を入力してください"
                }
                return nil
            }
            .assign(to: \.ageError, on: self)
            .store(in: &cancellables)
        
        // パスワードバリデーション
        Publishers.CombineLatest($password, $confirmPassword)
            .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
            .map { password, confirmPassword in
                if password.isEmpty {
                    return "パスワードを入力してください"
                } else if password.count < 8 {
                    return "パスワードは8文字以上で入力してください"
                } else if !confirmPassword.isEmpty && password != confirmPassword {
                    return "パスワードが一致しません"
                }
                return nil
            }
            .assign(to: \.passwordError, on: self)
            .store(in: &cancellables)
        
        // 利用規約バリデーション
        $termsAccepted
            .map { accepted in
                accepted ? nil : "利用規約に同意してください"
            }
            .assign(to: \.termsError, on: self)
            .store(in: &cancellables)
        
        // フォーム全体の有効性
        Publishers.CombineLatest4(
            $fullNameError,
            $emailError,
            $phoneError,
            $ageError
        )
        .combineLatest(
            Publishers.CombineLatest3(
                $passwordError,
                $termsError,
                $termsAccepted
            )
        )
        .map { firstGroup, secondGroup in
            let (fullNameError, emailError, phoneError, ageError) = firstGroup
            let (passwordError, termsError, termsAccepted) = secondGroup
            
            return fullNameError == nil &&
                   emailError == nil &&
                   phoneError == nil &&
                   ageError == nil &&
                   passwordError == nil &&
                   termsError == nil &&
                   termsAccepted
        }
        .assign(to: \.isFormValid, on: self)
        .store(in: &cancellables)
    }
    
    func submitRegistration() {
        // 登録処理の実装
        print("フォーム送信: \(fullName), \(email)")
    }
    
    private func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
    
    private func isValidPhoneNumber(_ phone: String) -> Bool {
        let phoneRegex = "^[0-9+\\-\\s()]+$"
        let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex)
        return phonePredicate.evaluate(with: phone) && phone.count >= 10
    }
}

カスタムバリデーションコンポーネント

再利用可能なValidatedTextField

struct ValidatedTextField: View {
    let title: String
    let placeholder: String
    @Binding var text: String
    let validator: (String) -> String?
    let keyboardType: UIKeyboardType
    
    @State private var errorMessage: String?
    @State private var isEditing = false
    
    init(
        title: String,
        placeholder: String,
        text: Binding<String>,
        keyboardType: UIKeyboardType = .default,
        validator: @escaping (String) -> String?
    ) {
        self.title = title
        self.placeholder = placeholder
        self._text = text
        self.keyboardType = keyboardType
        self.validator = validator
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(title)
                .font(.headline)
                .foregroundColor(.primary)
            
            TextField(placeholder, text: $text, onEditingChanged: { editing in
                isEditing = editing
                if !editing {
                    validateInput()
                }
            })
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .keyboardType(keyboardType)
            .overlay(
                RoundedRectangle(cornerRadius: 8)
                    .stroke(borderColor, lineWidth: 2)
            )
            .onChange(of: text) { _ in
                if !isEditing {
                    validateInput()
                }
            }
            
            if let error = errorMessage {
                HStack {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .foregroundColor(.red)
                    Text(error)
                        .font(.caption)
                        .foregroundColor(.red)
                }
                .transition(.opacity)
            }
        }
        .animation(.easeInOut(duration: 0.2), value: errorMessage)
    }
    
    private var borderColor: Color {
        if let _ = errorMessage {
            return .red
        } else if !text.isEmpty && errorMessage == nil {
            return .green
        } else {
            return .gray
        }
    }
    
    private func validateInput() {
        withAnimation {
            errorMessage = validator(text)
        }
    }
}

// 使用例
struct ValidatedTextFieldExample: View {
    @State private var email = ""
    @State private var phone = ""
    
    var body: some View {
        VStack(spacing: 20) {
            ValidatedTextField(
                title: "メールアドレス",
                placeholder: "[email protected]",
                text: $email,
                keyboardType: .emailAddress
            ) { email in
                if email.isEmpty {
                    return "メールアドレスを入力してください"
                } else if !isValidEmail(email) {
                    return "有効なメールアドレスを入力してください"
                }
                return nil
            }
            
            ValidatedTextField(
                title: "電話番号",
                placeholder: "090-1234-5678",
                text: $phone,
                keyboardType: .phonePad
            ) { phone in
                if phone.isEmpty {
                    return "電話番号を入力してください"
                } else if phone.count < 10 {
                    return "有効な電話番号を入力してください"
                }
                return nil
            }
        }
        .padding()
    }
    
    private func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}

高度なバリデーション機能

非同期バリデーション

class AsyncValidationViewModel: ObservableObject {
    @Published var username = ""
    @Published var validationState: ValidationState = .idle
    
    private var cancellables = Set<AnyCancellable>()
    private let apiClient = APIClient()
    
    enum ValidationState {
        case idle
        case validating
        case valid
        case invalid(String)
    }
    
    init() {
        $username
            .debounce(for: .milliseconds(800), scheduler: RunLoop.main)
            .removeDuplicates()
            .sink { [weak self] username in
                self?.validateUsername(username)
            }
            .store(in: &cancellables)
    }
    
    private func validateUsername(_ username: String) {
        guard !username.isEmpty else {
            validationState = .idle
            return
        }
        
        guard username.count >= 3 else {
            validationState = .invalid("ユーザー名は3文字以上で入力してください")
            return
        }
        
        validationState = .validating
        
        // 非同期でサーバーにユーザー名の可用性をチェック
        apiClient.checkUsernameAvailability(username)
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { [weak self] completion in
                    if case .failure = completion {
                        self?.validationState = .invalid("サーバーエラーが発生しました")
                    }
                },
                receiveValue: { [weak self] isAvailable in
                    if isAvailable {
                        self?.validationState = .valid
                    } else {
                        self?.validationState = .invalid("このユーザー名は既に使用されています")
                    }
                }
            )
            .store(in: &cancellables)
    }
}

struct AsyncValidationView: View {
    @StateObject private var viewModel = AsyncValidationViewModel()
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("ユーザー名")
                .font(.headline)
            
            HStack {
                TextField("ユーザー名を入力", text: $viewModel.username)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                Group {
                    switch viewModel.validationState {
                    case .idle:
                        EmptyView()
                    case .validating:
                        ProgressView()
                            .scaleEffect(0.7)
                    case .valid:
                        Image(systemName: "checkmark.circle.fill")
                            .foregroundColor(.green)
                    case .invalid:
                        Image(systemName: "xmark.circle.fill")
                            .foregroundColor(.red)
                    }
                }
                .frame(width: 20, height: 20)
            }
            
            if case .invalid(let message) = viewModel.validationState {
                Text(message)
                    .font(.caption)
                    .foregroundColor(.red)
            }
        }
        .padding()
    }
}

// APIクライアントのサンプル実装
class APIClient {
    func checkUsernameAvailability(_ username: String) -> AnyPublisher<Bool, Error> {
        // 実際のAPIコールをシミュレート
        Future<Bool, Error> { promise in
            DispatchQueue.global().asyncAfter(deadline: .now() + 1) {
                let unavailableUsernames = ["admin", "user", "test", "demo"]
                let isAvailable = !unavailableUsernames.contains(username.lowercased())
                promise(.success(isAvailable))
            }
        }
        .eraseToAnyPublisher()
    }
}

バリデーションルールシステム

protocol ValidationRule {
    func validate(_ value: String) -> ValidationResult
}

struct ValidationResult {
    let isValid: Bool
    let errorMessage: String?
    
    static let valid = ValidationResult(isValid: true, errorMessage: nil)
    
    static func invalid(_ message: String) -> ValidationResult {
        return ValidationResult(isValid: false, errorMessage: message)
    }
}

struct RequiredRule: ValidationRule {
    let message: String
    
    init(message: String = "必須項目です") {
        self.message = message
    }
    
    func validate(_ value: String) -> ValidationResult {
        return value.trimmingCharacters(in: .whitespaces).isEmpty ? 
            .invalid(message) : .valid
    }
}

struct MinLengthRule: ValidationRule {
    let minLength: Int
    let message: String
    
    init(minLength: Int, message: String? = nil) {
        self.minLength = minLength
        self.message = message ?? "\(minLength)文字以上で入力してください"
    }
    
    func validate(_ value: String) -> ValidationResult {
        return value.count >= minLength ? .valid : .invalid(message)
    }
}

struct EmailRule: ValidationRule {
    let message: String
    
    init(message: String = "有効なメールアドレスを入力してください") {
        self.message = message
    }
    
    func validate(_ value: String) -> ValidationResult {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: value) ? .valid : .invalid(message)
    }
}

class FieldValidator: ObservableObject {
    @Published var errorMessage: String?
    @Published var isValid = true
    
    private let rules: [ValidationRule]
    
    init(rules: [ValidationRule]) {
        self.rules = rules
    }
    
    func validate(_ value: String) {
        for rule in rules {
            let result = rule.validate(value)
            if !result.isValid {
                errorMessage = result.errorMessage
                isValid = false
                return
            }
        }
        errorMessage = nil
        isValid = true
    }
}

// 使用例
struct RuleBasedValidationView: View {
    @State private var email = ""
    @StateObject private var emailValidator = FieldValidator(rules: [
        RequiredRule(message: "メールアドレスを入力してください"),
        EmailRule()
    ])
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("メールアドレス")
                .font(.headline)
            
            TextField("メールアドレス", text: $email)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .overlay(
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(emailValidator.isValid ? Color.gray : Color.red, lineWidth: 1)
                )
                .onChange(of: email) { newValue in
                    emailValidator.validate(newValue)
                }
            
            if let error = emailValidator.errorMessage {
                Text(error)
                    .font(.caption)
                    .foregroundColor(.red)
            }
        }
        .padding()
    }
}

アクセシビリティとユーザビリティ

アクセシブルなバリデーション

struct AccessibleValidationView: View {
    @State private var email = ""
    @State private var emailError: String?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("メールアドレス(必須)")
                .font(.headline)
                .accessibilityAddTraits(.isHeader)
            
            TextField("メールアドレスを入力", text: $email)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .keyboardType(.emailAddress)
                .autocapitalization(.none)
                .accessibility(label: Text("メールアドレス入力フィールド"))
                .accessibility(hint: Text("有効なメールアドレスを入力してください"))
                .accessibility(value: Text(email.isEmpty ? "未入力" : email))
                .accessibilityAction(.magicTap) {
                    validateEmail()
                }
                .onChange(of: email) { _ in
                    validateEmail()
                }
            
            if let error = emailError {
                Text(error)
                    .font(.caption)
                    .foregroundColor(.red)
                    .accessibility(label: Text("エラー"))
                    .accessibility(value: Text(error))
                    .accessibilityAddTraits(.isStaticText)
            }
        }
        .padding()
        .accessibilityElement(children: .contain)
    }
    
    private func validateEmail() {
        if email.isEmpty {
            emailError = "メールアドレスを入力してください"
        } else if !isValidEmail(email) {
            emailError = "有効なメールアドレスを入力してください"
        } else {
            emailError = nil
        }
    }
    
    private func isValidEmail(_ email: String) -> Bool {
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}

ベストプラクティス

1. 段階的なバリデーション

struct GradualValidationView: View {
    @State private var password = ""
    @State private var showDetailedFeedback = false
    
    var body: some View {
        VStack(alignment: .leading, spacing: 15) {
            Text("パスワード")
                .font(.headline)
            
            SecureField("パスワード", text: $password)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .onTapGesture {
                    showDetailedFeedback = true
                }
                .onChange(of: password) { _ in
                    if !password.isEmpty {
                        showDetailedFeedback = true
                    }
                }
            
            if showDetailedFeedback {
                PasswordRequirementsView(password: password)
                    .transition(.opacity.combined(with: .move(edge: .top)))
            }
        }
        .padding()
        .animation(.easeInOut, value: showDetailedFeedback)
    }
}

struct PasswordRequirementsView: View {
    let password: String
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("パスワード要件")
                .font(.subheadline)
                .fontWeight(.semibold)
            
            RequirementRow(
                text: "8文字以上",
                isMet: password.count >= 8
            )
            RequirementRow(
                text: "大文字を含む",
                isMet: password.rangeOfCharacter(from: .uppercaseLetters) != nil
            )
            RequirementRow(
                text: "小文字を含む",
                isMet: password.rangeOfCharacter(from: .lowercaseLetters) != nil
            )
            RequirementRow(
                text: "数字を含む",
                isMet: password.rangeOfCharacter(from: .decimalDigits) != nil
            )
        }
        .padding()
        .background(Color.gray.opacity(0.1))
        .cornerRadius(8)
    }
}

struct RequirementRow: View {
    let text: String
    let isMet: Bool
    
    var body: some View {
        HStack {
            Image(systemName: isMet ? "checkmark.circle.fill" : "circle")
                .foregroundColor(isMet ? .green : .gray)
            Text(text)
                .font(.caption)
                .strikethrough(isMet)
                .foregroundColor(isMet ? .secondary : .primary)
        }
    }
}

2. パフォーマンス最適化

struct OptimizedValidationView: View {
    @State private var email = ""
    @State private var debouncedEmail = ""
    @State private var emailError: String?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            TextField("メールアドレス", text: $email)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .onReceive(
                    Just(email)
                        .debounce(for: .milliseconds(500), scheduler: RunLoop.main)
                ) { debouncedValue in
                    debouncedEmail = debouncedValue
                    validateEmail(debouncedValue)
                }
            
            if let error = emailError {
                Text(error)
                    .font(.caption)
                    .foregroundColor(.red)
            }
        }
        .padding()
    }
    
    private func validateEmail(_ email: String) {
        // 重い処理をバックグラウンドで実行
        DispatchQueue.global(qos: .userInitiated).async {
            let isValid = performHeavyValidation(email)
            
            DispatchQueue.main.async {
                if !isValid && !email.isEmpty {
                    emailError = "有効なメールアドレスを入力してください"
                } else {
                    emailError = nil
                }
            }
        }
    }
    
    private func performHeavyValidation(_ email: String) -> Bool {
        // 重いバリデーション処理のシミュレーション
        Thread.sleep(forTimeInterval: 0.1)
        let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
        return emailPredicate.evaluate(with: email)
    }
}

代表的なサードパーティライブラリ

SwiftUIでバリデーションを行う際に活用できるサードパーティライブラリ:

ValidatedPropertyKit

プロパティラッパーを使用したバリデーション:

import ValidatedPropertyKit

struct User {
    @Validated(.nonEmpty(message: "名前を入力してください"))
    var name: String = ""
    
    @Validated(.isEmail(message: "有効なメールアドレスを入力してください"))
    var email: String = ""
}

SwiftValidator

宣言的なバリデーションルール:

import SwiftValidator

class ViewController {
    let validator = Validator()
    
    func setupValidation() {
        validator.registerField(emailField, rules: [RequiredRule(), EmailRule()])
        validator.registerField(passwordField, rules: [RequiredRule(), MinLengthRule(length: 8)])
    }
}

FormValidator

SwiftUI専用のフォームバリデーション:

import FormValidator

struct FormView: View {
    @FormValidator([
        .required("メールアドレスを入力してください"),
        .email("有効なメールアドレスを入力してください")
    ]) var email: String = ""
    
    var body: some View {
        Form {
            TextField("メールアドレス", text: $email)
            if !$email.isValid {
                Text($email.errorMessage ?? "")
                    .foregroundColor(.red)
            }
        }
    }
}

まとめ

SwiftUIバリデーションは、宣言的UI、リアルタイム処理、視覚的フィードバック、アクセシビリティ統合により、優れたユーザーエクスペリエンスを提供します。@State、Combine、カスタムコンポーネントを適切に組み合わせることで、効率的で保守性の高いバリデーションシステムを構築できます。パフォーマンス最適化とアクセシビリティ対応を忘れずに実装することが重要です。