Andrew Lipscomb
Andrew Lipscomb

Reputation: 1058

SwiftUI validation and vetoing for user input

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:

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;

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

Answers (1)

heckj
heckj

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

Related Questions