From d36b31feded7e41a21570471fd45a7d8ac6ea2c6 Mon Sep 17 00:00:00 2001 From: John DeLong Date: Fri, 16 Aug 2024 21:52:15 -0400 Subject: [PATCH 1/3] WIP: Form validation --- Sources/MSLSwiftUI/Binding+KeyPathInit | 37 ----- Sources/MSLSwiftUI/Binding+KeyPathInit.swift | 37 +++++ Sources/MSLSwiftUI/Binding+Optional.swift | 10 ++ .../FormValidation/FormExample.swift | 141 ++++++++++++++++ .../FormValidation/FormValidatable.swift | 76 +++++++++ .../ValidatedPropertyWrapper.swift | 153 ++++++++++++++++++ .../FormValidation/isOptional.swift | 14 ++ 7 files changed, 431 insertions(+), 37 deletions(-) delete mode 100644 Sources/MSLSwiftUI/Binding+KeyPathInit create mode 100644 Sources/MSLSwiftUI/Binding+KeyPathInit.swift create mode 100644 Sources/MSLSwiftUI/Binding+Optional.swift create mode 100644 Sources/MSLSwiftUI/FormValidation/FormExample.swift create mode 100644 Sources/MSLSwiftUI/FormValidation/FormValidatable.swift create mode 100644 Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift create mode 100644 Sources/MSLSwiftUI/FormValidation/isOptional.swift diff --git a/Sources/MSLSwiftUI/Binding+KeyPathInit b/Sources/MSLSwiftUI/Binding+KeyPathInit deleted file mode 100644 index 9a85e32..0000000 --- a/Sources/MSLSwiftUI/Binding+KeyPathInit +++ /dev/null @@ -1,37 +0,0 @@ -import SwiftUI - -extension Binding { - /** - A convenience initializer for creating a `Binding` instance from a parent object and a writable key path. - - This initializer allows you to create a `Binding` that reads and writes to a specific property of a parent object. - - - Parameters: - - parent: The parent object containing the property to bind to. - - keyPath: A writable key path from the parent object to the property of type `Value`. - - - Example: - ```swift - struct Parent { - var childProperty: String - } - - let parent = Parent(childProperty: "Initial Value") - let binding = Binding(parent, keyPath: \.childProperty) - - // Now `binding` can be used to read and write `parent.childProperty` - binding.wrappedValue = "New Value" - print(parent.childProperty) // Prints "New Value" - ``` - **/ - init(_ parent: Parent, keyPath: WritableKeyPath) { - self.init( - get: { - return parent[keyPath: keyPath] - }, set: { - var parent = parent - parent[keyPath: keyPath] = $0 - } - ) - } -} diff --git a/Sources/MSLSwiftUI/Binding+KeyPathInit.swift b/Sources/MSLSwiftUI/Binding+KeyPathInit.swift new file mode 100644 index 0000000..d0705d1 --- /dev/null +++ b/Sources/MSLSwiftUI/Binding+KeyPathInit.swift @@ -0,0 +1,37 @@ +import SwiftUI + +extension Binding { + /** + A convenience initializer for creating a `Binding` instance from a parent object and a writable key path. + + This initializer allows you to create a `Binding` that reads and writes to a specific property of a parent object. + + - Parameters: + - parent: The parent object containing the property to bind to. + - keyPath: A writable key path from the parent object to the property of type `Value`. + + - Example: + ```swift + struct Parent { + var childProperty: String + } + + let parent = Parent(childProperty: "Initial Value") + let binding = Binding(parent, keyPath: \.childProperty) + + // Now `binding` can be used to read and write `parent.childProperty` + binding.wrappedValue = "New Value" + print(parent.childProperty) // Prints "New Value" + ``` + **/ + init(_ parent: Parent, keyPath: WritableKeyPath) { + self.init( + get: { + return parent[keyPath: keyPath] + }, set: { + var parent = parent + parent[keyPath: keyPath] = $0 + } + ) + } +} diff --git a/Sources/MSLSwiftUI/Binding+Optional.swift b/Sources/MSLSwiftUI/Binding+Optional.swift new file mode 100644 index 0000000..e7220f1 --- /dev/null +++ b/Sources/MSLSwiftUI/Binding+Optional.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Provides a way to default to a non optional type when using bindings +/// https://stackoverflow.com/a/61002589/6437349 +public func ?? (lhs: Binding, rhs: T) -> Binding { + Binding( + get: { lhs.wrappedValue ?? rhs }, + set: { lhs.wrappedValue = $0 } + ) +} diff --git a/Sources/MSLSwiftUI/FormValidation/FormExample.swift b/Sources/MSLSwiftUI/FormValidation/FormExample.swift new file mode 100644 index 0000000..a87ccc8 --- /dev/null +++ b/Sources/MSLSwiftUI/FormValidation/FormExample.swift @@ -0,0 +1,141 @@ +// Field format (i.e add dollar sign to monetary field) +// Field validation (i.e. verify number is between 1 and 10) +// - Prevent users from entering invalid values +// Bindable update + +// Required fields + +// FormValidation does not handle text formatting. Any text formatting should be +// handled by the view displaying the value from the form object. + +// Ideas: +// - Get "next" required/invalid field +// - Restrict value changes if validation fails +// - Dirty / Clean management + +import SwiftUI + +struct UserFormInfo: FormValidatable { + @FormFieldValidated( + requirement: .required(nil), + validation: { $0.count < 3 ? "Name must be at least 3 characters" : nil } + ) + var name = "" + + @FormFieldValidated( + requirement: .required("This field is required"), + validation: { value in + !(value?.isPhoneNumberValid() ?? false) ? "Phone number is not valid." : nil + } + ) + var phoneNumber: String? = nil + + @FormFieldValidated(validation: { !$0.isEmailValid() ? "Invalid email address" : nil }) + var email = "" + + @FormFieldValidated(validation: { $0.isEmpty ? "Address cannot be empty" : nil }) + var address = "" + + var dateOfBirth = Date() + + var donation: Double? = nil +} + +extension String { + func isPhoneNumberValid() -> Bool { + let phoneRegex = "^[0-9+\\- ]{7,15}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex) + return predicate.evaluate(with: self) + } + + func isEmailValid() -> Bool { + let emailRegex = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", emailRegex) + return predicate.evaluate(with: self) + } +} + +struct ExampleForm: View { + @State + private var userInfo = UserFormInfo() + + @State + private var isSubmitted = false + + @State + private var fieldToFocus: String? + + var body: some View { + VStack { + Form { + Section(header: Text("Personal Information")) { + TextField("Name", text: self.$userInfo.name) + .overlay( + ValidationErrorView(errorMessage: self.userInfo.$name.errorMessage), + alignment: .bottomLeading + ) + + TextField("Phone Number", text: self.$userInfo.phoneNumber ?? "") + .keyboardType(.phonePad) + .overlay( + ValidationErrorView(errorMessage: self.userInfo.$phoneNumber.errorMessage), + alignment: .bottomLeading + ) + + TextField("Email", text: self.$userInfo.email) + .keyboardType(.emailAddress) + .overlay(ValidationErrorView(errorMessage: self.userInfo.$email.errorMessage)) + + TextField("Address", text: self.$userInfo.address) + .overlay(ValidationErrorView(errorMessage: self.userInfo.$address.errorMessage)) + + DatePicker("Date of Birth", selection: self.$userInfo.dateOfBirth, displayedComponents: .date) + } + } + + Button("Submit") { + // Handle submit action + self.isSubmitted = true + } + .disabled(!self.userInfo.isValid()) + + Button("Jump To Field") { + self.fieldToFocus = self.userInfo.getNextInvalidProperty() + } + + Button("Reset") { + self.userInfo.reset() + } + + if let field = self.fieldToFocus { + Text(field) + } + + if self.userInfo.hasChanges() { + Text("Has Changes!") + } + + if self.isSubmitted { + Text("Submitted!") + } + } + } +} + +struct ValidationErrorView: View { + var errorMessage: String? + + var body: some View { + Group { + if let errorMessage = self.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + } + } + } +} + +#Preview { + ExampleForm() +} diff --git a/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift new file mode 100644 index 0000000..b2a613b --- /dev/null +++ b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift @@ -0,0 +1,76 @@ +import SwiftUI + +public protocol FormValidatable { + func isValid() -> Bool + + func hasChanges() -> Bool +} + +public extension FormValidatable { + func isValid() -> Bool { + let mirror = Mirror(reflecting: self) + for child in mirror.children { + if let field = child.value as? ValidatedProtocol { + if case .invalid = field.status { + return false + } + } + } + return true + } + + func hasChanges() -> Bool { + let mirror = Mirror(reflecting: self) + for child in mirror.children { + if let field = child.value as? ValidatedProtocol { + if field.editState == .dirty { + return true + } + } + } + return false + } + + func getNextInvalidProperty() -> String? { + let mirror = Mirror(reflecting: self) + for child in mirror.children { + guard let propertyName = child.label else { continue } + + if let field = child.value as? ValidatedProtocol { + if case .invalid = field.status { + return propertyName + } + } + } + return nil + } + + // May need to be a class +// mutating func reset() { +// let mirror = Mirror(reflecting: self) +// for var child in mirror.children { +// if var field = child.value as? ValidatedProtocol { +// field.reset() +// +// // Update the property value +// if let propertyName = child.label { +// let keyPath = \Self.[propertyName] +// self[keyPath: keyPath] = field as! Self.[propertyName] +// } +// } +// } +// } +} + +public protocol ValidatedProtocol { + /// An error message describing why the value provided for the field did not pass validation. + var errorMessage: String? { get } + + var isRequired: Bool { get } + + var status: FormFieldStatus { get } + + var editState: FormFieldEditState { get } + + mutating func reset() +} diff --git a/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift new file mode 100644 index 0000000..d6bad64 --- /dev/null +++ b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift @@ -0,0 +1,153 @@ +import SwiftUI + +public enum FormFieldRequirement: Equatable { + /// Provide an optional message when a value is not provided + case required(_ message: String?) + + /// A value is not required for this field + case notRequired +} + +public enum FormFieldStatus: Equatable { + /// No value has been provided + case empty + + /// A value has been provided, but it did not pass validation + case invalid(message: String?) + + /// A value has been provided and it passed validation + case valid +} + +public enum FormFieldEditState { + case pristine + case dirty +} + +@propertyWrapper +public struct FormFieldValidated: ValidatedProtocol { + private var value: Value + private let validation: (Value) -> String? + private let requirement: FormFieldRequirement + + private let originalValue: Value + + public private(set) var status: FormFieldStatus + + public var editState: FormFieldEditState { + if self.value != self.originalValue { + return .dirty + } else { + return .pristine + } + } + + public var errorMessage: String? { + if case let .invalid(message) = self.status { + return message + } else { + return nil + } + } + + public var isRequired: Bool { + if case .required = self.requirement { + return true + } else { + return false + } + } + + public var wrappedValue: Value { + get { self.value } + set { + // If the value is an optional String and the new value is an empty string, set it to nil + if + isOptional(Value.self), + let stringValue = newValue as? String, + stringValue.isEmpty == true + { + // Assign to `nil` when string is empty + self.value = assignNilIfEmpty(stringValue) as! Value + } else { + self.value = newValue + } + + let validationResult = self.validation(self.value) + self.status = Self.calculateStatus( + requirement: self.requirement, + value: self.value, + validationResult: validationResult + ) + } + } + + // When using $, returns the instance of this `FormFieldValidated` class + public var projectedValue: FormFieldValidated { + return self + } + + init( + wrappedValue: Value, + requirement: FormFieldRequirement = .notRequired, + validation: @escaping (Value) -> String? + ) { + self.originalValue = wrappedValue + + self.requirement = requirement + self.value = wrappedValue + self.validation = validation + + let validationResult = validation(self.value) + self.status = Self.calculateStatus( + requirement: requirement, + value: wrappedValue, + validationResult: validationResult + ) + } + + public mutating func reset() { + self.value = self.originalValue + } +} + +// MARK: Helper functions + +extension FormFieldValidated { + private static func calculateStatus( + requirement: FormFieldRequirement, + value: Value, + validationResult: String? + ) -> FormFieldStatus { + if self.isNilOrEmpty(value) { + if case let .required(message) = requirement { + return .invalid(message: message) + } else { + return .empty + } + } + + if let message = validationResult { + return .invalid(message: message) + } + + return .valid + } + + private func assignNilIfEmpty(_ value: T) -> T? { + if let stringValue = value as? String, stringValue.isEmpty { + return nil + } + return value + } + + private static func isNilOrEmpty(_ value: Value) -> Bool { + if let unwrappedValue = value as? AnyOptional, unwrappedValue.isNil { + return true + } else if (value as? String)?.isEmpty == true { + return true + } else { + return false + } + } +} diff --git a/Sources/MSLSwiftUI/FormValidation/isOptional.swift b/Sources/MSLSwiftUI/FormValidation/isOptional.swift new file mode 100644 index 0000000..24b5fb3 --- /dev/null +++ b/Sources/MSLSwiftUI/FormValidation/isOptional.swift @@ -0,0 +1,14 @@ +func isOptional(_ type: (some Any).Type) -> Bool { + return type is OptionalProtocol.Type +} + +protocol OptionalProtocol {} +extension Optional: OptionalProtocol {} + +protocol AnyOptional { + var isNil: Bool { get } +} + +extension Optional: AnyOptional { + var isNil: Bool { return self == nil } +} From a2efb7d69d44b347590faa0fffeafb5cb9886b21 Mon Sep 17 00:00:00 2001 From: John DeLong Date: Mon, 19 Aug 2024 10:49:49 -0400 Subject: [PATCH 2/3] Introduced reflection caching --- .../FormValidation/FormExample.swift | 7 +- .../FormValidation/FormValidatable.swift | 48 +++++-- .../FormValidation/KeyPathListable.swift | 122 ++++++++++++++++++ .../ValidatedPropertyWrapper.swift | 4 +- 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift diff --git a/Sources/MSLSwiftUI/FormValidation/FormExample.swift b/Sources/MSLSwiftUI/FormValidation/FormExample.swift index a87ccc8..74cec56 100644 --- a/Sources/MSLSwiftUI/FormValidation/FormExample.swift +++ b/Sources/MSLSwiftUI/FormValidation/FormExample.swift @@ -39,6 +39,10 @@ struct UserFormInfo: FormValidatable { var dateOfBirth = Date() var donation: Double? = nil + + mutating func reset() { + self._name.reset() + } } extension String { @@ -100,11 +104,12 @@ struct ExampleForm: View { .disabled(!self.userInfo.isValid()) Button("Jump To Field") { - self.fieldToFocus = self.userInfo.getNextInvalidProperty() +// self.fieldToFocus = self.userInfo.getNextInvalidProperty() } Button("Reset") { self.userInfo.reset() +// self.userInfo.reset() } if let field = self.fieldToFocus { diff --git a/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift index b2a613b..e9246b8 100644 --- a/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift +++ b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift @@ -1,14 +1,16 @@ import SwiftUI -public protocol FormValidatable { - func isValid() -> Bool +public protocol FormValidatable: KeyPathListable { + mutating func isValid() -> Bool - func hasChanges() -> Bool + mutating func hasChanges() -> Bool + + mutating func nextInvalidProperty() -> String? } public extension FormValidatable { - func isValid() -> Bool { - let mirror = Mirror(reflecting: self) + mutating func isValid() -> Bool { + let mirror = self.reflectionCache() for child in mirror.children { if let field = child.value as? ValidatedProtocol { if case .invalid = field.status { @@ -19,8 +21,17 @@ public extension FormValidatable { return true } - func hasChanges() -> Bool { - let mirror = Mirror(reflecting: self) + mutating func hasChanges() -> Bool { +// for keyPath in Self.allKeyPaths.values { +// if let field = self[keyPath: keyPath] as? ValidatedProtocol { +// if field.editState == .dirty { +// return true +// } +// } +// print("test") +// } + + let mirror = self.reflectionCache() for child in mirror.children { if let field = child.value as? ValidatedProtocol { if field.editState == .dirty { @@ -31,8 +42,8 @@ public extension FormValidatable { return false } - func getNextInvalidProperty() -> String? { - let mirror = Mirror(reflecting: self) + mutating func nextInvalidProperty() -> String? { + let mirror = self.reflectionCache() for child in mirror.children { guard let propertyName = child.label else { continue } @@ -62,6 +73,25 @@ public extension FormValidatable { // } } +public protocol FormNavigatable { + static var keyPaths: [String: PartialKeyPath] { get } + var nextInvalidProperty: PartialKeyPath? { get } +} + +extension FormNavigatable { + func nextInvalidProperty() -> PartialKeyPath? { + let mirror = Mirror(reflecting: self) + + for child in mirror.children { + guard let propertyName = child.label else { continue } + if let field = child.value as? ValidatedProtocol, case .invalid = field.status { + return Self.keyPaths[propertyName] + } + } + return nil + } +} + public protocol ValidatedProtocol { /// An error message describing why the value provided for the field did not pass validation. var errorMessage: String? { get } diff --git a/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift b/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift new file mode 100644 index 0000000..f4f36e8 --- /dev/null +++ b/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift @@ -0,0 +1,122 @@ +import Foundation + +public protocol DefaultValueProvider { + init() +} + +public protocol KeyPathListable: DefaultValueProvider { + static var allKeyPaths: [String: AnyKeyPath] { get } + static var allProperties: [String] { get } +// static var reflectionCache: Mirror { get } +} + +extension KeyPathListable { + private static var _membersToKeyPaths: [String: AnyKeyPath]? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.membersToKeyPaths) as? [String: AnyKeyPath] + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.membersToKeyPaths, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + private subscript(checkedMirrorDescendant key: String) -> Any? { + return Mirror(reflecting: self).descendant(key) + } + + static var allKeyPaths: [String: AnyKeyPath] { + if _membersToKeyPaths == nil { + var keyPaths = [String: PartialKeyPath]() + + let mirror = Mirror(reflecting: Self()) + + for case let (key?, _) in mirror.children { + keyPaths[key] = \Self.[checkedMirrorDescendant: key] as PartialKeyPath + } + + _membersToKeyPaths = keyPaths + } + + return _membersToKeyPaths ?? [:] + } + + private static var _allProperties: [String]? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.allProperties) as? [String] + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.allProperties, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } + + static var allProperties: [String] { + if _allProperties == nil { + _allProperties = [String]() + + let mirror = Mirror(reflecting: Self()) + + for case let (key?, _) in mirror.children { + _allProperties!.append(key) + } + } + + return _allProperties! + } + + private var _reflectionCache: Mirror? { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.reflectionCache) as? Mirror + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.reflectionCache, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + mutating func reflectionCache() -> Mirror { + if let cache = _reflectionCache { + return cache + } else { + let mirror = Mirror(reflecting: self) + self._reflectionCache = mirror + + return mirror + } + } +} + +private enum AssociatedKeys { + static var membersToKeyPaths: UInt8 = 0 + static var allProperties: UInt8 = 0 + static var reflectionCache: UInt8 = 0 +} + +// public protocol KeyPathListable { +// var allKeyPaths: [String: PartialKeyPath] { get } +// } +// +// extension KeyPathListable { +// +// private subscript(checkedMirrorDescendant key: String) -> Any { +// return Mirror(reflecting: self).descendant(key)! +// } +// +// var allKeyPaths: [String: PartialKeyPath] { +// var membersTokeyPaths = [String: PartialKeyPath]() +// let mirror = Mirror(reflecting: self) +// for case (let key?, _) in mirror.children { +// membersTokeyPaths[key] = \Self.[checkedMirrorDescendant: key] as PartialKeyPath +// } +// return membersTokeyPaths +// } +// +// } diff --git a/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift index d6bad64..b9b14ba 100644 --- a/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift +++ b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift @@ -98,7 +98,7 @@ public struct FormFieldValidated: ValidatedProtocol { self.value = wrappedValue self.validation = validation - let validationResult = validation(self.value) + let validationResult = validation(wrappedValue) self.status = Self.calculateStatus( requirement: requirement, value: wrappedValue, @@ -107,7 +107,7 @@ public struct FormFieldValidated: ValidatedProtocol { } public mutating func reset() { - self.value = self.originalValue + self.wrappedValue = self.originalValue } } From c962cb973cbb73382d565dfa90a6929be4970214 Mon Sep 17 00:00:00 2001 From: John DeLong Date: Mon, 20 Jan 2025 14:50:59 -0500 Subject: [PATCH 3/3] Clean up form validator --- .../FormValidation/FormExample.swift | 24 ++-- .../FormValidation/FormValidatable.swift | 68 +++------- .../FormValidation/KeyPathListable.swift | 122 ------------------ .../ValidatedPropertyWrapper.swift | 8 +- Sources/MSLSwiftUI/README.md | 1 + .../documentation/form_validation.md | 95 ++++++++++++++ 6 files changed, 131 insertions(+), 187 deletions(-) delete mode 100644 Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift create mode 100644 Sources/MSLSwiftUI/documentation/form_validation.md diff --git a/Sources/MSLSwiftUI/FormValidation/FormExample.swift b/Sources/MSLSwiftUI/FormValidation/FormExample.swift index 74cec56..1830953 100644 --- a/Sources/MSLSwiftUI/FormValidation/FormExample.swift +++ b/Sources/MSLSwiftUI/FormValidation/FormExample.swift @@ -8,14 +8,14 @@ // FormValidation does not handle text formatting. Any text formatting should be // handled by the view displaying the value from the form object. -// Ideas: +// Additional Ideas: // - Get "next" required/invalid field // - Restrict value changes if validation fails // - Dirty / Clean management import SwiftUI -struct UserFormInfo: FormValidatable { +struct UserFormInfo: FormValidatable, DefaultValueProvider { @FormFieldValidated( requirement: .required(nil), validation: { $0.count < 3 ? "Name must be at least 3 characters" : nil } @@ -39,10 +39,6 @@ struct UserFormInfo: FormValidatable { var dateOfBirth = Date() var donation: Double? = nil - - mutating func reset() { - self._name.reset() - } } extension String { @@ -59,6 +55,7 @@ extension String { } } +@available(iOS 15.0, *) struct ExampleForm: View { @State private var userInfo = UserFormInfo() @@ -66,14 +63,16 @@ struct ExampleForm: View { @State private var isSubmitted = false - @State + @FocusState private var fieldToFocus: String? + var body: some View { VStack { Form { Section(header: Text("Personal Information")) { TextField("Name", text: self.$userInfo.name) + .focused(self.$fieldToFocus, equals: "_name") .overlay( ValidationErrorView(errorMessage: self.userInfo.$name.errorMessage), alignment: .bottomLeading @@ -104,12 +103,11 @@ struct ExampleForm: View { .disabled(!self.userInfo.isValid()) Button("Jump To Field") { -// self.fieldToFocus = self.userInfo.getNextInvalidProperty() + self.fieldToFocus = self.userInfo.nextInvalidProperty() } Button("Reset") { self.userInfo.reset() -// self.userInfo.reset() } if let field = self.fieldToFocus { @@ -122,6 +120,8 @@ struct ExampleForm: View { if self.isSubmitted { Text("Submitted!") + + } } } @@ -142,5 +142,9 @@ struct ValidationErrorView: View { } #Preview { - ExampleForm() + if #available(iOS 15.0, *) { + ExampleForm() + } else { + // Fallback on earlier versions + } } diff --git a/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift index e9246b8..89eb2a7 100644 --- a/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift +++ b/Sources/MSLSwiftUI/FormValidation/FormValidatable.swift @@ -1,16 +1,20 @@ import SwiftUI -public protocol FormValidatable: KeyPathListable { - mutating func isValid() -> Bool +public protocol DefaultValueProvider { + init() +} + +public protocol FormValidatable { + func isValid() -> Bool - mutating func hasChanges() -> Bool + func hasChanges() -> Bool - mutating func nextInvalidProperty() -> String? + func nextInvalidProperty() -> String? } public extension FormValidatable { - mutating func isValid() -> Bool { - let mirror = self.reflectionCache() + func isValid() -> Bool { + let mirror = Mirror(reflecting: self) for child in mirror.children { if let field = child.value as? ValidatedProtocol { if case .invalid = field.status { @@ -21,17 +25,8 @@ public extension FormValidatable { return true } - mutating func hasChanges() -> Bool { -// for keyPath in Self.allKeyPaths.values { -// if let field = self[keyPath: keyPath] as? ValidatedProtocol { -// if field.editState == .dirty { -// return true -// } -// } -// print("test") -// } - - let mirror = self.reflectionCache() + func hasChanges() -> Bool { + let mirror = Mirror(reflecting: self) for child in mirror.children { if let field = child.value as? ValidatedProtocol { if field.editState == .dirty { @@ -42,8 +37,8 @@ public extension FormValidatable { return false } - mutating func nextInvalidProperty() -> String? { - let mirror = self.reflectionCache() + func nextInvalidProperty() -> String? { + let mirror = Mirror(reflecting: self) for child in mirror.children { guard let propertyName = child.label else { continue } @@ -55,40 +50,11 @@ public extension FormValidatable { } return nil } - - // May need to be a class -// mutating func reset() { -// let mirror = Mirror(reflecting: self) -// for var child in mirror.children { -// if var field = child.value as? ValidatedProtocol { -// field.reset() -// -// // Update the property value -// if let propertyName = child.label { -// let keyPath = \Self.[propertyName] -// self[keyPath: keyPath] = field as! Self.[propertyName] -// } -// } -// } -// } -} - -public protocol FormNavigatable { - static var keyPaths: [String: PartialKeyPath] { get } - var nextInvalidProperty: PartialKeyPath? { get } } -extension FormNavigatable { - func nextInvalidProperty() -> PartialKeyPath? { - let mirror = Mirror(reflecting: self) - - for child in mirror.children { - guard let propertyName = child.label else { continue } - if let field = child.value as? ValidatedProtocol, case .invalid = field.status { - return Self.keyPaths[propertyName] - } - } - return nil +public extension FormValidatable where Self: DefaultValueProvider { + mutating func reset() { + self = Self() } } diff --git a/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift b/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift deleted file mode 100644 index f4f36e8..0000000 --- a/Sources/MSLSwiftUI/FormValidation/KeyPathListable.swift +++ /dev/null @@ -1,122 +0,0 @@ -import Foundation - -public protocol DefaultValueProvider { - init() -} - -public protocol KeyPathListable: DefaultValueProvider { - static var allKeyPaths: [String: AnyKeyPath] { get } - static var allProperties: [String] { get } -// static var reflectionCache: Mirror { get } -} - -extension KeyPathListable { - private static var _membersToKeyPaths: [String: AnyKeyPath]? { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.membersToKeyPaths) as? [String: AnyKeyPath] - } - set { - objc_setAssociatedObject( - self, - &AssociatedKeys.membersToKeyPaths, - newValue, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - private subscript(checkedMirrorDescendant key: String) -> Any? { - return Mirror(reflecting: self).descendant(key) - } - - static var allKeyPaths: [String: AnyKeyPath] { - if _membersToKeyPaths == nil { - var keyPaths = [String: PartialKeyPath]() - - let mirror = Mirror(reflecting: Self()) - - for case let (key?, _) in mirror.children { - keyPaths[key] = \Self.[checkedMirrorDescendant: key] as PartialKeyPath - } - - _membersToKeyPaths = keyPaths - } - - return _membersToKeyPaths ?? [:] - } - - private static var _allProperties: [String]? { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.allProperties) as? [String] - } - set { - objc_setAssociatedObject(self, &AssociatedKeys.allProperties, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - } - - static var allProperties: [String] { - if _allProperties == nil { - _allProperties = [String]() - - let mirror = Mirror(reflecting: Self()) - - for case let (key?, _) in mirror.children { - _allProperties!.append(key) - } - } - - return _allProperties! - } - - private var _reflectionCache: Mirror? { - get { - return objc_getAssociatedObject(self, &AssociatedKeys.reflectionCache) as? Mirror - } - set { - objc_setAssociatedObject( - self, - &AssociatedKeys.reflectionCache, - newValue, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - - mutating func reflectionCache() -> Mirror { - if let cache = _reflectionCache { - return cache - } else { - let mirror = Mirror(reflecting: self) - self._reflectionCache = mirror - - return mirror - } - } -} - -private enum AssociatedKeys { - static var membersToKeyPaths: UInt8 = 0 - static var allProperties: UInt8 = 0 - static var reflectionCache: UInt8 = 0 -} - -// public protocol KeyPathListable { -// var allKeyPaths: [String: PartialKeyPath] { get } -// } -// -// extension KeyPathListable { -// -// private subscript(checkedMirrorDescendant key: String) -> Any { -// return Mirror(reflecting: self).descendant(key)! -// } -// -// var allKeyPaths: [String: PartialKeyPath] { -// var membersTokeyPaths = [String: PartialKeyPath]() -// let mirror = Mirror(reflecting: self) -// for case (let key?, _) in mirror.children { -// membersTokeyPaths[key] = \Self.[checkedMirrorDescendant: key] as PartialKeyPath -// } -// return membersTokeyPaths -// } -// -// } diff --git a/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift index b9b14ba..d483924 100644 --- a/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift +++ b/Sources/MSLSwiftUI/FormValidation/ValidatedPropertyWrapper.swift @@ -58,6 +58,10 @@ public struct FormFieldValidated: ValidatedProtocol { } } + public mutating func reset() { + self.wrappedValue = self.originalValue + } + public var wrappedValue: Value { get { self.value } set { @@ -105,10 +109,6 @@ public struct FormFieldValidated: ValidatedProtocol { validationResult: validationResult ) } - - public mutating func reset() { - self.wrappedValue = self.originalValue - } } // MARK: Helper functions diff --git a/Sources/MSLSwiftUI/README.md b/Sources/MSLSwiftUI/README.md index a2594c5..403d907 100644 --- a/Sources/MSLSwiftUI/README.md +++ b/Sources/MSLSwiftUI/README.md @@ -5,6 +5,7 @@ MSL SwiftUI provides common helper functions to work with [SwiftUI.](https://dev * [Installation](#installation) ## Features +* [x] [FormValidation](./documentation/form_validation.md) ## Installation diff --git a/Sources/MSLSwiftUI/documentation/form_validation.md b/Sources/MSLSwiftUI/documentation/form_validation.md new file mode 100644 index 0000000..a26d4d3 --- /dev/null +++ b/Sources/MSLSwiftUI/documentation/form_validation.md @@ -0,0 +1,95 @@ +# Form Validation + +## Features +* Validate forms quickly and easily through the use of property wrappers +* Mark fields as required / options +* Add custom validation logic for each field +* Add custom error / validation messages +* Know if a field is `pristine` or `dirty`. + +## How to Use +The following steps describe how to quickly setup a form and validation for a SwiftUI view. +An example implementation has also been created in the `FormExample.swift` file. + +### Step 1: Create a form backing struct + +```swift +struct UserFormInfo: FormValidatable { + var name: String = "" + var phoneNumber: String? = nil + var email: String = "" +} +``` + +### Step 2: Add `FormValidatable` conformance + +```swift +struct UserFormInfo: FormValidatable { + // ... +} +``` + +### Step 3: Add property wrappers + +```swift +struct UserFormInfo: FormValidatable { + @FormFieldValidated( + requirement: .required(nil), + validation: { $0.count < 3 ? "Name must be at least 3 characters" : nil } + ) + var name = "" + + @FormFieldValidated( + requirement: .required("This field is required"), + validation: { value in + !(value?.isPhoneNumberValid() ?? false) ? "Phone number is not valid." : nil + } + ) + var phoneNumber: String? = nil + + @FormFieldValidated(validation: { !$0.isEmailValid() ? "Invalid email address" : nil }) + var email = "" +} +``` + +### Step 4: Connect to your UI + +```swift +struct ExampleForm: View { + @State + private var userInfo = UserFormInfo() + + var body: some View { + VStack { + Form { + Section(header: Text("Personal Information")) { + TextField("Name", text: self.$userInfo.name) + .overlay( + ValidationErrorView(errorMessage: self.userInfo.$name.errorMessage), + alignment: .bottomLeading + ) + + TextField("Phone Number", text: self.$userInfo.phoneNumber ?? "") + .keyboardType(.phonePad) + .overlay( + ValidationErrorView(errorMessage: self.userInfo.$phoneNumber.errorMessage), + alignment: .bottomLeading + ) + + TextField("Email", text: self.$userInfo.email) + .keyboardType(.emailAddress) + .overlay(ValidationErrorView(errorMessage: self.userInfo.$email.errorMessage)) + } + } + + Button("Submit") { + // Handle submit action + } + .disabled(!self.userInfo.isValid()) + + Button("Reset") { + self.userInfo = UserFormInfo() + } + } + } +}