3

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:

  • Have a generic control (lets say for instance a TextField)
  • Apply a validation/veto on the update of that control (for instance, a user types text)
  • Propagate the intended change into a validator, updating a Binding source object somewhere (ideally, an @State member inside the View)
  • Feeding that value back into the control for display

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;

  • The cursor state still moves on the text control past the point of validation
  • It exposes what should be entirely internal state and requires passing that down from parents or via the EnvironmentObject (if you are doing this with Lists 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.

Andrew Lipscomb
  • 936
  • 8
  • 19

1 Answers1

0

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)
    }
}
heckj
  • 7,136
  • 3
  • 39
  • 50