Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

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
}
}

6 changes: 3 additions & 3 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,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)
}
Expand Down
28 changes: 23 additions & 5 deletions ExampleApp/ExampleApp/InputFields/TextInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,38 @@ 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 = getErrorMessage() {
Comment thread
NatalyaLuzyanina marked this conversation as resolved.
Outdated
Text(errorMessage)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.red)
}
Spacer()
}
.frame(height: 50)
}

init(
Comment thread
ilia-chub marked this conversation as resolved.
title: LocalizedStringKey,
text: Binding<String>,
failedRules: [ValidationRule]
) {
self.title = title
self._text = text
self.failedRules = failedRules
}

private func getErrorMessage() -> String? {
if let message = failedRules.first?.message {
return message
} else {
return nil
}
}
}
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 {
return $0.contains("T") ? nil : "Shold contain T"
Comment thread
ridebyhorse marked this conversation as resolved.
Outdated
}
}
}
42 changes: 21 additions & 21 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +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)
TextInputField(
title: "Name",
text: $viewModel.name,
failedRules: failedRules
)
Comment thread
NatalyaLuzyanina marked this conversation as resolved.
Outdated
}
.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
47 changes: 47 additions & 0 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +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 = ""
Comment thread
ridebyhorse marked this conversation as resolved.
@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"
}
]
Comment thread
ridebyhorse marked this conversation as resolved.
}

@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 {
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
Loading