Skip to content
4 changes: 2 additions & 2 deletions ExampleApp/ExampleApp/InputFields/SecureInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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, failedRules[0].message.isEmpty == false {
Text(failedRules[0].message)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.red)
}
Expand Down
2 changes: 1 addition & 1 deletion ExampleApp/ExampleApp/InputFields/TextInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions ExampleApp/ExampleApp/MyRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import FormView

extension ValidationRule {
static var myRule: Self {
Self.custom {
return $0.contains("T") ? nil : "Should contain T"
Self.custom(conditions: [.manual, .onFieldValueChanged, .onFieldFocus]) {
return ($0.contains("T"), "Should contain T")
}
}
}
2 changes: 1 addition & 1 deletion ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct ContentView: View {

var body: some View {
FormView(
validate: .onFieldValueChanged,
validate: [.manual, .onFieldValueChanged, .onFieldFocus],
hideError: .onValueChanged
) { proxy in
FormField(
Expand Down
35 changes: 22 additions & 13 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,42 @@ 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
guard let self else {
return (true, "")
}

return 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
return $0 == self?.pass ? nil : "Not equal to pass"
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 -> String? {
private func availabilityCheckAsync(_ value: String) async -> (Bool, String) {
print(#function)

isLoading = true
Expand All @@ -68,7 +77,7 @@ class ContentViewModel: ObservableObject {

isLoading = false

return isAvailable ? nil : "Not available"
return (isAvailable, "Not available")
}

deinit {
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
34 changes: 29 additions & 5 deletions Sources/FormView/FormField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ public struct FormField<Content: View>: 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
Expand All @@ -62,8 +66,12 @@ public struct FormField<Content: View>: 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
)
}
}
}
Expand All @@ -75,8 +83,24 @@ public struct FormField<Content: View>: 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)
&& failedValidationRules.isEmpty
&& newValue == true
{
failedValidationRules = await validator.validate(
value: value,
condition: .onFieldFocus,
isNeedToCheckExternal: false
)
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions Sources/FormView/FormView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ import SwiftUI

public enum ValidationBehaviour {
case onFieldValueChanged
case onFieldFocus
case onFieldFocusLost
case never
case manual
}

public enum ErrorHideBehaviour {
Expand Down Expand Up @@ -65,10 +66,10 @@ public struct FormView<Content: View>: 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
) {
Expand Down
118 changes: 65 additions & 53 deletions Sources/FormView/Validation/Rules/ValidationRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,116 +9,128 @@ 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 -> (Bool, String)

internal required init(isExternal: Bool, checkClosure: @escaping (String) async -> String?) {
internal required init(
conditions: [ValidationBehaviour],
isExternal: Bool,
checkClosure: @escaping (String) async -> (Bool, String)
) {
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 (result, message) = await checkClosure(value)
self.message = message

return message == nil
return result
}
}

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 -> (Bool, 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)
public static func external(checkClosure: @escaping (String) async -> (Bool, String)) -> Self {
return Self(conditions: [.manual], isExternal: true, checkClosure: checkClosure)
}

public static func notEmpty(message: String) -> Self {
return Self(isExternal: false) {
return $0.isEmpty == false ? nil : message
public static func notEmpty(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.isEmpty == false, message)
}
}

public static func atLeastOneLowercaseLetter(message: String) -> Self {
return Self(isExternal: false) {
return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? nil : message
public static func atLeastOneLowercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil, message)
}
}

public static func atLeastOneUppercaseLetter(message: String) -> Self {
return Self(isExternal: false) {
$0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? nil : message
public static func atLeastOneUppercaseLetter(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil, message)
}
}

public static func atLeastOneDigit(message: String) -> Self {
return Self(isExternal: false) {
$0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? nil : message
public static func atLeastOneDigit(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil, message)
}
}

public static func atLeastOneLetter(message: String) -> Self {
return Self(isExternal: false) {
$0.rangeOfCharacter(from: CharacterSet.letters) != nil ? nil : message
public static func atLeastOneLetter(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.rangeOfCharacter(from: CharacterSet.letters) != 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 digitsOnly(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return (CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)), message)
}
}

public static func lettersOnly(message: String) -> Self {
return Self(isExternal: false) {
CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message
public static func lettersOnly(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return (CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)), 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 atLeastOneSpecialCharacter(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != 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 noSpecialCharacters(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == 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 email(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: 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(message: String) -> Self {
return Self(isExternal: false) {
$0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? nil : message
public static func notRecurringPincode(conditions: [ValidationBehaviour], message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil, message)
}
}

public static func minLength(count: Int, message: String) -> Self {
return Self(isExternal: false) {
$0.count >= count ? nil : message
public static func minLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.count >= count, message)
}
}

public static func maxLength(count: Int, message: String) -> Self {
return Self(isExternal: false) {
$0.count <= count ? nil : message
public static func maxLength(conditions: [ValidationBehaviour], count: Int, message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return ($0.count <= count, message)
}
}

public static func regex(value: String, message: String) -> Self {
return Self(isExternal: false) {
NSPredicate(format: "SELF MATCHES %@", value)
.evaluate(with: $0) ? nil : message
public static func regex(conditions: [ValidationBehaviour], value: String, message: String) -> Self {
return Self(conditions: conditions, isExternal: false) {
return (NSPredicate(format: "SELF MATCHES %@", value).evaluate(with: $0), message)
}
}
}
9 changes: 7 additions & 2 deletions Sources/FormView/Validation/Validators/FieldValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down