Skip to content

Commit 894bfd3

Browse files
authored
UPUP-976
1 parent 02a1bf6 commit 894bfd3

14 files changed

Lines changed: 341 additions & 192 deletions

File tree

ExampleApp/ExampleApp/AppDelegate.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
1313
_ application: UIApplication,
1414
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
1515
) -> Bool {
16-
1716
return true
1817
}
1918
}
20-

ExampleApp/ExampleApp/InputFields/SecureInputField.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import FormView
1111
struct SecureInputField: View {
1212
let title: LocalizedStringKey
1313
let text: Binding<String>
14-
let failedRules: [TextValidationRule]
14+
let failedRules: [ValidationRule]
1515

1616
@FocusState private var isFocused: Bool
1717
@State private var isSecure = true
@@ -24,8 +24,8 @@ struct SecureInputField: View {
2424
eyeImage
2525
}
2626
.background(Color.white)
27-
if failedRules.isEmpty == false {
28-
Text(failedRules[0].message)
27+
if failedRules.isEmpty == false, let message = failedRules[0].message {
28+
Text(message)
2929
.font(.system(size: 12, weight: .semibold))
3030
.foregroundColor(.red)
3131
}

ExampleApp/ExampleApp/InputFields/TextInputField.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,30 @@ import FormView
1010

1111
struct TextInputField: View {
1212
let title: LocalizedStringKey
13-
let text: Binding<String>
14-
let failedRules: [TextValidationRule]
13+
@Binding var text: String
14+
let failedRules: [ValidationRule]
1515

1616
var body: some View {
1717
VStack(alignment: .leading) {
18-
TextField(title, text: text)
18+
TextField(title, text: $text)
1919
.background(Color.white)
20-
if failedRules.isEmpty == false {
21-
Text(failedRules[0].message)
20+
if let errorMessage = failedRules.first?.message {
21+
Text(errorMessage)
2222
.font(.system(size: 12, weight: .semibold))
2323
.foregroundColor(.red)
2424
}
2525
Spacer()
2626
}
2727
.frame(height: 50)
2828
}
29+
30+
init(
31+
title: LocalizedStringKey,
32+
text: Binding<String>,
33+
failedRules: [ValidationRule]
34+
) {
35+
self.title = title
36+
self._text = text
37+
self.failedRules = failedRules
38+
}
2939
}

ExampleApp/ExampleApp/MyRule.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66

77
import FormView
88

9-
extension TextValidationRule {
9+
extension ValidationRule {
1010
static var myRule: Self {
11-
TextValidationRule(message: "Shold contain T") {
12-
$0.contains("T")
11+
Self.custom {
12+
return $0.contains("T") ? nil : "Should contain T"
1313
}
1414
}
1515
}

ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,50 +13,46 @@ struct ContentView: View {
1313

1414
var body: some View {
1515
FormView(
16-
validate: .never,
16+
validate: .onFieldValueChanged,
1717
hideError: .onValueChanged
1818
) { proxy in
1919
FormField(
2020
value: $viewModel.name,
21-
rules: [
22-
TextValidationRule.noSpecialCharacters(message: "No spec chars"),
23-
.notEmpty(message: "Name empty"),
24-
.myRule
25-
]
21+
rules: viewModel.nameValidationRules
2622
) { failedRules in
2723
TextInputField(title: "Name", text: $viewModel.name, failedRules: failedRules)
2824
}
25+
.disabled(viewModel.isLoading)
2926
FormField(
3027
value: $viewModel.age,
31-
rules: [
32-
TextValidationRule.digitsOnly(message: "Digits only"),
33-
.maxLength(count: 2, message: "Max length 2")
34-
]
28+
rules: viewModel.ageValidationRules
3529
) { failedRules in
3630
TextInputField(title: "Age", text: $viewModel.age, failedRules: failedRules)
3731
}
32+
.disabled(viewModel.isLoading)
3833
FormField(
3934
value: $viewModel.pass,
40-
rules: [
41-
TextValidationRule.atLeastOneDigit(message: "One digit"),
42-
.atLeastOneLetter(message: "One letter"),
43-
.notEmpty(message: "Pass not empty")
44-
]
35+
rules: viewModel.passValidationRules
4536
) { failedRules in
4637
SecureInputField(title: "Password", text: $viewModel.pass, failedRules: failedRules)
4738
}
39+
.disabled(viewModel.isLoading)
4840
FormField(
4941
value: $viewModel.confirmPass,
50-
rules: [
51-
TextValidationRule.equalTo(value: viewModel.pass, message: "Not equal to pass"),
52-
.notEmpty(message: "Confirm pass not empty")
53-
]
42+
rules: viewModel.confirmPassValidationRules
5443
) { failedRules in
5544
SecureInputField(title: "Confirm Password", text: $viewModel.confirmPass, failedRules: failedRules)
5645
}
46+
.disabled(viewModel.isLoading)
47+
if viewModel.isLoading {
48+
ProgressView()
49+
}
5750
Button("Validate") {
58-
print("Form is valid: \(proxy.validate())")
51+
Task {
52+
print("Form is valid: \(await proxy.validate())")
53+
}
5954
}
55+
.disabled(viewModel.isLoading)
6056
}
6157
.padding(.horizontal, 16)
6258
.padding(.top, 40)

ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,69 @@
66
//
77

88
import SwiftUI
9+
import FormView
910

1011
class ContentViewModel: ObservableObject {
1112
@Published var name: String = ""
1213
@Published var age: String = ""
1314
@Published var pass: String = ""
1415
@Published var confirmPass: String = ""
16+
@Published var isLoading = false
17+
18+
var nameValidationRules: [ValidationRule] = []
19+
var ageValidationRules: [ValidationRule] = []
20+
var passValidationRules: [ValidationRule] = []
21+
var confirmPassValidationRules: [ValidationRule] = []
1522

1623
private let coordinator: ContentCoordinator
1724

1825
init(coordinator: ContentCoordinator) {
1926
self.coordinator = coordinator
2027
print("init ContentViewModel")
28+
29+
setupValidationRules()
30+
}
31+
32+
private func setupValidationRules() {
33+
nameValidationRules = [
34+
ValidationRule.notEmpty(message: "Name empty"),
35+
ValidationRule.noSpecialCharacters(message: "No spec chars"),
36+
ValidationRule.myRule,
37+
ValidationRule.external { [weak self] in await self?.availabilityCheckAsync($0) }
38+
]
39+
40+
ageValidationRules = [
41+
ValidationRule.digitsOnly(message: "Digits only"),
42+
ValidationRule.maxLength(count: 2, message: "Max length 2")
43+
]
44+
45+
passValidationRules = [
46+
ValidationRule.atLeastOneDigit(message: "One digit"),
47+
ValidationRule.atLeastOneLetter(message: "One letter"),
48+
ValidationRule.notEmpty(message: "Pass not empty")
49+
]
50+
51+
confirmPassValidationRules = [
52+
ValidationRule.notEmpty(message: "Confirm pass not empty"),
53+
ValidationRule.custom { [weak self] in
54+
return $0 == self?.pass ? nil : "Not equal to pass"
55+
}
56+
]
57+
}
58+
59+
@MainActor
60+
private func availabilityCheckAsync(_ value: String) async -> String? {
61+
print(#function)
62+
63+
isLoading = true
64+
65+
try? await Task.sleep(nanoseconds: 2_000_000_000)
66+
67+
let isAvailable = Bool.random()
68+
69+
isLoading = false
70+
71+
return isAvailable ? nil : "Not available"
2172
}
2273

2374
deinit {

README.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,92 @@ A banch of predefind rules for text validation is available via `TextValidationR
124124
* equalTo - value equal to another value. Useful for password confirmation.
125125
* etc...
126126

127+
### Outer Validation Rules
128+
If you need to display validation errors from external services (e.g., a backend), follow these steps:
129+
1. Create an `OuterValidationRule` enum:
130+
```swift
131+
enum OuterValidationRule {
132+
case duplicateName
133+
134+
var message: String {
135+
switch self {
136+
case .duplicateName:
137+
return "This name already exists"
138+
}
139+
}
140+
}
141+
```
142+
143+
2. Update the text field component:
144+
```swift
145+
struct TextInputField: View {
146+
let title: LocalizedStringKey
147+
@Binding var text: String
148+
let failedRules: [TextValidationRule]
149+
@Binding var outerRules: [OuterValidationRule]
150+
151+
var body: some View {
152+
VStack(alignment: .leading) {
153+
TextField(title, text: $text)
154+
.background(Color.white)
155+
if let errorMessage = getErrorMessage() {
156+
Text(errorMessage)
157+
.font(.system(size: 12, weight: .semibold))
158+
.foregroundColor(.red)
159+
}
160+
Spacer()
161+
}
162+
.frame(height: 50)
163+
.onChange(of: text) { _ in
164+
outerRules = []
165+
}
166+
}
167+
168+
private func getErrorMessage() -> String? {
169+
if let message = failedRules.first?.message {
170+
return message
171+
} else if let message = outerRules.first?.message {
172+
return message
173+
} else {
174+
return nil
175+
}
176+
}
177+
178+
init(
179+
title: LocalizedStringKey,
180+
text: Binding<String>,
181+
failedRules: [TextValidationRule],
182+
outerRules: Binding<[OuterValidationRule]> = .constant([])
183+
) {
184+
self.title = title
185+
self._text = text
186+
self.failedRules = failedRules
187+
self._outerRules = outerRules
188+
}
189+
}
190+
```
191+
3. Update the text field initialization in your view:
192+
```swift
193+
TextInputField(
194+
title: "Name",
195+
text: $viewModel.name,
196+
failedRules: failedRules,
197+
outerRules: $viewModel.nameOuterRules
198+
)
199+
```
200+
201+
4. In your ViewModel, declare a `@Published` property of type `OuterValidationRule` and update its rules as needed:
202+
```swift
203+
class ContentViewModel: ObservableObject {
204+
@Published var nameOuterRules: [OuterValidationRule] = []
205+
206+
func applyNameOuterRules() {
207+
nameOuterRules = [.duplicateName]
208+
}
209+
}
210+
```
211+
212+
127213
### Implementation Details
128214
FormView doesn't use any external dependencies.
129215

Sources/FormView/FormField.swift

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,26 @@
77

88
import SwiftUI
99

10-
public struct FormField<Value: Hashable, Rule: ValidationRule, Content: View>: View where Value == Rule.Value {
11-
@Binding private var value: Value
12-
@ViewBuilder private let content: ([Rule]) -> Content
10+
public struct FormField<Content: View>: View {
11+
@Binding private var value: String
12+
@ViewBuilder private let content: ([ValidationRule]) -> Content
1313

14-
@State private var failedValidationRules: [Rule] = []
14+
@State private var failedValidationRules: [ValidationRule] = []
1515

1616
// Fields Focus
1717
@FocusState private var isFocused: Bool
1818
@State private var id: String = UUID().uuidString
1919
@Environment(\.focusedFieldId) var currentFocusedFieldId
2020

2121
// ValidateInput
22-
private let validator: FieldValidator<Rule>
22+
private let validator: FieldValidator
2323
@Environment(\.errorHideBehaviour) var errorHideBehaviour
2424
@Environment(\.validationBehaviour) var validationBehaviour
2525

2626
public init(
27-
value: Binding<Value>,
28-
rules: [Rule] = [],
29-
@ViewBuilder content: @escaping ([Rule]) -> Content
27+
value: Binding<String>,
28+
rules: [ValidationRule] = [],
29+
@ViewBuilder content: @escaping ([ValidationRule]) -> Content
3030
) {
3131
self._value = value
3232
self.content = content
@@ -46,7 +46,7 @@ public struct FormField<Value: Hashable, Rule: ValidationRule, Content: View>: V
4646
value: [
4747
// Замыкание для каждого филда вызывается FormValidator'ом из FormView для валидации по требованию
4848
FieldState(id: id, isFocused: isFocused) {
49-
let failedRules = validator.validate(value: value)
49+
let failedRules = await validator.validate(value: value, isNeedToCheckExternal: true)
5050
failedValidationRules = failedRules
5151

5252
return failedRules.isEmpty
@@ -57,23 +57,27 @@ public struct FormField<Value: Hashable, Rule: ValidationRule, Content: View>: V
5757

5858
// Fields Validation
5959
.onChange(of: value) { newValue in
60-
if errorHideBehaviour == .onValueChanged {
61-
failedValidationRules = .empty
62-
}
63-
64-
if validationBehaviour == .onFieldValueChanged {
65-
failedValidationRules = validator.validate(value: newValue)
60+
Task { @MainActor in
61+
if errorHideBehaviour == .onValueChanged {
62+
failedValidationRules = .empty
63+
}
64+
65+
if validationBehaviour == .onFieldValueChanged {
66+
failedValidationRules = await validator.validate(value: newValue, isNeedToCheckExternal: false)
67+
}
6668
}
6769
}
6870
.onChange(of: isFocused) { newValue in
69-
if errorHideBehaviour == .onFocusLost && newValue == false {
70-
failedValidationRules = .empty
71-
} else if errorHideBehaviour == .onFocus && newValue == true {
72-
failedValidationRules = .empty
73-
}
74-
75-
if validationBehaviour == .onFieldFocusLost && newValue == false {
76-
failedValidationRules = validator.validate(value: value)
71+
Task { @MainActor in
72+
if errorHideBehaviour == .onFocusLost && newValue == false {
73+
failedValidationRules = .empty
74+
} else if errorHideBehaviour == .onFocus && newValue == true {
75+
failedValidationRules = .empty
76+
}
77+
78+
if validationBehaviour == .onFieldFocusLost && newValue == false {
79+
failedValidationRules = await validator.validate(value: value, isNeedToCheckExternal: false)
80+
}
7781
}
7882
}
7983
}

0 commit comments

Comments
 (0)