Skip to content
Merged

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions ExampleApp/ExampleApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {

return true
}
}

4 changes: 2 additions & 2 deletions ExampleApp/ExampleApp/InputFields/SecureInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import FormView
struct SecureInputField: View {
let title: LocalizedStringKey
let text: Binding<String>
let failedRules: [TextValidationRule]
let failedRules: [ValidationRule]

@FocusState private var isFocused: Bool
@State private var isSecure = true
Expand All @@ -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)
Expand Down
20 changes: 15 additions & 5 deletions ExampleApp/ExampleApp/InputFields/TextInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,30 @@ import FormView

struct TextInputField: View {
let title: LocalizedStringKey
let text: Binding<String>
let failedRules: [TextValidationRule]
@Binding var text: String
let failedRules: [ValidationRule]

var body: some View {
VStack(alignment: .leading) {
TextField(title, text: text)
TextField(title, text: $text)
.background(Color.white)
if failedRules.isEmpty == false {
Text(failedRules[0].message)
if let errorMessage = failedRules.first?.message, errorMessage.isEmpty == false {
Text(errorMessage)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.red)
}
Spacer()
}
.frame(height: 50)
}

init(
title: LocalizedStringKey,
text: Binding<String>,
failedRules: [ValidationRule]
) {
self.title = title
self._text = text
self.failedRules = failedRules
}
}
6 changes: 3 additions & 3 deletions ExampleApp/ExampleApp/MyRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

import FormView

extension TextValidationRule {
extension ValidationRule {
static var myRule: Self {
TextValidationRule(message: "Shold contain T") {
$0.contains("T")
Self.custom(conditions: [.manual, .onFieldValueChanged, .onFieldFocus]) {
return ($0.contains("T"), "Should contain T")
}
}
}
36 changes: 16 additions & 20 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,46 @@ struct ContentView: View {

var body: some View {
FormView(
validate: .never,
validate: [.manual, .onFieldValueChanged, .onFieldFocus],
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)
}
.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)
}
.disabled(viewModel.isLoading)
if viewModel.isLoading {
ProgressView()
}
Button("Validate") {
print("Form is valid: \(proxy.validate())")
Task {
print("Form is valid: \(await proxy.validate())")
}
}
.disabled(viewModel.isLoading)
}
.padding(.horizontal, 16)
.padding(.top, 40)
Expand Down
67 changes: 67 additions & 0 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,85 @@
//

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 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")

setupValidationRules()
}

private func setupValidationRules() {
nameValidationRules = [
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
guard let self else {
return (true, "")
}

return await self.availabilityCheckAsync($0)
}
]

ageValidationRules = [
ValidationRule.digitsOnly(
conditions: [.manual, .onFieldValueChanged],
message: "Digits only"
),
ValidationRule.maxLength(
conditions: [.manual, .onFieldValueChanged],
count: 2,
message: "Max length 2"
)
Comment thread
yanboyko marked this conversation as resolved.
Outdated
]

passValidationRules = [
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(conditions: [.manual, .onFieldValueChanged], message: "Confirm pass not empty"),
ValidationRule.custom(conditions: [.manual, .onFieldValueChanged]) { [weak self] in
return ($0 == self?.pass, "Not equal to pass")
}
]
}

@MainActor
private func availabilityCheckAsync(_ value: String) async -> (Bool, String) {
print(#function)

isLoading = true

try? await Task.sleep(nanoseconds: 2_000_000_000)

let isAvailable = Bool.random()

isLoading = false

return (isAvailable, "Not available")
}

deinit {
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,11 @@ 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(
name: "FormView",
dependencies: []),
.testTarget(
name: "FormViewTests",
dependencies: ["FormView", "ViewInspector"])
dependencies: [])
]
)
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,92 @@ 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
@Binding var text: String
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)
.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
}
}

init(
title: LocalizedStringKey,
text: Binding<String>,
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.

Expand Down
4 changes: 2 additions & 2 deletions Sources/FormView/Environment/EnvironmentKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down
Loading