Reputation: 1058
I'm looking to implement a generic validation/vetoing loop in SwiftUI - the sort of thing that should be pretty straightforward to do with a "single source of truth" framework
In short I want to:
TextField
)Binding
source object somewhere (ideally, an @State
member inside the View
) It seems that for all the "single source of truth" talk Apple is kind of lying - injecting a validation stage into this chain seems difficult, especially without breaking encapsulation of the view
Note that I don't really want to solve this problem in particular - I'm looking for a pattern to implement (ie: replace the String
and TextField
with Bool
and Toggle
for example)
The following code shows my best attempt at doing the above loop
class ValidatedValue<T>: ObservableObject {
let objectWillChange = ObservableObjectPublisher()
var validator: (T, T)->T
var value: T {
get {
_value
}
set {
_value = validator(_value, newValue)
objectWillChange.send()
}
}
/// Backing value for the observable
var _value: T
init(_ value: T, validator: @escaping (T, T)->T) {
self._value = value
self.validator = validator
}
}
struct MustHaveDTextField: View {
@ObservedObject var editingValue: ValidatedValue<String>
public var body: some View {
return TextField(
"Must have a d",
text: $editingValue.value
}
}
With the validated value defined well outside the scope of the View
ValidatedValue(
"oddity has a d",
validator: { current, new in
if new.contains("d") {
return new
}
else {
return current
}
}
)
This kind of works as it will prevent you from modifying the string input if it contains no "d"s. However;
EnvironmentObject
(if you are doing this with List
s of things...ow) Either I'm missing something key, or I'm taking the wrong approach, or what Apple says is not what Apple does.
Modifying internal state during the loop like what is done here or here is not good - they modify state inside the view loop which XCode flags as undefined behaviour
. This one also has a similar solution but again suffers from needing to put the validation logic outside the view - IMHO it should be self-contained.
Upvotes: 3
Views: 1869
Reputation: 7367
I won't claim it's the one-correct-way, but a way I've handled this is by expressing result of processed validation rules (in my case encoded within a combine pipeline) as result properties:
validationMessages: String[]
isEverythingOK: Boolean
I hooked up the validation by exposing @Published
properties for the field inputs available to the user, and on the model I paired each with a combine Subject, sending updates using the didSet{}
closure on the property. The rules for the validatation are all then included within the a Combine pipline on the model, and only the results are exposed.
There's a bit of sample code for how I handled it within Using Combine, and available on Github at ReactiveForm.swift and ReactiveFormModel.swift
I'll try to include the relevant bits here for example. Note that in the example, I'm intentionally exposing a publisher for the SwiftUI view, but really only to show that it's possible - not that it's a way to solve this particular solution.
In practice, I found formalizing or knowing exactly what you want displayed when the form isn't validated made a huge impact on how I developed the solution to it.
import Foundation
import Combine
class ReactiveFormModel : ObservableObject {
@Published var firstEntry: String = "" {
didSet {
firstEntryPublisher.send(self.firstEntry)
}
}
private let firstEntryPublisher = CurrentValueSubject<String, Never>("")
@Published var secondEntry: String = "" {
didSet {
secondEntryPublisher.send(self.secondEntry)
}
}
private let secondEntryPublisher = CurrentValueSubject<String, Never>("")
@Published var validationMessages = [String]()
private var cancellableSet: Set<AnyCancellable> = []
var submitAllowed: AnyPublisher<Bool, Never>
init() {
let validationPipeline = Publishers.CombineLatest(firstEntryPublisher, secondEntryPublisher)
.map { (arg) -> [String] in
var diagMsgs = [String]()
let (value, value_repeat) = arg
if !(value_repeat == value) {
diagMsgs.append("Values for fields must match.")
}
if (value.count < 5 || value_repeat.count < 5) {
diagMsgs.append("Please enter values of at least 5 characters.")
}
return diagMsgs
}
submitAllowed = validationPipeline
.map { stringArray in
return stringArray.count < 1
}
.eraseToAnyPublisher()
let _ = validationPipeline
.assign(to: \.validationMessages, on: self)
.store(in: &cancellableSet)
}
}
Upvotes: 0