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、カスタムコンポーネントを適切に組み合わせることで、効率的で保守性の高いバリデーションシステムを構築できます。パフォーマンス最適化とアクセシビリティ対応を忘れずに実装することが重要です。