From f6c25805f1d2dc8c1d16754c5285f3456eb5ebe5 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Mon, 10 Mar 2025 20:15:33 +0300 Subject: [PATCH 01/14] fix tests and update ViewInspector to 0.10.1 --- Package.resolved | 4 +- Package.swift | 2 +- Tests/FormViewTests/FormViewTests.swift | 56 ++++++++++++++----- .../Validation/TextValidationRuleTests.swift | 30 +++++----- .../Validation/ValidatorTests.swift | 23 ++++---- 5 files changed, 69 insertions(+), 46 deletions(-) diff --git a/Package.resolved b/Package.resolved index da2cfba..5868e83 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nalexn/ViewInspector", "state" : { - "branch" : "master", - "revision" : "4effbd9143ab797eb60d2f32d4265c844c980946" + "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", + "version" : "0.10.1" } } ], diff --git a/Package.swift b/Package.swift index f92de2c..d4ae6c4 100644 --- a/Package.swift +++ b/Package.swift @@ -14,7 +14,7 @@ let package = Package( targets: ["FormView"]) ], dependencies: [ - .package(url: "https://github.com/nalexn/ViewInspector", branch: "master") + .package(url: "https://github.com/nalexn/ViewInspector", exact: .init(0, 10, 1)) ], targets: [ .target( diff --git a/Tests/FormViewTests/FormViewTests.swift b/Tests/FormViewTests/FormViewTests.swift index 726a10b..f95e4fc 100644 --- a/Tests/FormViewTests/FormViewTests.swift +++ b/Tests/FormViewTests/FormViewTests.swift @@ -12,18 +12,28 @@ import Combine @testable import FormView final class FormViewTests: XCTestCase { + @MainActor func testPreventInvalidInput() throws { var text1 = "" var text2 = "" let sut = InspectionWrapperView( - wrapped: FormView { + wrapped: FormView { _ in ScrollView { FormField( value: Binding(get: { text1 }, set: { text1 = $0 }), - validationRules: [.digitsOnly] + rules: [TextValidationRule.digitsOnly(message: "")], + content: { _ in + TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 })) + } ) .id(1) - FormField(value: Binding(get: { text2 }, set: { text2 = $0 })) + FormField( + value: Binding(get: { text2 }, set: { text2 = $0 }), + rules: [TextValidationRule.digitsOnly(message: "")], + content: { _ in + TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 })) + } + ) .id(2) } } @@ -31,34 +41,45 @@ final class FormViewTests: XCTestCase { let exp = sut.inspection.inspect { view in let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).textField() + let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self) + + text1 = "123" try scrollView.callOnSubmit() try textField1.callOnChange(newValue: "New Focus Field", index: 1) try textField1.callOnChange(newValue: "123") XCTAssertEqual(try textField1.input(), "123") - text1 = "123" try textField1.callOnChange(newValue: "123_A") - XCTAssertEqual(try textField1.input(), text1) + XCTAssertNotEqual(try textField1.input(), "123_A") } ViewHosting.host(view: sut) wait(for: [exp], timeout: 0.1) } + @MainActor func testSubmitTextField() throws { var text1 = "" var text2 = "" let sut = InspectionWrapperView( - wrapped: FormView { + wrapped: FormView { _ in ScrollView { FormField( value: Binding(get: { text1 }, set: { text1 = $0 }), - validationRules: [.digitsOnly] + rules: [TextValidationRule.digitsOnly(message: "")], + content: { _ in + TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 })) + } ) .id(1) - FormField(value: Binding(get: { text2 }, set: { text2 = $0 })) + FormField( + value: Binding(get: { text2 }, set: { text2 = $0 }), + rules: [TextValidationRule.digitsOnly(message: "")], + content: { _ in + TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 })) + } + ) .id(2) } } @@ -66,7 +87,7 @@ final class FormViewTests: XCTestCase { let exp = sut.inspection.inspect { view in let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).textField() + let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self) // let formField2 = try view.find(viewWithId: 2).view(FormField.self).actualView() try scrollView.callOnSubmit() @@ -76,17 +97,22 @@ final class FormViewTests: XCTestCase { XCTAssertTrue(true) } - ViewHosting.host(view: sut.environment(\.focusField, "field1")) + ViewHosting.host(view: sut.environment(\.focusedFieldId, "1")) wait(for: [exp], timeout: 0.1) } func testFocusNextField() throws { - var fieldStates = [FieldState(id: "1", isFocused: false), FieldState(id: "2", isFocused: false)] + let fieldStates = [ + FieldState(id: "1", isFocused: true, onValidate: { true }), + FieldState(id: "2", isFocused: false, onValidate: { false }) + ] - var nextFocusField = fieldStates.focusNextField(currentFocusField: "") - XCTAssertEqual(nextFocusField, "1") + var nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "1") + nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces) + XCTAssertEqual(nextFocusField, "2") - nextFocusField = fieldStates.focusNextField(currentFocusField: "1") + nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "2") + nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces) XCTAssertEqual(nextFocusField, "2") } } diff --git a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift b/Tests/FormViewTests/Validation/TextValidationRuleTests.swift index b6dc3f5..a4e2569 100644 --- a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift +++ b/Tests/FormViewTests/Validation/TextValidationRuleTests.swift @@ -10,64 +10,64 @@ import XCTest final class TextValidationRuleTests: XCTestCase { func testIgnoreEmpty() throws { - try test(textRule: .digitsOnly, trueString: "", falseString: "1234 A") + try test(textRule: .digitsOnly(message: ""), trueString: "", falseString: "1234 A") } func testNotEmpty() throws { - try test(textRule: .notEmpty, trueString: "Not empty", falseString: "") + try test(textRule: .notEmpty(message: ""), trueString: "Not empty", falseString: "") } func testMinLength() throws { - try test(textRule: .minLength(4), trueString: "1234", falseString: "123") + try test(textRule: .minLength(count: 4, message: ""), trueString: "1234", falseString: "123") } func testMaxLength() throws { - try test(textRule: .maxLength(4), trueString: "1234", falseString: "123456") + try test(textRule: .maxLength(count: 4, message: ""), trueString: "1234", falseString: "123456") } func testAtLeastOneDigit() throws { - try test(textRule: .atLeastOneDigit, trueString: "Digit 5", falseString: "No Digits") + try test(textRule: .atLeastOneDigit(message: ""), trueString: "Digit 5", falseString: "No Digits") } func testAtLeastOneLetter() throws { - try test(textRule: .atLeastOneLetter, trueString: "1234 A", falseString: "1234") + try test(textRule: .atLeastOneLetter(message: ""), trueString: "1234 A", falseString: "1234") } func testDigitsOnly() throws { - try test(textRule: .digitsOnly, trueString: "1234", falseString: "1234 A") + try test(textRule: .digitsOnly(message: ""), trueString: "1234", falseString: "1234 A") } func testLettersOnly() throws { - try test(textRule: .lettersOnly, trueString: "Letters", falseString: "Digit 5") + try test(textRule: .lettersOnly(message: ""), trueString: "Letters", falseString: "Digit 5") } func testAtLeastOneLowercaseLetter() throws { - try test(textRule: .atLeastOneLowercaseLetter, trueString: "LOWEr", falseString: "UPPER") + try test(textRule: .atLeastOneLowercaseLetter(message: ""), trueString: "LOWEr", falseString: "UPPER") } func testAtLeastOneUppercaseLetter() throws { - try test(textRule: .atLeastOneUppercaseLetter, trueString: "Upper", falseString: "lower") + try test(textRule: .atLeastOneUppercaseLetter(message: ""), trueString: "Upper", falseString: "lower") } func testAtLeastOneSpecialCharacter() throws { - try test(textRule: .atLeastOneSpecialCharacter, trueString: "Special %", falseString: "No special") + try test(textRule: .atLeastOneSpecialCharacter(message: ""), trueString: "Special %", falseString: "No special") } func testNoSpecialCharacters() throws { - try test(textRule: .noSpecialCharacters, trueString: "No special", falseString: "Special %") + try test(textRule: .noSpecialCharacters(message: ""), trueString: "No special", falseString: "Special %") } func testEmail() throws { - try test(textRule: .email, trueString: "alievmaxx@gmail.com", falseString: "alievmaxx@.com") + try test(textRule: .email(message: ""), trueString: "alievmaxx@gmail.com", falseString: "alievmaxx@.com") } func testNotRecurringPincode() throws { - try test(textRule: .notRecurringPincode, trueString: "1234", falseString: "5555") + try test(textRule: .notRecurringPincode(message: ""), trueString: "1234", falseString: "5555") } func testRegex() throws { let dateRegex = "(\\d{2}).(\\d{2}).(\\d{4})" - try test(textRule: .regex(dateRegex), trueString: "21.12.2000", falseString: "21..2000") + try test(textRule: .regex(value: dateRegex, message: ""), trueString: "21.12.2000", falseString: "21..2000") } private func test(textRule: TextValidationRule, trueString: String, falseString: String) throws { diff --git a/Tests/FormViewTests/Validation/ValidatorTests.swift b/Tests/FormViewTests/Validation/ValidatorTests.swift index a2f5d59..949a68b 100644 --- a/Tests/FormViewTests/Validation/ValidatorTests.swift +++ b/Tests/FormViewTests/Validation/ValidatorTests.swift @@ -11,25 +11,22 @@ import XCTest final class ValidatorTests: XCTestCase { func testValidator() throws { - var text: String = "" var failedValidationRules: [TextValidationRule] = [] - let validator = FieldValidator( - value: Binding(get: { text }, set: { text = $0 }), - validationRules: [.digitsOnly], - inputRules: [.maxLength(4)], - failedValidationRules: Binding(get: { failedValidationRules }, set: { failedValidationRules = $0 }) + let validator = FieldValidator( + rules: [.digitsOnly(message: ""), .maxLength(count: 4, message: "")] ) - validator.value = "1" - validator.validate() + failedValidationRules = validator.validate(value: "1") XCTAssertTrue(failedValidationRules.isEmpty) + failedValidationRules.removeAll() - validator.value = "12_A" - XCTAssertEqual(failedValidationRules, [.digitsOnly]) + failedValidationRules = validator.validate(value: "12_A") + XCTAssertTrue(failedValidationRules.isEmpty == false) + failedValidationRules.removeAll() - validator.value = "12345" - let failedInputRules = validator.validateInput() - XCTAssertEqual(failedInputRules, [.maxLength(4)]) + failedValidationRules = validator.validate(value: "12345") + XCTAssertTrue(failedValidationRules.isEmpty == false) + failedValidationRules.removeAll() } } From eaa6f27a1352bd0b5e310af81b205c8b1bfd2b4c Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 11 Mar 2025 14:44:32 +0300 Subject: [PATCH 02/14] add example of validation from ViewModel --- .../InputFields/TextInputField.swift | 38 ++++++++- .../UI/ContentScreen/ContentView.swift | 10 ++- .../UI/ContentScreen/ContentViewModel.swift | 5 ++ README.md | 83 +++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index 992299f..52aac1f 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -8,17 +8,29 @@ import SwiftUI import FormView +enum OuterValidationRule { + case duplicateName + + var message: String { + switch self { + case .duplicateName: + return "This name already exists" + } + } +} + struct TextInputField: View { let title: LocalizedStringKey let text: Binding let failedRules: [TextValidationRule] + @Binding var outerRules: [OuterValidationRule] var body: some View { VStack(alignment: .leading) { TextField(title, text: text) .background(Color.white) - if failedRules.isEmpty == false { - Text(failedRules[0].message) + if let errorMessage = getErrorMessage() { + Text(errorMessage) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) } @@ -26,4 +38,26 @@ struct TextInputField: View { } .frame(height: 50) } + + private func getErrorMessage() -> String? { + if let message = failedRules.first?.message { + return message + } else if let message = outerRules.first?.message { + return message + } else { + return nil + } + } + + init( + title: LocalizedStringKey, + text: Binding, + failedRules: [TextValidationRule], + outerRules: Binding<[OuterValidationRule]> = .constant([]) + ) { + self.title = title + self.text = text + self.failedRules = failedRules + self._outerRules = outerRules + } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index c4513fa..62e9754 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -24,7 +24,12 @@ struct ContentView: View { .myRule ] ) { failedRules in - TextInputField(title: "Name", text: $viewModel.name, failedRules: failedRules) + TextInputField( + title: "Name", + text: $viewModel.name, + failedRules: failedRules, + outerRules: $viewModel.nameOuterRules + ) } FormField( value: $viewModel.age, @@ -57,6 +62,9 @@ struct ContentView: View { Button("Validate") { print("Form is valid: \(proxy.validate())") } + Button("Apply name outer rules") { + viewModel.applyNameOuterRules() + } } .padding(.horizontal, 16) .padding(.top, 40) diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index a05e288..2f5d15f 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -12,6 +12,7 @@ class ContentViewModel: ObservableObject { @Published var age: String = "" @Published var pass: String = "" @Published var confirmPass: String = "" + @Published var nameOuterRules: [OuterValidationRule] = [] private let coordinator: ContentCoordinator @@ -20,6 +21,10 @@ class ContentViewModel: ObservableObject { print("init ContentViewModel") } + func applyNameOuterRules() { + nameOuterRules = [.duplicateName] + } + deinit { print("deinit ContentViewModel") } diff --git a/README.md b/README.md index 85ccdee..49f53ab 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,89 @@ A banch of predefind rules for text validation is available via `TextValidationR * equalTo - value equal to another value. Useful for password confirmation. * etc... +### Outer Validation Rules +If you need to display validation errors from external services (e.g., a backend), follow these steps: +1. Create an `OuterValidationRule` enum: +```swift +enum OuterValidationRule { + case duplicateName + + var message: String { + switch self { + case .duplicateName: + return "This name already exists" + } + } +} +``` + +2. Update the text field component: +```swift +struct TextInputField: View { + let title: LocalizedStringKey + let text: Binding + let failedRules: [TextValidationRule] + @Binding var outerRules: [OuterValidationRule] + + var body: some View { + VStack(alignment: .leading) { + TextField(title, text: text) + .background(Color.white) + if let errorMessage = getErrorMessage() { + Text(errorMessage) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.red) + } + Spacer() + } + .frame(height: 50) + } + + private func getErrorMessage() -> String? { + if let message = failedRules.first?.message { + return message + } else if let message = outerRules.first?.message { + return message + } else { + return nil + } + } + + init( + title: LocalizedStringKey, + text: Binding, + failedRules: [TextValidationRule], + outerRules: Binding<[OuterValidationRule]> = .constant([]) + ) { + self.title = title + self.text = text + self.failedRules = failedRules + self._outerRules = outerRules + } +} +``` +3. Update the text field initialization in your view: +```swift +TextInputField( + title: "Name", + text: $viewModel.name, + failedRules: failedRules, + outerRules: $viewModel.nameOuterRules +) +``` + +4. In your ViewModel, declare a `@Published` property of type `OuterValidationRule` and update its rules as needed: +```swift +class ContentViewModel: ObservableObject { + @Published var nameOuterRules: [OuterValidationRule] = [] + + func applyNameOuterRules() { + nameOuterRules = [.duplicateName] + } +} +``` + + ### Implementation Details FormView doesn't use any external dependencies. From 96219cc6674f40471d25c7f5dc866070dfd99e4a Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 11 Mar 2025 17:21:30 +0300 Subject: [PATCH 03/14] add error hiding on change of text --- ExampleApp/ExampleApp/InputFields/TextInputField.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index 52aac1f..dc730d5 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -21,13 +21,13 @@ enum OuterValidationRule { struct TextInputField: View { let title: LocalizedStringKey - let text: Binding + @Binding var text: String let failedRules: [TextValidationRule] @Binding var outerRules: [OuterValidationRule] var body: some View { VStack(alignment: .leading) { - TextField(title, text: text) + TextField(title, text: $text) .background(Color.white) if let errorMessage = getErrorMessage() { Text(errorMessage) @@ -37,6 +37,9 @@ struct TextInputField: View { Spacer() } .frame(height: 50) + .onChange(of: text) { _ in + outerRules = [] + } } private func getErrorMessage() -> String? { @@ -56,7 +59,7 @@ struct TextInputField: View { outerRules: Binding<[OuterValidationRule]> = .constant([]) ) { self.title = title - self.text = text + self._text = text self.failedRules = failedRules self._outerRules = outerRules } From 58f9727ea38d0654b3cec0af9602568e75bdd941 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 11 Mar 2025 17:22:51 +0300 Subject: [PATCH 04/14] update readme --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 49f53ab..cf76625 100644 --- a/README.md +++ b/README.md @@ -144,13 +144,13 @@ enum OuterValidationRule { ```swift struct TextInputField: View { let title: LocalizedStringKey - let text: Binding + @Binding var text: String let failedRules: [TextValidationRule] @Binding var outerRules: [OuterValidationRule] var body: some View { VStack(alignment: .leading) { - TextField(title, text: text) + TextField(title, text: $text) .background(Color.white) if let errorMessage = getErrorMessage() { Text(errorMessage) @@ -160,6 +160,9 @@ struct TextInputField: View { Spacer() } .frame(height: 50) + .onChange(of: text) { _ in + outerRules = [] + } } private func getErrorMessage() -> String? { @@ -179,7 +182,7 @@ struct TextInputField: View { outerRules: Binding<[OuterValidationRule]> = .constant([]) ) { self.title = title - self.text = text + self._text = text self.failedRules = failedRules self._outerRules = outerRules } From d7793ee428e4b2732cd385109ad62587c79b9604 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Thu, 13 Mar 2025 19:54:10 +0300 Subject: [PATCH 05/14] refactor --- .../xcshareddata/swiftpm/Package.resolved | 4 +- ExampleApp/ExampleApp/AppDelegate.swift | 2 - .../InputFields/SecureInputField.swift | 6 +- .../InputFields/TextInputField.swift | 23 +--- ExampleApp/ExampleApp/MyRule.swift | 6 +- .../UI/ContentScreen/ContentView.swift | 42 +++---- .../UI/ContentScreen/ContentViewModel.swift | 48 ++++++- Package.swift | 5 +- Sources/FormView/FormField.swift | 50 ++++---- Sources/FormView/FormView.swift | 15 ++- Sources/FormView/Preference/FieldState.swift | 2 +- .../Validation/Rules/TextValidationRule.swift | 117 ----------------- .../Validation/Rules/ValidationRule.swift | 118 +++++++++++++++++- .../Validators/FieldValidator.swift | 18 ++- .../Validation/Validators/FormValidator.swift | 6 +- Tests/FormViewTests/FormViewTests.swift | 118 ------------------ .../InspectionHelper/Inspection.swift | 23 ---- .../InspectionWrapperView.swift | 24 ---- .../Validation/TextValidationRuleTests.swift | 79 ------------ .../Validation/ValidatorTests.swift | 32 ----- 20 files changed, 240 insertions(+), 498 deletions(-) delete mode 100644 Sources/FormView/Validation/Rules/TextValidationRule.swift delete mode 100644 Tests/FormViewTests/FormViewTests.swift delete mode 100644 Tests/FormViewTests/InspectionHelper/Inspection.swift delete mode 100644 Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift delete mode 100644 Tests/FormViewTests/Validation/TextValidationRuleTests.swift delete mode 100644 Tests/FormViewTests/Validation/ValidatorTests.swift diff --git a/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index da2cfba..5868e83 100644 --- a/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nalexn/ViewInspector", "state" : { - "branch" : "master", - "revision" : "4effbd9143ab797eb60d2f32d4265c844c980946" + "revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2", + "version" : "0.10.1" } } ], diff --git a/ExampleApp/ExampleApp/AppDelegate.swift b/ExampleApp/ExampleApp/AppDelegate.swift index 115016c..e6ff82c 100644 --- a/ExampleApp/ExampleApp/AppDelegate.swift +++ b/ExampleApp/ExampleApp/AppDelegate.swift @@ -13,8 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return true } } - diff --git a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift index d821bcd..9ceddc3 100644 --- a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift @@ -11,7 +11,7 @@ import FormView struct SecureInputField: View { let title: LocalizedStringKey let text: Binding - let failedRules: [TextValidationRule] + let failedRules: [ValidationRule] @FocusState private var isFocused: Bool @State private var isSecure = true @@ -24,8 +24,8 @@ struct SecureInputField: View { eyeImage } .background(Color.white) - if failedRules.isEmpty == false { - Text(failedRules[0].message) + if failedRules.isEmpty == false, let message = failedRules[0].message { + Text(message) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) } diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index dc730d5..08295c4 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -8,22 +8,10 @@ import SwiftUI import FormView -enum OuterValidationRule { - case duplicateName - - var message: String { - switch self { - case .duplicateName: - return "This name already exists" - } - } -} - struct TextInputField: View { let title: LocalizedStringKey @Binding var text: String - let failedRules: [TextValidationRule] - @Binding var outerRules: [OuterValidationRule] + let failedRules: [ValidationRule] var body: some View { VStack(alignment: .leading) { @@ -37,16 +25,11 @@ struct TextInputField: View { Spacer() } .frame(height: 50) - .onChange(of: text) { _ in - outerRules = [] - } } private func getErrorMessage() -> String? { if let message = failedRules.first?.message { return message - } else if let message = outerRules.first?.message { - return message } else { return nil } @@ -55,12 +38,10 @@ struct TextInputField: View { init( title: LocalizedStringKey, text: Binding, - failedRules: [TextValidationRule], - outerRules: Binding<[OuterValidationRule]> = .constant([]) + failedRules: [ValidationRule] ) { self.title = title self._text = text self.failedRules = failedRules - self._outerRules = outerRules } } diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 90577bc..12f745b 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -6,10 +6,10 @@ import FormView -extension TextValidationRule { +extension ValidationRule { static var myRule: Self { - TextValidationRule(message: "Shold contain T") { - $0.contains("T") + Self.custom { + return $0.contains("T") ? nil : "Shold contain T" } } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index 62e9754..f11d8da 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -13,58 +13,50 @@ struct ContentView: View { var body: some View { FormView( - validate: .never, + validate: .onFieldValueChanged, hideError: .onValueChanged ) { proxy in FormField( value: $viewModel.name, - rules: [ - TextValidationRule.noSpecialCharacters(message: "No spec chars"), - .notEmpty(message: "Name empty"), - .myRule - ] + rules: viewModel.nameValidationRules ) { failedRules in TextInputField( title: "Name", text: $viewModel.name, - failedRules: failedRules, - outerRules: $viewModel.nameOuterRules + failedRules: failedRules ) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.age, - rules: [ - TextValidationRule.digitsOnly(message: "Digits only"), - .maxLength(count: 2, message: "Max length 2") - ] + rules: viewModel.ageValidationRules ) { failedRules in TextInputField(title: "Age", text: $viewModel.age, failedRules: failedRules) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.pass, - rules: [ - TextValidationRule.atLeastOneDigit(message: "One digit"), - .atLeastOneLetter(message: "One letter"), - .notEmpty(message: "Pass not empty") - ] + rules: viewModel.passValidationRules ) { failedRules in SecureInputField(title: "Password", text: $viewModel.pass, failedRules: failedRules) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.confirmPass, - rules: [ - TextValidationRule.equalTo(value: viewModel.pass, message: "Not equal to pass"), - .notEmpty(message: "Confirm pass not empty") - ] + rules: viewModel.confirmPassValidationRules ) { failedRules in SecureInputField(title: "Confirm Password", text: $viewModel.confirmPass, failedRules: failedRules) } - Button("Validate") { - print("Form is valid: \(proxy.validate())") + .disabled(viewModel.isLoading) + if viewModel.isLoading { + ProgressView() } - Button("Apply name outer rules") { - viewModel.applyNameOuterRules() + Button("Validate") { + Task { + print("Form is valid: \(await proxy.validate())") + } } + .disabled(viewModel.isLoading) } .padding(.horizontal, 16) .padding(.top, 40) diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index 2f5d15f..be68ae0 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -6,23 +6,65 @@ // import SwiftUI +import FormView class ContentViewModel: ObservableObject { @Published var name: String = "" @Published var age: String = "" @Published var pass: String = "" @Published var confirmPass: String = "" - @Published var nameOuterRules: [OuterValidationRule] = [] + @Published var isLoading = false + + var nameValidationRules: [ValidationRule] = [] + var ageValidationRules: [ValidationRule] = [] + var passValidationRules: [ValidationRule] = [] + var confirmPassValidationRules: [ValidationRule] = [] private let coordinator: ContentCoordinator init(coordinator: ContentCoordinator) { self.coordinator = coordinator print("init ContentViewModel") + + nameValidationRules = [ + ValidationRule.notEmpty(message: "Name empty"), + ValidationRule.noSpecialCharacters(message: "No spec chars"), + ValidationRule.myRule, + ValidationRule.external { [weak self] in await self?.availabilityCheckAsync($0) } + ] + + ageValidationRules = [ + ValidationRule.digitsOnly(message: "Digits only"), + ValidationRule.maxLength(count: 2, message: "Max length 2") + ] + + passValidationRules = [ + ValidationRule.atLeastOneDigit(message: "One digit"), + ValidationRule.atLeastOneLetter(message: "One letter"), + ValidationRule.notEmpty(message: "Pass not empty") + ] + + confirmPassValidationRules = [ + ValidationRule.notEmpty(message: "Confirm pass not empty"), + ValidationRule.custom { [weak self] in + return $0 == self?.pass ? nil : "Not equal to pass" + } + ] } - func applyNameOuterRules() { - nameOuterRules = [.duplicateName] + @MainActor + private func availabilityCheckAsync(_ value: String) async -> String? { + print(#function) + + isLoading = true + + try? await Task.sleep(nanoseconds: 2_000_000_000) + + let isAvailable = Bool.random() + + isLoading = false + + return isAvailable ? nil : "Not available" } deinit { diff --git a/Package.swift b/Package.swift index d4ae6c4..2251412 100644 --- a/Package.swift +++ b/Package.swift @@ -19,9 +19,6 @@ let package = Package( targets: [ .target( name: "FormView", - dependencies: []), - .testTarget( - name: "FormViewTests", - dependencies: ["FormView", "ViewInspector"]) + dependencies: []) ] ) diff --git a/Sources/FormView/FormField.swift b/Sources/FormView/FormField.swift index a1e8d91..0fa9486 100644 --- a/Sources/FormView/FormField.swift +++ b/Sources/FormView/FormField.swift @@ -7,11 +7,11 @@ import SwiftUI -public struct FormField: View where Value == Rule.Value { - @Binding private var value: Value - @ViewBuilder private let content: ([Rule]) -> Content +public struct FormField: View { + @Binding private var value: String + @ViewBuilder private let content: ([ValidationRule]) -> Content - @State private var failedValidationRules: [Rule] = [] + @State private var failedValidationRules: [ValidationRule] = [] // Fields Focus @FocusState private var isFocused: Bool @@ -19,14 +19,14 @@ public struct FormField: V @Environment(\.focusedFieldId) var currentFocusedFieldId // ValidateInput - private let validator: FieldValidator + private let validator: FieldValidator @Environment(\.errorHideBehaviour) var errorHideBehaviour @Environment(\.validationBehaviour) var validationBehaviour public init( - value: Binding, - rules: [Rule] = [], - @ViewBuilder content: @escaping ([Rule]) -> Content + value: Binding, + rules: [ValidationRule] = [], + @ViewBuilder content: @escaping ([ValidationRule]) -> Content ) { self._value = value self.content = content @@ -46,7 +46,7 @@ public struct FormField: V value: [ // Замыкание для каждого филда вызывается FormValidator'ом из FormView для валидации по требованию FieldState(id: id, isFocused: isFocused) { - let failedRules = validator.validate(value: value) + let failedRules = await validator.validate(value: value, isNeedToCheckExternal: true) failedValidationRules = failedRules return failedRules.isEmpty @@ -57,23 +57,27 @@ public struct FormField: V // Fields Validation .onChange(of: value) { newValue in - if errorHideBehaviour == .onValueChanged { - failedValidationRules = .empty - } - - if validationBehaviour == .onFieldValueChanged { - failedValidationRules = validator.validate(value: newValue) + Task { @MainActor in + if errorHideBehaviour == .onValueChanged { + failedValidationRules = .empty + } + + if validationBehaviour == .onFieldValueChanged { + failedValidationRules = await validator.validate(value: newValue, isNeedToCheckExternal: false) + } } } .onChange(of: isFocused) { newValue in - if errorHideBehaviour == .onFocusLost && newValue == false { - failedValidationRules = .empty - } else if errorHideBehaviour == .onFocus && newValue == true { - failedValidationRules = .empty - } - - if validationBehaviour == .onFieldFocusLost && newValue == false { - failedValidationRules = validator.validate(value: value) + Task { @MainActor in + if errorHideBehaviour == .onFocusLost && newValue == false { + failedValidationRules = .empty + } else if errorHideBehaviour == .onFocus && newValue == true { + failedValidationRules = .empty + } + + if validationBehaviour == .onFieldFocusLost && newValue == false { + failedValidationRules = await validator.validate(value: value, isNeedToCheckExternal: false) + } } } } diff --git a/Sources/FormView/FormView.swift b/Sources/FormView/FormView.swift index 035c708..7599a59 100644 --- a/Sources/FormView/FormView.swift +++ b/Sources/FormView/FormView.swift @@ -31,19 +31,24 @@ private class FormStateHandler: ObservableObject { currentFocusedFieldId = focusedField?.id ?? .empty // Замыкание onValidateRun вызывается методом validate() FormValidator'a. - formValidator.onValidateRun = { [weak self] focusOnFirstFailedField in + formValidator.onValidateRun = { @MainActor [weak self] focusOnFirstFailedField in guard let self else { return false } - let resutls = newStates.map { $0.onValidate() } - + var results: [Bool] = [] + + for newState in newStates { + let result = await newState.onValidate() + results.append(result) + } + // Фокус на первом зафейленом филде. - if let index = resutls.firstIndex(of: false), focusOnFirstFailedField { + if let index = results.firstIndex(of: false), focusOnFirstFailedField { currentFocusedFieldId = fieldStates[index].id } - return resutls.allSatisfy { $0 } + return results.allSatisfy { $0 } } } diff --git a/Sources/FormView/Preference/FieldState.swift b/Sources/FormView/Preference/FieldState.swift index 41b03c1..c71b427 100644 --- a/Sources/FormView/Preference/FieldState.swift +++ b/Sources/FormView/Preference/FieldState.swift @@ -10,7 +10,7 @@ import SwiftUI struct FieldState { var id: String var isFocused: Bool - var onValidate: () -> Bool + var onValidate: () async -> Bool } extension FieldState: Equatable { diff --git a/Sources/FormView/Validation/Rules/TextValidationRule.swift b/Sources/FormView/Validation/Rules/TextValidationRule.swift deleted file mode 100644 index 08b1a1d..0000000 --- a/Sources/FormView/Validation/Rules/TextValidationRule.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// TextValidationRule.swift -// -// -// Created by Maxim Aliev on 27.01.2023. -// - -import Foundation - -public struct TextValidationRule: ValidationRule { - public let message: String - - private let checkClosure: (String) -> Bool - - public init(message: String, checkClosure: @escaping (String) -> Bool) { - self.checkClosure = checkClosure - self.message = message - } - - public func check(value: String) -> Bool { - return checkClosure(value) - } -} - -extension TextValidationRule { - public static func notEmpty(message: String) -> Self { - TextValidationRule(message: message) { - $0.isEmpty == false - } - } - - public static func atLeastOneLowercaseLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil - } - } - - public static func atLeastOneUppercaseLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil - } - } - - public static func atLeastOneDigit(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil - } - } - - public static func atLeastOneLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.letters) != nil - } - } - - public static func digitsOnly(message: String) -> Self { - TextValidationRule(message: message) { - CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) - } - } - - public static func lettersOnly(message: String) -> Self { - TextValidationRule(message: message) { - CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) - } - } - - public static func atLeastOneSpecialCharacter(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil - } - } - - public static func noSpecialCharacters(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil - } - } - - public static func email(message: String) -> Self { - TextValidationRule(message: message) { - NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") - .evaluate(with: $0) - } - } - - public static func notRecurringPincode(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil - } - } - - public static func minLength(count: Int, message: String) -> Self { - TextValidationRule(message: message) { - $0.count >= count - } - } - - public static func maxLength(count: Int, message: String) -> Self { - TextValidationRule(message: message) { - $0.count <= count - } - } - - public static func regex(value: String, message: String) -> Self { - TextValidationRule(message: message) { - NSPredicate(format: "SELF MATCHES %@", value) - .evaluate(with: $0) - } - } - - public static func equalTo(value: String, message: String) -> Self { - TextValidationRule(message: message) { - $0 == value - } - } -} diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index 2019334..5f67b7f 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -2,13 +2,123 @@ // ValidationRule.swift // // -// Created by Maxim Aliev on 29.01.2023. +// Created by Maxim Aliev on 27.01.2023. // import Foundation +import SwiftUI -public protocol ValidationRule { - associatedtype Value +public class ValidationRule { + public var message: String? + public let isExternal: Bool - func check(value: Value) -> Bool + private let checkClosure: (String) async -> String? + + internal required init(isExternal: Bool, checkClosure: @escaping (String) async -> String?) { + self.checkClosure = checkClosure + self.isExternal = isExternal + } + + public func check(value: String) async -> Bool { + let message = await checkClosure(value) + self.message = message + + return message == nil + } +} + +extension ValidationRule { + public static func custom(checkClosure: @escaping (String) async -> String?) -> Self { + return Self(isExternal: false, checkClosure: checkClosure) + } + + public static func external(checkClosure: @escaping (String) async -> String?) -> Self { + return Self(isExternal: true, checkClosure: checkClosure) + } + + public static func notEmpty(message: String) -> Self { + return Self(isExternal: false) { + return $0.isEmpty == false ? nil : message + } + } + + public static func atLeastOneLowercaseLetter(message: String) -> Self { + return Self(isExternal: false) { + return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? nil : message + } + } + + public static func atLeastOneUppercaseLetter(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? nil : message + } + } + + public static func atLeastOneDigit(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? nil : message + } + } + + public static func atLeastOneLetter(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? nil : message + } + } + + public static func digitsOnly(message: String) -> Self { + return Self(isExternal: false) { + CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + } + } + + public static func lettersOnly(message: String) -> Self { + return Self(isExternal: false) { + CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + } + } + + public static func atLeastOneSpecialCharacter(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil ? nil : message + } + } + + public static func noSpecialCharacters(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil ? nil : message + } + } + + public static func email(message: String) -> Self { + return Self(isExternal: false) { + NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") + .evaluate(with: $0) ? nil : message + } + } + + public static func notRecurringPincode(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? nil : message + } + } + + public static func minLength(count: Int, message: String) -> Self { + return Self(isExternal: false) { + $0.count >= count ? nil : message + } + } + + public static func maxLength(count: Int, message: String) -> Self { + return Self(isExternal: false) { + $0.count <= count ? nil : message + } + } + + public static func regex(value: String, message: String) -> Self { + return Self(isExternal: false) { + NSPredicate(format: "SELF MATCHES %@", value) + .evaluate(with: $0) ? nil : message + } + } } diff --git a/Sources/FormView/Validation/Validators/FieldValidator.swift b/Sources/FormView/Validation/Validators/FieldValidator.swift index 36f1752..ec7d609 100644 --- a/Sources/FormView/Validation/Validators/FieldValidator.swift +++ b/Sources/FormView/Validation/Validators/FieldValidator.swift @@ -7,16 +7,22 @@ import SwiftUI -struct FieldValidator { - private let rules: [Rule] +struct FieldValidator { + private let rules: [ValidationRule] - init(rules: [Rule]) { + init(rules: [ValidationRule]) { self.rules = rules } - func validate(value: Rule.Value) -> [Rule] { - return rules.filter { - $0.check(value: value) == false + func validate(value: String, isNeedToCheckExternal: Bool) async -> [ValidationRule] { + var failedRules: [ValidationRule] = [] + + for rule in rules where rule.isExternal == false || isNeedToCheckExternal { + if await rule.check(value: value) == false { + failedRules.append(rule) + } } + + return failedRules } } diff --git a/Sources/FormView/Validation/Validators/FormValidator.swift b/Sources/FormView/Validation/Validators/FormValidator.swift index b493a70..25f775e 100644 --- a/Sources/FormView/Validation/Validators/FormValidator.swift +++ b/Sources/FormView/Validation/Validators/FormValidator.swift @@ -8,18 +8,18 @@ import Foundation public struct FormValidator { - var onValidateRun: ((Bool) -> Bool)? + var onValidateRun: ((Bool) async -> Bool)? public init() { onValidateRun = nil } - public func validate(focusOnFirstFailedField: Bool = true) -> Bool { + public func validate(focusOnFirstFailedField: Bool = true) async -> Bool { guard let onValidateRun else { assertionFailure("onValidateRun closure not found") return false } - return onValidateRun(focusOnFirstFailedField) + return await onValidateRun(focusOnFirstFailedField) } } diff --git a/Tests/FormViewTests/FormViewTests.swift b/Tests/FormViewTests/FormViewTests.swift deleted file mode 100644 index f95e4fc..0000000 --- a/Tests/FormViewTests/FormViewTests.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// FormViewTests.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import SwiftUI -import XCTest -import ViewInspector -import Combine -@testable import FormView - -final class FormViewTests: XCTestCase { - @MainActor - func testPreventInvalidInput() throws { - var text1 = "" - var text2 = "" - let sut = InspectionWrapperView( - wrapped: FormView { _ in - ScrollView { - FormField( - value: Binding(get: { text1 }, set: { text1 = $0 }), - rules: [TextValidationRule.digitsOnly(message: "")], - content: { _ in - TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 })) - } - ) - .id(1) - FormField( - value: Binding(get: { text2 }, set: { text2 = $0 }), - rules: [TextValidationRule.digitsOnly(message: "")], - content: { _ in - TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 })) - } - ) - .id(2) - } - } - ) - - let exp = sut.inspection.inspect { view in - let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self) - - text1 = "123" - - try scrollView.callOnSubmit() - try textField1.callOnChange(newValue: "New Focus Field", index: 1) - try textField1.callOnChange(newValue: "123") - XCTAssertEqual(try textField1.input(), "123") - - try textField1.callOnChange(newValue: "123_A") - XCTAssertNotEqual(try textField1.input(), "123_A") - } - - ViewHosting.host(view: sut) - wait(for: [exp], timeout: 0.1) - } - - @MainActor - func testSubmitTextField() throws { - var text1 = "" - var text2 = "" - let sut = InspectionWrapperView( - wrapped: FormView { _ in - ScrollView { - FormField( - value: Binding(get: { text1 }, set: { text1 = $0 }), - rules: [TextValidationRule.digitsOnly(message: "")], - content: { _ in - TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 })) - } - ) - .id(1) - FormField( - value: Binding(get: { text2 }, set: { text2 = $0 }), - rules: [TextValidationRule.digitsOnly(message: "")], - content: { _ in - TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 })) - } - ) - .id(2) - } - } - ) - - let exp = sut.inspection.inspect { view in - let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self) -// let formField2 = try view.find(viewWithId: 2).view(FormField.self).actualView() - - try scrollView.callOnSubmit() - try textField1.callOnChange(newValue: "field2", index: 1) - -// XCTAssertEqual(formField2.focusField, "field2") - XCTAssertTrue(true) - } - - ViewHosting.host(view: sut.environment(\.focusedFieldId, "1")) - wait(for: [exp], timeout: 0.1) - } - - func testFocusNextField() throws { - let fieldStates = [ - FieldState(id: "1", isFocused: true, onValidate: { true }), - FieldState(id: "2", isFocused: false, onValidate: { false }) - ] - - var nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "1") - nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces) - XCTAssertEqual(nextFocusField, "2") - - nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "2") - nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces) - XCTAssertEqual(nextFocusField, "2") - } -} diff --git a/Tests/FormViewTests/InspectionHelper/Inspection.swift b/Tests/FormViewTests/InspectionHelper/Inspection.swift deleted file mode 100644 index 77b5b12..0000000 --- a/Tests/FormViewTests/InspectionHelper/Inspection.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Inspection.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import Combine -import ViewInspector -@testable import FormView - -final class Inspection { - let notice = PassthroughSubject() - var callbacks: [UInt: (V) -> Void] = [:] - - func visit(_ view: V, _ line: UInt) { - if let callback = callbacks.removeValue(forKey: line) { - callback(view) - } - } -} - -extension Inspection: InspectionEmissary { } diff --git a/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift b/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift deleted file mode 100644 index 6fe6878..0000000 --- a/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// InspectionWrapperView.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import SwiftUI - -struct InspectionWrapperView: View { - let inspection = Inspection() - var wrapped: V - - init(wrapped: V) { - self.wrapped = wrapped - } - - var body: some View { - wrapped - .onReceive(inspection.notice) { - inspection.visit(self, $0) - } - } -} diff --git a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift b/Tests/FormViewTests/Validation/TextValidationRuleTests.swift deleted file mode 100644 index a4e2569..0000000 --- a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// TextValidationRuleTests.swift -// -// -// Created by Maxim Aliev on 07.02.2023. -// - -import XCTest -@testable import FormView - -final class TextValidationRuleTests: XCTestCase { - func testIgnoreEmpty() throws { - try test(textRule: .digitsOnly(message: ""), trueString: "", falseString: "1234 A") - } - - func testNotEmpty() throws { - try test(textRule: .notEmpty(message: ""), trueString: "Not empty", falseString: "") - } - - func testMinLength() throws { - try test(textRule: .minLength(count: 4, message: ""), trueString: "1234", falseString: "123") - } - - func testMaxLength() throws { - try test(textRule: .maxLength(count: 4, message: ""), trueString: "1234", falseString: "123456") - } - - func testAtLeastOneDigit() throws { - try test(textRule: .atLeastOneDigit(message: ""), trueString: "Digit 5", falseString: "No Digits") - } - - func testAtLeastOneLetter() throws { - try test(textRule: .atLeastOneLetter(message: ""), trueString: "1234 A", falseString: "1234") - } - - func testDigitsOnly() throws { - try test(textRule: .digitsOnly(message: ""), trueString: "1234", falseString: "1234 A") - } - - func testLettersOnly() throws { - try test(textRule: .lettersOnly(message: ""), trueString: "Letters", falseString: "Digit 5") - } - - func testAtLeastOneLowercaseLetter() throws { - try test(textRule: .atLeastOneLowercaseLetter(message: ""), trueString: "LOWEr", falseString: "UPPER") - } - - func testAtLeastOneUppercaseLetter() throws { - try test(textRule: .atLeastOneUppercaseLetter(message: ""), trueString: "Upper", falseString: "lower") - } - - func testAtLeastOneSpecialCharacter() throws { - try test(textRule: .atLeastOneSpecialCharacter(message: ""), trueString: "Special %", falseString: "No special") - } - - func testNoSpecialCharacters() throws { - try test(textRule: .noSpecialCharacters(message: ""), trueString: "No special", falseString: "Special %") - } - - func testEmail() throws { - try test(textRule: .email(message: ""), trueString: "alievmaxx@gmail.com", falseString: "alievmaxx@.com") - } - - func testNotRecurringPincode() throws { - try test(textRule: .notRecurringPincode(message: ""), trueString: "1234", falseString: "5555") - } - - func testRegex() throws { - let dateRegex = "(\\d{2}).(\\d{2}).(\\d{4})" - try test(textRule: .regex(value: dateRegex, message: ""), trueString: "21.12.2000", falseString: "21..2000") - } - - private func test(textRule: TextValidationRule, trueString: String, falseString: String) throws { - let isPassed = textRule.check(value: trueString) - let isFailed = textRule.check(value: falseString) == false - - XCTAssertTrue(isPassed && isFailed) - } -} diff --git a/Tests/FormViewTests/Validation/ValidatorTests.swift b/Tests/FormViewTests/Validation/ValidatorTests.swift deleted file mode 100644 index 949a68b..0000000 --- a/Tests/FormViewTests/Validation/ValidatorTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// ValidatorTests.swift -// -// -// Created by Maxim Aliev on 19.02.2023. -// - -import SwiftUI -import XCTest -@testable import FormView - -final class ValidatorTests: XCTestCase { - func testValidator() throws { - var failedValidationRules: [TextValidationRule] = [] - - let validator = FieldValidator( - rules: [.digitsOnly(message: ""), .maxLength(count: 4, message: "")] - ) - - failedValidationRules = validator.validate(value: "1") - XCTAssertTrue(failedValidationRules.isEmpty) - failedValidationRules.removeAll() - - failedValidationRules = validator.validate(value: "12_A") - XCTAssertTrue(failedValidationRules.isEmpty == false) - failedValidationRules.removeAll() - - failedValidationRules = validator.validate(value: "12345") - XCTAssertTrue(failedValidationRules.isEmpty == false) - failedValidationRules.removeAll() - } -} From 24e3cc380f430899b5b9c2cb620c96472873a586 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Thu, 13 Mar 2025 19:56:57 +0300 Subject: [PATCH 06/14] refactor --- .../ExampleApp/InputFields/TextInputField.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index 08295c4..0ff8ef4 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -27,14 +27,6 @@ struct TextInputField: View { .frame(height: 50) } - private func getErrorMessage() -> String? { - if let message = failedRules.first?.message { - return message - } else { - return nil - } - } - init( title: LocalizedStringKey, text: Binding, @@ -44,4 +36,12 @@ struct TextInputField: View { self._text = text self.failedRules = failedRules } + + private func getErrorMessage() -> String? { + if let message = failedRules.first?.message { + return message + } else { + return nil + } + } } From 3cfca6b0caff850e8d214a067c5d979949bdbaff Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Fri, 14 Mar 2025 13:15:31 +0300 Subject: [PATCH 07/14] fixes after review --- ExampleApp/ExampleApp/InputFields/TextInputField.swift | 10 +--------- ExampleApp/ExampleApp/MyRule.swift | 2 +- .../ExampleApp/UI/ContentScreen/ContentView.swift | 6 +----- .../ExampleApp/UI/ContentScreen/ContentViewModel.swift | 4 ++++ Sources/FormView/FormView.swift | 2 +- 5 files changed, 8 insertions(+), 16 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index 0ff8ef4..d39d1a4 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -17,7 +17,7 @@ struct TextInputField: View { VStack(alignment: .leading) { TextField(title, text: $text) .background(Color.white) - if let errorMessage = getErrorMessage() { + if let errorMessage = failedRules.first?.message { Text(errorMessage) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) @@ -36,12 +36,4 @@ struct TextInputField: View { self._text = text self.failedRules = failedRules } - - private func getErrorMessage() -> String? { - if let message = failedRules.first?.message { - return message - } else { - return nil - } - } } diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 12f745b..1372ce1 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -9,7 +9,7 @@ import FormView extension ValidationRule { static var myRule: Self { Self.custom { - return $0.contains("T") ? nil : "Shold contain T" + return $0.contains("T") ? nil : "Should contain T" } } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index f11d8da..9e0427c 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -20,11 +20,7 @@ struct ContentView: View { value: $viewModel.name, rules: viewModel.nameValidationRules ) { failedRules in - TextInputField( - title: "Name", - text: $viewModel.name, - failedRules: failedRules - ) + TextInputField(title: "Name", text: $viewModel.name, failedRules: failedRules) } .disabled(viewModel.isLoading) FormField( diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index be68ae0..fcce647 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -26,6 +26,10 @@ class ContentViewModel: ObservableObject { self.coordinator = coordinator print("init ContentViewModel") + setupValidationRules() + } + + private func setupValidationRules() { nameValidationRules = [ ValidationRule.notEmpty(message: "Name empty"), ValidationRule.noSpecialCharacters(message: "No spec chars"), diff --git a/Sources/FormView/FormView.swift b/Sources/FormView/FormView.swift index 7599a59..a33fa83 100644 --- a/Sources/FormView/FormView.swift +++ b/Sources/FormView/FormView.swift @@ -42,7 +42,7 @@ private class FormStateHandler: ObservableObject { let result = await newState.onValidate() results.append(result) } - + // Фокус на первом зафейленом филде. if let index = results.firstIndex(of: false), focusOnFirstFailedField { currentFocusedFieldId = fieldStates[index].id From ded5053e878ff38ee69bfd10d0374b8d32e13f86 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 17:37:18 +0300 Subject: [PATCH 08/14] add validation with many conditions --- ExampleApp/ExampleApp/MyRule.swift | 2 +- .../UI/ContentScreen/ContentView.swift | 2 +- .../UI/ContentScreen/ContentViewModel.swift | 32 +++++--- .../Environment/EnvironmentKeys.swift | 4 +- Sources/FormView/FormField.swift | 30 ++++++-- Sources/FormView/FormView.swift | 7 +- .../Validation/Rules/ValidationRule.swift | 73 +++++++++++-------- .../Validators/FieldValidator.swift | 9 ++- 8 files changed, 103 insertions(+), 56 deletions(-) diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 1372ce1..26d6411 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -8,7 +8,7 @@ import FormView extension ValidationRule { static var myRule: Self { - Self.custom { + Self.custom(conditions: [.manual, .onFieldValueChanged, .onFieldFocus]) { return $0.contains("T") ? nil : "Should contain T" } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index 9e0427c..cf1a406 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { var body: some View { FormView( - validate: .onFieldValueChanged, + validate: [.manual, .onFieldFocusLost, .onFieldValueChanged, .onFieldFocus], hideError: .onValueChanged ) { proxy in FormField( diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index fcce647..b75539e 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -31,26 +31,38 @@ class ContentViewModel: ObservableObject { private func setupValidationRules() { nameValidationRules = [ - ValidationRule.notEmpty(message: "Name empty"), - ValidationRule.noSpecialCharacters(message: "No spec chars"), + ValidationRule.notEmpty(conditions: [.manual, .onFieldValueChanged, .onFieldFocus], message: "Name empty"), + ValidationRule.noSpecialCharacters( + conditions: [.manual, .onFieldValueChanged, .onFieldFocus], + message: "No spec chars" + ), ValidationRule.myRule, - ValidationRule.external { [weak self] in await self?.availabilityCheckAsync($0) } + ValidationRule.external { [weak self] in + await self?.availabilityCheckAsync($0) + } ] ageValidationRules = [ - ValidationRule.digitsOnly(message: "Digits only"), - ValidationRule.maxLength(count: 2, message: "Max length 2") + ValidationRule.digitsOnly( + conditions: [.manual, .onFieldValueChanged], + message: "Digits only" + ), + ValidationRule.maxLength( + conditions: [.manual, .onFieldValueChanged], + count: 2, + message: "Max length 2" + ) ] passValidationRules = [ - ValidationRule.atLeastOneDigit(message: "One digit"), - ValidationRule.atLeastOneLetter(message: "One letter"), - ValidationRule.notEmpty(message: "Pass not empty") + ValidationRule.atLeastOneDigit(conditions: [.manual, .onFieldValueChanged], message: "One digit"), + ValidationRule.atLeastOneLetter(conditions: [.manual, .onFieldValueChanged], message: "One letter"), + ValidationRule.notEmpty(conditions: [.manual, .onFieldValueChanged], message: "Pass not empty") ] confirmPassValidationRules = [ - ValidationRule.notEmpty(message: "Confirm pass not empty"), - ValidationRule.custom { [weak self] in + ValidationRule.notEmpty(conditions: [.manual, .onFieldValueChanged], message: "Confirm pass not empty"), + ValidationRule.custom(conditions: [.manual, .onFieldValueChanged]) { [weak self] in return $0 == self?.pass ? nil : "Not equal to pass" } ] diff --git a/Sources/FormView/Environment/EnvironmentKeys.swift b/Sources/FormView/Environment/EnvironmentKeys.swift index 61b91fe..141bedc 100644 --- a/Sources/FormView/Environment/EnvironmentKeys.swift +++ b/Sources/FormView/Environment/EnvironmentKeys.swift @@ -23,11 +23,11 @@ extension EnvironmentValues { // MARK: - ValidationBehaviourKey private struct ValidationBehaviourKey: EnvironmentKey { - static var defaultValue: ValidationBehaviour = .never + static var defaultValue: [ValidationBehaviour] = [.manual] } extension EnvironmentValues { - var validationBehaviour: ValidationBehaviour { + var validationBehaviour: [ValidationBehaviour] { get { self[ValidationBehaviourKey.self] } set { self[ValidationBehaviourKey.self] = newValue } } diff --git a/Sources/FormView/FormField.swift b/Sources/FormView/FormField.swift index 0fa9486..a466f2f 100644 --- a/Sources/FormView/FormField.swift +++ b/Sources/FormView/FormField.swift @@ -46,7 +46,11 @@ public struct FormField: View { value: [ // Замыкание для каждого филда вызывается FormValidator'ом из FormView для валидации по требованию FieldState(id: id, isFocused: isFocused) { - let failedRules = await validator.validate(value: value, isNeedToCheckExternal: true) + let failedRules = await validator.validate( + value: value, + condition: .manual, + isNeedToCheckExternal: true + ) failedValidationRules = failedRules return failedRules.isEmpty @@ -62,8 +66,12 @@ public struct FormField: View { failedValidationRules = .empty } - if validationBehaviour == .onFieldValueChanged { - failedValidationRules = await validator.validate(value: newValue, isNeedToCheckExternal: false) + if validationBehaviour.contains(.onFieldValueChanged) { + failedValidationRules = await validator.validate( + value: newValue, + condition: .onFieldValueChanged, + isNeedToCheckExternal: false + ) } } } @@ -75,8 +83,20 @@ public struct FormField: View { failedValidationRules = .empty } - if validationBehaviour == .onFieldFocusLost && newValue == false { - failedValidationRules = await validator.validate(value: value, isNeedToCheckExternal: false) + if validationBehaviour.contains(.onFieldFocusLost) && newValue == false { + failedValidationRules = await validator.validate( + value: value, + condition: .onFieldFocusLost, + isNeedToCheckExternal: false + ) + } + + if validationBehaviour.contains(.onFieldFocus) && newValue == true { + failedValidationRules = await validator.validate( + value: value, + condition: .onFieldFocus, + isNeedToCheckExternal: false + ) } } } diff --git a/Sources/FormView/FormView.swift b/Sources/FormView/FormView.swift index a33fa83..a00c2d9 100644 --- a/Sources/FormView/FormView.swift +++ b/Sources/FormView/FormView.swift @@ -9,8 +9,9 @@ import SwiftUI public enum ValidationBehaviour { case onFieldValueChanged + case onFieldFocus case onFieldFocusLost - case never + case manual } public enum ErrorHideBehaviour { @@ -65,10 +66,10 @@ public struct FormView: View { @ViewBuilder private let content: (FormValidator) -> Content private let errorHideBehaviour: ErrorHideBehaviour - private let validationBehaviour: ValidationBehaviour + private let validationBehaviour: [ValidationBehaviour] public init( - validate: ValidationBehaviour = .never, + validate: [ValidationBehaviour] = [.manual], hideError: ErrorHideBehaviour = .onValueChanged, @ViewBuilder content: @escaping (FormValidator) -> Content ) { diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index 5f67b7f..39670e8 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -11,12 +11,18 @@ import SwiftUI public class ValidationRule { public var message: String? public let isExternal: Bool + public let conditions: [ValidationBehaviour] private let checkClosure: (String) async -> String? - internal required init(isExternal: Bool, checkClosure: @escaping (String) async -> String?) { + internal required init( + conditions: [ValidationBehaviour], + isExternal: Bool, + checkClosure: @escaping (String) async -> String? + ) { self.checkClosure = checkClosure self.isExternal = isExternal + self.conditions = conditions } public func check(value: String) async -> Bool { @@ -28,95 +34,98 @@ public class ValidationRule { } extension ValidationRule { - public static func custom(checkClosure: @escaping (String) async -> String?) -> Self { - return Self(isExternal: false, checkClosure: checkClosure) + public static func custom( + conditions: [ValidationBehaviour], + checkClosure: @escaping (String) async -> String? + ) -> Self { + return Self(conditions: conditions, isExternal: false, checkClosure: checkClosure) } public static func external(checkClosure: @escaping (String) async -> String?) -> Self { - return Self(isExternal: true, checkClosure: checkClosure) + return Self(conditions: [.manual], isExternal: true, checkClosure: checkClosure) } - public static func notEmpty(message: String) -> Self { - return Self(isExternal: false) { + public static func notEmpty(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { return $0.isEmpty == false ? nil : message } } - public static func atLeastOneLowercaseLetter(message: String) -> Self { - return Self(isExternal: false) { + public static func atLeastOneLowercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? nil : message } } - public static func atLeastOneUppercaseLetter(message: String) -> Self { - return Self(isExternal: false) { + public static func atLeastOneUppercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? nil : message } } - public static func atLeastOneDigit(message: String) -> Self { - return Self(isExternal: false) { + public static func atLeastOneDigit(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? nil : message } } - public static func atLeastOneLetter(message: String) -> Self { - return Self(isExternal: false) { + public static func atLeastOneLetter(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? nil : message } } - public static func digitsOnly(message: String) -> Self { - return Self(isExternal: false) { + public static func digitsOnly(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message } } - public static func lettersOnly(message: String) -> Self { - return Self(isExternal: false) { + public static func lettersOnly(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message } } - public static func atLeastOneSpecialCharacter(message: String) -> Self { - return Self(isExternal: false) { + public static func atLeastOneSpecialCharacter(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil ? nil : message } } - public static func noSpecialCharacters(message: String) -> Self { - return Self(isExternal: false) { + public static func noSpecialCharacters(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil ? nil : message } } - public static func email(message: String) -> Self { - return Self(isExternal: false) { + public static func email(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") .evaluate(with: $0) ? nil : message } } - public static func notRecurringPincode(message: String) -> Self { - return Self(isExternal: false) { + public static func notRecurringPincode(conditions: [ValidationBehaviour], message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? nil : message } } - public static func minLength(count: Int, message: String) -> Self { - return Self(isExternal: false) { + public static func minLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.count >= count ? nil : message } } - public static func maxLength(count: Int, message: String) -> Self { - return Self(isExternal: false) { + public static func maxLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { $0.count <= count ? nil : message } } - public static func regex(value: String, message: String) -> Self { - return Self(isExternal: false) { + public static func regex(conditions: [ValidationBehaviour], value: String, message: String) -> Self { + return Self(conditions: conditions, isExternal: false) { NSPredicate(format: "SELF MATCHES %@", value) .evaluate(with: $0) ? nil : message } diff --git a/Sources/FormView/Validation/Validators/FieldValidator.swift b/Sources/FormView/Validation/Validators/FieldValidator.swift index ec7d609..6acaf40 100644 --- a/Sources/FormView/Validation/Validators/FieldValidator.swift +++ b/Sources/FormView/Validation/Validators/FieldValidator.swift @@ -14,10 +14,15 @@ struct FieldValidator { self.rules = rules } - func validate(value: String, isNeedToCheckExternal: Bool) async -> [ValidationRule] { + func validate( + value: String, + condition: ValidationBehaviour, + isNeedToCheckExternal: Bool + ) async -> [ValidationRule] { var failedRules: [ValidationRule] = [] - for rule in rules where rule.isExternal == false || isNeedToCheckExternal { + for rule in rules where (rule.isExternal == false || isNeedToCheckExternal) + && rule.conditions.contains(condition) { if await rule.check(value: value) == false { failedRules.append(rule) } From 4162a802a7546a9beaf80b6f0d398b956dc58ff8 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 18:25:15 +0300 Subject: [PATCH 09/14] make message required --- .../InputFields/SecureInputField.swift | 4 +- ExampleApp/ExampleApp/MyRule.swift | 2 +- .../UI/ContentScreen/ContentViewModel.swift | 12 +++-- .../Validation/Rules/ValidationRule.swift | 50 +++++++++++-------- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift index 9ceddc3..8e4bc7a 100644 --- a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift @@ -24,8 +24,8 @@ struct SecureInputField: View { eyeImage } .background(Color.white) - if failedRules.isEmpty == false, let message = failedRules[0].message { - Text(message) + if failedRules.isEmpty == false { + Text(failedRules[0].message) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) } diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 26d6411..1c2e105 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -9,7 +9,7 @@ import FormView extension ValidationRule { static var myRule: Self { Self.custom(conditions: [.manual, .onFieldValueChanged, .onFieldFocus]) { - return $0.contains("T") ? nil : "Should contain T" + return $0.contains("T") ? ("Should contain T", true) : ("Should contain T", false) } } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index b75539e..3e6201b 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -38,7 +38,11 @@ class ContentViewModel: ObservableObject { ), ValidationRule.myRule, ValidationRule.external { [weak self] in - await self?.availabilityCheckAsync($0) + guard let self else { + return ("", true) + } + + return await self.availabilityCheckAsync($0) } ] @@ -63,13 +67,13 @@ class ContentViewModel: ObservableObject { confirmPassValidationRules = [ ValidationRule.notEmpty(conditions: [.manual, .onFieldValueChanged], message: "Confirm pass not empty"), ValidationRule.custom(conditions: [.manual, .onFieldValueChanged]) { [weak self] in - return $0 == self?.pass ? nil : "Not equal to pass" + return $0 == self?.pass ? ("Not equal to pass", true) : ("Not equal to pass", false) } ] } @MainActor - private func availabilityCheckAsync(_ value: String) async -> String? { + private func availabilityCheckAsync(_ value: String) async -> (String, Bool) { print(#function) isLoading = true @@ -80,7 +84,7 @@ class ContentViewModel: ObservableObject { isLoading = false - return isAvailable ? nil : "Not available" + return isAvailable ? ("Not available", true) : ("Not available", false) } deinit { diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index 39670e8..1b35e61 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -9,125 +9,133 @@ import Foundation import SwiftUI public class ValidationRule { - public var message: String? + public var message: String public let isExternal: Bool public let conditions: [ValidationBehaviour] - private let checkClosure: (String) async -> String? + private let checkClosure: (String) async -> (String, Bool) internal required init( conditions: [ValidationBehaviour], isExternal: Bool, - checkClosure: @escaping (String) async -> String? + checkClosure: @escaping (String) async -> (String, Bool) ) { + self.message = .empty self.checkClosure = checkClosure self.isExternal = isExternal self.conditions = conditions } public func check(value: String) async -> Bool { - let message = await checkClosure(value) + let checkResult = await checkClosure(value) + let message = checkResult.0 self.message = message - return message == nil + return checkResult.1 } } extension ValidationRule { public static func custom( conditions: [ValidationBehaviour], - checkClosure: @escaping (String) async -> String? + checkClosure: @escaping (String) async -> (String, Bool) ) -> Self { return Self(conditions: conditions, isExternal: false, checkClosure: checkClosure) } - public static func external(checkClosure: @escaping (String) async -> String?) -> Self { + public static func external(checkClosure: @escaping (String) async -> (String, Bool)) -> Self { return Self(conditions: [.manual], isExternal: true, checkClosure: checkClosure) } public static func notEmpty(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - return $0.isEmpty == false ? nil : message + return $0.isEmpty == false ? (message, true) : (message, false) } } public static func atLeastOneLowercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? nil : message + return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? (message, true) : (message, false) } } public static func atLeastOneUppercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? nil : message + $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? (message, true) : (message, false) } } public static func atLeastOneDigit(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? nil : message + $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? (message, true) : (message, false) } } public static func atLeastOneLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? nil : message + $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? (message, true) : (message, false) } } public static func digitsOnly(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) + ? (message, true) + : (message, false) } } public static func lettersOnly(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? (message, true) : (message, false) } } public static func atLeastOneSpecialCharacter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil ? nil : message + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil + ? (message, true) + : (message, false) } } public static func noSpecialCharacters(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil ? nil : message + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil + ? (message, true) + : (message, false) } } public static func email(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") - .evaluate(with: $0) ? nil : message + .evaluate(with: $0) ? (message, true) : (message, false) } } public static func notRecurringPincode(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? nil : message + $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? (message, true) : (message, false) } } public static func minLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.count >= count ? nil : message + $0.count >= count ? (message, true) : (message, false) } } public static func maxLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.count <= count ? nil : message + $0.count <= count ? (message, true) : (message, false) } } public static func regex(conditions: [ValidationBehaviour], value: String, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { NSPredicate(format: "SELF MATCHES %@", value) - .evaluate(with: $0) ? nil : message + .evaluate(with: $0) ? (message, true) : (message, false) } } } From 49eafc53348a2203fcf78a48846d53d311ab222b Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 18:50:42 +0300 Subject: [PATCH 10/14] refactor --- .../Validation/Rules/ValidationRule.swift | 52 +++++++++---------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index 1b35e61..b143774 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -13,12 +13,12 @@ public class ValidationRule { public let isExternal: Bool public let conditions: [ValidationBehaviour] - private let checkClosure: (String) async -> (String, Bool) + private let checkClosure: (String) async -> (Bool, String) internal required init( conditions: [ValidationBehaviour], isExternal: Bool, - checkClosure: @escaping (String) async -> (String, Bool) + checkClosure: @escaping (String) async -> (Bool, String) ) { self.message = .empty self.checkClosure = checkClosure @@ -28,114 +28,110 @@ public class ValidationRule { public func check(value: String) async -> Bool { let checkResult = await checkClosure(value) - let message = checkResult.0 + let message = checkResult.1 self.message = message - return checkResult.1 + return checkResult.0 } } extension ValidationRule { public static func custom( conditions: [ValidationBehaviour], - checkClosure: @escaping (String) async -> (String, Bool) + checkClosure: @escaping (String) async -> (Bool, String) ) -> Self { return Self(conditions: conditions, isExternal: false, checkClosure: checkClosure) } - public static func external(checkClosure: @escaping (String) async -> (String, Bool)) -> Self { + public static func external(checkClosure: @escaping (String) async -> (Bool, String)) -> Self { return Self(conditions: [.manual], isExternal: true, checkClosure: checkClosure) } public static func notEmpty(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - return $0.isEmpty == false ? (message, true) : (message, false) + return ($0.isEmpty == false, message) } } public static func atLeastOneLowercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? (message, true) : (message, false) + return ($0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil, message) } } public static func atLeastOneUppercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? (message, true) : (message, false) + return ($0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil, message) } } public static func atLeastOneDigit(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? (message, true) : (message, false) + return ($0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil, message) } } public static func atLeastOneLetter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? (message, true) : (message, false) + return ($0.rangeOfCharacter(from: CharacterSet.letters) != nil, message) } } public static func digitsOnly(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) - ? (message, true) - : (message, false) + return (CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)), message) } } public static func lettersOnly(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? (message, true) : (message, false) + return (CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)), message) } } public static func atLeastOneSpecialCharacter(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil - ? (message, true) - : (message, false) + return ($0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil, message) } } public static func noSpecialCharacters(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil - ? (message, true) - : (message, false) + return ($0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil, message) } } public static func email(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") - .evaluate(with: $0) ? (message, true) : (message, false) + return ( + NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") + .evaluate(with: $0), + message + ) } } public static func notRecurringPincode(conditions: [ValidationBehaviour], message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? (message, true) : (message, false) + return ($0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil, message) } } public static func minLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.count >= count ? (message, true) : (message, false) + return ($0.count >= count, message) } } public static func maxLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - $0.count <= count ? (message, true) : (message, false) + return ($0.count <= count, message) } } public static func regex(conditions: [ValidationBehaviour], value: String, message: String) -> Self { return Self(conditions: conditions, isExternal: false) { - NSPredicate(format: "SELF MATCHES %@", value) - .evaluate(with: $0) ? (message, true) : (message, false) + return (NSPredicate(format: "SELF MATCHES %@", value).evaluate(with: $0), message) } } } From ef0b8f3bc9029d0424d1dd2a66d7fe837db0b159 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 18:56:31 +0300 Subject: [PATCH 11/14] refactor --- ExampleApp/ExampleApp/MyRule.swift | 2 +- .../ExampleApp/UI/ContentScreen/ContentViewModel.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 1c2e105..16ca586 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -9,7 +9,7 @@ import FormView extension ValidationRule { static var myRule: Self { Self.custom(conditions: [.manual, .onFieldValueChanged, .onFieldFocus]) { - return $0.contains("T") ? ("Should contain T", true) : ("Should contain T", false) + return ($0.contains("T"), "Should contain T") } } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index 3e6201b..fa9437a 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -39,7 +39,7 @@ class ContentViewModel: ObservableObject { ValidationRule.myRule, ValidationRule.external { [weak self] in guard let self else { - return ("", true) + return (true, "") } return await self.availabilityCheckAsync($0) @@ -67,13 +67,13 @@ class ContentViewModel: ObservableObject { confirmPassValidationRules = [ ValidationRule.notEmpty(conditions: [.manual, .onFieldValueChanged], message: "Confirm pass not empty"), ValidationRule.custom(conditions: [.manual, .onFieldValueChanged]) { [weak self] in - return $0 == self?.pass ? ("Not equal to pass", true) : ("Not equal to pass", false) + return ($0 == self?.pass, "Not equal to pass") } ] } @MainActor - private func availabilityCheckAsync(_ value: String) async -> (String, Bool) { + private func availabilityCheckAsync(_ value: String) async -> (Bool, String) { print(#function) isLoading = true @@ -84,7 +84,7 @@ class ContentViewModel: ObservableObject { isLoading = false - return isAvailable ? ("Not available", true) : ("Not available", false) + return (isAvailable, "Not available") } deinit { From 3de1bd4c849a76223d55d105580decb07e844bb2 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 19:08:30 +0300 Subject: [PATCH 12/14] refactor --- ExampleApp/ExampleApp/InputFields/SecureInputField.swift | 2 +- ExampleApp/ExampleApp/InputFields/TextInputField.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift index 8e4bc7a..bb2d6fe 100644 --- a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift @@ -24,7 +24,7 @@ struct SecureInputField: View { eyeImage } .background(Color.white) - if failedRules.isEmpty == false { + if failedRules.isEmpty == false, failedRules[0].message.isEmpty == false { Text(failedRules[0].message) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index d39d1a4..0f47e55 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -17,7 +17,7 @@ struct TextInputField: View { VStack(alignment: .leading) { TextField(title, text: $text) .background(Color.white) - if let errorMessage = failedRules.first?.message { + if let errorMessage = failedRules.first?.message, errorMessage.isEmpty == false { Text(errorMessage) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) From 4c5c08624ba4da94eb0b7bb1a935ccee66e46c38 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Tue, 18 Mar 2025 19:52:10 +0300 Subject: [PATCH 13/14] refactor --- ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift | 2 +- Sources/FormView/FormField.swift | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index cf1a406..345c031 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -13,7 +13,7 @@ struct ContentView: View { var body: some View { FormView( - validate: [.manual, .onFieldFocusLost, .onFieldValueChanged, .onFieldFocus], + validate: [.manual, .onFieldValueChanged, .onFieldFocus], hideError: .onValueChanged ) { proxy in FormField( diff --git a/Sources/FormView/FormField.swift b/Sources/FormView/FormField.swift index a466f2f..65f06be 100644 --- a/Sources/FormView/FormField.swift +++ b/Sources/FormView/FormField.swift @@ -91,7 +91,11 @@ public struct FormField: View { ) } - if validationBehaviour.contains(.onFieldFocus) && newValue == true { + if + validationBehaviour.contains(.onFieldFocus) + && failedValidationRules.isEmpty + && newValue == true + { failedValidationRules = await validator.validate( value: value, condition: .onFieldFocus, From 8121ff870cdf9536c76412249ff3c2e25f8e5019 Mon Sep 17 00:00:00 2001 From: Yan Boyko Date: Wed, 19 Mar 2025 15:29:23 +0300 Subject: [PATCH 14/14] fix comments --- .../UI/ContentScreen/ContentViewModel.swift | 11 ++--------- .../FormView/Validation/Rules/ValidationRule.swift | 5 ++--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index fa9437a..6e587e0 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -47,15 +47,8 @@ class ContentViewModel: ObservableObject { ] ageValidationRules = [ - ValidationRule.digitsOnly( - conditions: [.manual, .onFieldValueChanged], - message: "Digits only" - ), - ValidationRule.maxLength( - conditions: [.manual, .onFieldValueChanged], - count: 2, - message: "Max length 2" - ) + ValidationRule.digitsOnly(conditions: [.manual, .onFieldValueChanged], message: "Digits only"), + ValidationRule.maxLength(conditions: [.manual, .onFieldValueChanged], count: 2, message: "Max length 2") ] passValidationRules = [ diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index b143774..0346864 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -27,11 +27,10 @@ public class ValidationRule { } public func check(value: String) async -> Bool { - let checkResult = await checkClosure(value) - let message = checkResult.1 + let (result, message) = await checkClosure(value) self.message = message - return checkResult.0 + return result } }