Skip to content

Commit

Permalink
Dramatically simplfy validation code by not relying on focus state type
Browse files Browse the repository at this point in the history
  • Loading branch information
Supereg committed Nov 4, 2023
1 parent e5d82d2 commit 3e76afb
Show file tree
Hide file tree
Showing 19 changed files with 306 additions and 191 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
import SwiftUI


/// The debounce duration for Validation Engine
struct ValidationDebounceDurationKey: EnvironmentKey {
static let defaultValue: Duration = .seconds(0.5)
}


extension EnvironmentValues {
/// The configurable debounce duration for input submission.
///
/// Having a debounce like this, ensures that validation error messages don't get into the way when a user
/// is actively typing into a text field.
/// This duration is used to debounce repeated calls to ``ValidationEngine/submit(input:debounce:)`` where `debounce` is set to `true`.
public var validationDebounce: Duration {
get {
self[ValidationDebounceDurationKey.self]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import SwiftUI

extension ValidationEngine {
/// The configuration of a ``ValidationEngine``.
public struct Configuration: OptionSet, EnvironmentKey, Equatable {
public struct Configuration: OptionSet, EnvironmentKey, Equatable { // TODO do we have to test those?
/// This configuration controls the behavior of the ``ValidationEngine/displayedValidationResults`` property.
///
/// If ``ValidationEngine/submit(input:debounce:)`` is called with empty input and this option is set, then the
Expand Down Expand Up @@ -40,7 +40,9 @@ extension ValidationEngine {


extension EnvironmentValues {
/// Access the ``ValidationEngine/Configuration-swift.struct`` from the environment.
/// Access the ``ValidationEngine/Configuration-swift.struct`` of a ValidationEngine through the environment.
///
/// - Note: Supplying a value into the environment is always an additive change!
public var validationConfiguration: ValidationEngine.Configuration {
get {
self[ValidationEngine.Configuration.self]
Expand Down
78 changes: 78 additions & 0 deletions Sources/SpeziValidation/FocusStateTest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// SwiftUIView.swift
//
//
// Created by Andreas Bauer on 03.11.23.
//

import SwiftUI
/*
struct ChildView: View {
@State var text: String = ""
@State var isInFocus: Bool = false
@FocusState var focusState: String?

var body: some View {
TextField("Input A", text: text)
}
}

struct FooView: View {
var body: some View {
ChildView()
}
}
*/
struct ChildView: View {
private let identifier: String

@State var text: String = ""
@FocusState var isInFocus: Bool
@FocusState.Binding var focusState: String?

var body: some View {
Text("Has Focus: \(isInFocus ? "Yes": "No")")
if let focusState {
Text("Focus State: \(focusState)")
}
TextField("Input A", text: $text)
.focused($focusState, equals: identifier)
.focused($isInFocus)
}


init(id: String, focus: FocusState<String?>.Binding) {
self.identifier = id
self._focusState = focus
}
}


struct FocusStateTest: View {
@FocusState private var state: String?

var body: some View {
List {
Section {
ChildView(id: "A", focus: $state)
}

Section {
ChildView(id: "B", focus: $state)
}

Section("Set Focus") {
Button("A") {
state = "A"
}
Button("B") {
state = "B"
}
}
}
}
}

#Preview {
FocusStateTest()
}
18 changes: 18 additions & 0 deletions Sources/SpeziValidation/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
{
"sourceLanguage" : "en",
"strings" : {
"A" : {

},
"B" : {

},
"Focus State: %@" : {

},
"Has Focus: %@" : {

},
"Input A" : {

},
"Set Focus" : {

},
"VALIDATION_RULE_MINIMAL_EMAIL" : {
"localizations" : {
"de" : {
Expand Down
5 changes: 5 additions & 0 deletions Sources/SpeziValidation/ValidationEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,15 @@ public class ValidationEngine: Identifiable {
}

/// Access the configuration of the validation engine.
///
/// You may use the ``SwiftUI/EnvironmentValues/validationConfiguration`` environment key to configure this value from
/// the environment.
public var configuration: Configuration
/// The configurable debounce duration for input submission.
///
/// This duration is used to debounce repeated calls to ``submit(input:debounce:)`` where `debounce` is set to `true`.
/// You may use the ``SwiftUI/EnvironmentValues/validationDebounce`` environment key to configure this value from
/// the environment.
public var debounceDuration: Duration

private var debounceTask: Task<Void, Never>? {
Expand Down
17 changes: 10 additions & 7 deletions Sources/SpeziValidation/ValidationModifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,27 @@
import SwiftUI


struct ValidationModifier<FocusValue: Hashable>: ViewModifier {
struct ValidationModifier: ViewModifier {
private let input: String
private let fieldIdentifier: FocusValue?

@Environment(\.validationConfiguration) private var configuration
@Environment(\.validationDebounce) private var debounce

@State private var validation: ValidationEngine
@FocusState private var hasFocus: Bool

init(input: String, field fieldIdentifier: FocusValue?, rules: [ValidationRule]) {
init(input: String, rules: [ValidationRule]) {
self.input = input
self.fieldIdentifier = fieldIdentifier
self._validation = State(wrappedValue: ValidationEngine(rules: rules))
}

func body(content: Content) -> some View {
content
.environment(validation)
.focused($hasFocus)
.preference(
key: CapturedValidationStateKey<FocusValue>.self,
value: [CapturedValidationState(engine: validation, input: input, field: fieldIdentifier)]
key: CapturedValidationStateKey.self,
value: [CapturedValidationState(engine: validation, input: input, focus: $hasFocus)]
)
.onChange(of: configuration, initial: true) {
validation.configuration = configuration
Expand Down Expand Up @@ -60,7 +60,7 @@ extension View {
/// - rules: An array of ``ValidationRule``s.
/// - Returns: The modified view.
public func validate(input value: String, rules: [ValidationRule]) -> some View {
modifier(ValidationModifier<Never>(input: value, field: nil, rules: rules))
modifier(ValidationModifier(input: value, rules: rules))
}

/// Validate an input against a set of validation rules.
Expand All @@ -78,6 +78,8 @@ extension View {
validate(input: value, rules: rules)
}

/*
TODO: remove?
/// Validate an input against a set of validation rules with automatic focus management.
///
/// This modifier can be used to validate a `String` input against a set of ``ValidationRule``s.
Expand Down Expand Up @@ -117,4 +119,5 @@ extension View {
) -> some View {
validate(input: value, field: fieldIdentifier, rules: rules)
}
*/
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,37 @@
import SwiftUI


/// A momentary snapshot of the current validation state of a view.
///
/// This structure provides context to a particular ``ValidationEngine`` instance by capturing it's input
/// and optionally a [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState) value.
///
/// This particularly allows to run a validation from the outside of a view.
@dynamicMemberLookup
public struct CapturedValidationState<FocusValue> {
public struct CapturedValidationState {
private let engine: ValidationEngine
private let input: String
let fieldIdentifier: FocusValue?
private let focusState: FocusState<Bool>.Binding

init(engine: ValidationEngine, input: String, field fieldIdentifier: FocusValue?) {
init(engine: ValidationEngine, input: String, focus focusState: FocusState<Bool>.Binding) {
self.engine = engine
self.input = input
self.fieldIdentifier = fieldIdentifier
self.focusState = focusState
}

/// Moves focus to this field.
func moveFocus() {
focusState.wrappedValue = true
}

/// Execute the validation engine for the current state of the captured view.
@MainActor public func runValidation() {
engine.runValidation(input: input)
}

/// Access properties of the underlying ``ValidationEngine``.
/// - Parameter keyPath: The key path into the validation engine.
/// - Returns: The value of the property.
public subscript<Value>(dynamicMember keyPath: KeyPath<ValidationEngine, Value>) -> Value {
engine[keyPath: keyPath]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import Foundation


/// Represents the result of a ``ValidationRule``.
/// A failed validation result of a ``ValidationRule`` for a particular input.
///
/// For more information see ``ValidationRule/validate(_:)``.
public struct FailedValidationResult: Identifiable, Equatable, CustomLocalizedStringResourceConvertible {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,75 @@
import SwiftUI


struct CapturedValidationStateKey<FocusValue>: PreferenceKey {
static var defaultValue: [CapturedValidationState<FocusValue>] {
/// Provide access to validation state to the parent view.
///
/// The internal preference key to provide parent views access to all configured ``ValidationEngine`` and input
/// state by capturing it into a ``CapturedValidationState``.
struct CapturedValidationStateKey: PreferenceKey {
static var defaultValue: [CapturedValidationState] {
[]
}

static func reduce(value: inout [CapturedValidationState<FocusValue>], nextValue: () -> [CapturedValidationState<FocusValue>]) {
static func reduce(value: inout [CapturedValidationState], nextValue: () -> [CapturedValidationState]) {
value.append(contentsOf: nextValue())
}
}


extension View {
public func receiveValidation(in state: ValidationState<Never>.Binding) -> some View {
onPreferenceChange(CapturedValidationStateKey<Never>.self) { entries in
/// Receive validation state of all subviews.
///
/// By supplying a binding to your declared ``ValidationState`` property, you can receive all changes to the
/// validation state of your child views.
///
/// - Note: This version of the modifier uses a [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState)
/// value of `Never`. Meaning, it will only capture validation modifier that do not specify a focus value.
///
/// - Parameter state: The binding to the ``ValidationState``.
/// - Returns: The modified view.
public func receiveValidation(in state: ValidationState.Binding) -> some View {
onPreferenceChange(CapturedValidationStateKey.self) { entries in
state.wrappedValue = ValidationContext(entries: entries)
}
}

/*
TODO: remove?
/// Receive validation state of all subviews.
///
/// By supplying a binding to your declared ``ValidationState`` property, you can receive all changes to the
/// validation state of your child views.
///
/// - Note: While this modifier collects all validation state with the respective focus state value type, it doesn't
/// require to supply a [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState)
/// and, therefore, doesn't automatically switch focus on a failed validation.
/// For more information refer to the ``SwiftUI/View/receiveValidation(in:focus:)`` modifier.
///
/// - Parameter state: The binding to the ``ValidationState``.
/// - Returns: The modified view.
public func receiveValidation<Value>(in state: ValidationState<Value>.Binding) -> some View {
onPreferenceChange(CapturedValidationStateKey<Value>.self) { entries in
state.wrappedValue = ValidationContext(entries: entries)
}
}

*/
/*
/// Receive validation state of all subviews.
///
/// By supplying a binding to your declared ``ValidationState`` property, you can receive all changes to the
/// validation state of your child views.
///
/// This modifier uses the supplied [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState)
/// binding to automatically set focus to the first field that failed validation, once you manually
/// call ``ValidationContext/validateSubviews(switchFocus:)`` on your validation state property.
///
/// - Parameters:
/// - state: The binding to the ``ValidationState``.
/// - focus: A [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState) binding that will
/// be used to automatically set focus to the first field that failed validation.
/// - Returns: The modified view.
public func receiveValidation<Value>(in state: ValidationState<Value>.Binding, focus: FocusState<Value?>.Binding) -> some View {
onPreferenceChange(CapturedValidationStateKey<Value>.self) { entries in
state.wrappedValue = ValidationContext(entries: entries, focus: focus)
}
}
}*/
}
Loading

0 comments on commit 3e76afb

Please sign in to comment.