Validation with SwiftUI & Combine – Part 1

SwiftUI lets us build beautiful and conventional user interfaces, and Combine lets us bring those interfaces to life by allowing data inside and outside of our app to flow seamlessly as the user interacts with it.

Combine also empowers us to create simple and reusable validation functionality.

The above is a form created using SwiftUI. Below is it’s code:

NavigationView {
    Form {
        Section(header: Text("Name")) {
            TextField("First Name", text: $model.firstName)
            TextField("Middle Names", text: $model.middleNames)
            TextField("Last Name", text: $model.lastNames)
        }
        Section(header: Text("Personal Information")) {
            DatePicker(selection: $model.birthday,
                       displayedComponents: [.date],
                       label: { Text("Birthday") })
            Picker("Gender", selection: $model.gender) {
                ForEach(Model.genders, id: .self) { value in
                    Text(value)
                }
            }
        }
        Section(header: Text("Address")) {
            TextField("Street Number or Name", text: $model.addressHouseNumberOrName)
            TextField("First Line", text: $model.addressFirstLine)
            TextField("Second Line", text: $model.addressSecondLine)
            TextField("Postcode", text: $model.addressPostcode)
            TextField("Country", text: $model.addressCountry)
        }
        Button(action: {}, label: {
            HStack {
                Text("Submit")
                Spacer()
                Image(systemName: "checkmark.circle.fill")
            }
        })
        .disabled(self.isSaveDisabled)
    }
    .navigationBarTitle("Form")
} 

The form’s fields are bound to the properties of a flat observable model. Note that all of the bound properties are annotated with @Published, this will be important later.

class Model: ObservableObject {

    @Published var firstName: String = ""

    @Published var middleNames: String = ""

    @Published var lastNames: String = ""

    @Published var birthday: Date = Date()

    @Published var addressHouseNumberOrName: String = ""

    @Published var addressFirstLine: String = ""

    @Published var addressSecondLine: String = ""

    @Published var addressPostcode: String = ""

    @Published var addressCountry: String = ""
}

So far, so vanilla! Back in our view we have an instance of the model and a single boolean @State variable that indicates whether the ‘Submit’ button is disabled.

@ObservedObject var model = Model()

@State var isSaveDisabled = true

We now have a perfectly functional form … Except that we can’t press the save button! It’s time to add some validation.

Boilerplate

Typically, the validity of a field is defined as one of several states, so we need a value type to indicate what state our field is in. An enum is well suited for this. The failure state also has an associated value containing an error message.

enum Validation {
    case success
    case failure(message: String)

    var isSuccess: Bool {
        if case .success = self {
            return true
        }
        return false
    }
}

The Good Stuff

The beauty of published properties is that they contain a publisher that receives a message every time the property changes – perfect for triggering our validation logic.

This logic is performed via Combine operators that takes the publisher of one of our properties and maps it to a validation state.

For example, this one validates whether a string is non-empty:

static func nonEmptyValidation(for publisher: Published<String>.Publisher,
                                   errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
    return publisher.map { value in
        guard value.count > 0 else {
            return .failure(message: errorMessage())
        }
        return .success
    }
    .dropFirst()
    .eraseToAnyPublisher()
}

This one matches a string against a Regex (using this awesome Regex library):

static func matcherValidation(for publisher: Published<String>.Publisher,
                                  withPattern pattern: Regex,
                                  errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
    return publisher.map { value in
        guard pattern.matches(value) else {
            return .failure(message: errorMessage())
        }
        return .success
    }
    .dropFirst()
    .eraseToAnyPublisher()
}

Finally, this one validates whether a given date is before a date and/or after a date:

static func dateValidation(for publisher: Published<Date>.Publisher,
                               afterDate after: Date = .distantPast,
                               beforeDate before: Date = .distantFuture,
                               errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
    return publisher.map { date in
        return date > after && date < before ? .success : .failure(message: errorMessage())
    }.eraseToAnyPublisher()
} 

There are also a couple of type aliases in the above (all of which is contained in a ValidationPublishers class) that make things a little easier to read:

typealias ValidationErrorClosure = () -> String

typealias ValidationPublisher = AnyPublisher<Validation, Never>

All of these publishers follow a similar pattern:

  • First, they map the value to a validation state using a provided closure of the form (ValueType) β†’ Validation.
    • This is where our actual validation logic goes.
    • Note that the errorMessage provided uses @autoclosure so that our message can include interpolated values at the point at which validation occurs.
  • Second, they drop the first message.
    • This is so that the initial message, sent on initialisation of the property, is not received by our UI. If it was, we would likely get validation errors before the user has even interacted with the form!
  • Finally, we erase the publisher type to make the return type simpler.

Sugar

Creating a validation publisher will now look something like this:

ValidationPublisher.nonEmptyValidator(for: model.$firstName, 
                                      errorMessage: "First name must be provided.") 

This feels a little clunky. As validation publishers are essentially operators for the publishers of published properties, we can refine this with a few extensions:

extension Published.Publisher where Value == String {

    func nonEmptyValidator(_ errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
        return ValidationPublishers.nonEmptyValidation(for: self, errorMessage: errorMessage())
    }

    func matcherValidation(_ pattern: String, _ errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
        return ValidationPublishers.matcherValidation(for: self, withPattern: pattern.r!, errorMessage: errorMessage())
    }

}

extension Published.Publisher where Value == Date {
     func dateValidation(afterDate after: Date = .distantPast,
                         beforeDate before: Date = .distantFuture,
                         errorMessage: @autoclosure @escaping ValidationErrorClosure) -> ValidationPublisher {
        return ValidationPublishers.dateValidation(for: self, afterDate: after, beforeDate: before, errorMessage: errorMessage())
    }
}

Now creating a validation publisher looks like this:

model.$firstName.nonEmptyValidator("First name must be provided.")

Much nicer! πŸŽ‰

Updating the Form

The best way to react to changes in validation state is to add an onReceive to each of our fields and then set an error message field that will be optionally displayed in our UI.

However, this doesn’t scale well. It would require an onReceive method on every field that requires validation, and if multiple fields contain invalid inputs, we also need to show all of these messages.

We could use an array of strings or a dictionary of validations, and then manage this as each validation occurs … but this would stray away from our beautiful reactive code, so let’s not!

Instead we need a generic way to:

a) listen for the validation of a property on a given field

and

b) display or hide an error message depending on the state

The solution for this? ViewModifier πŸ™Œ

struct ValidationModifier: ViewModifier {

    @State var latestValidation: Validation = .success

    let validationPublisher: ValidationPublisher

    func body(content: Content) -> some View {
        return VStack(alignment: .leading) {
            content
            validationMessage
        }.onReceive(validationPublisher) { validation in
            self.latestValidation = validation
        }
    }

    var validationMessage: some View {
        switch latestValidation {
        case .success:
            return AnyView(EmptyView())
        case .failure(let message):
            let text = Text(message)
                .foregroundColor(Color.red)
                .font(.caption)
            return AnyView(text)
        }
    }
}

A ViewModifier gives two great benefits:

  • First, it provides somewhere to store our publisher and validation state
  • Second, we can play with the content of the field being validated in isolation, and prior to rendering.

ValidationModifier lets us:

  • Receive validations and assign them to the latestValidation property so that the view updates.
  • Optionally show an error message for failure states. In the case of success we provide an empty view, though we could go further and show a checkmark icon or tint the text green.

As with the validation publishers, we need a convenience method to make our UI code prettier:

extension View {

    func validation(_ validationPublisher: ValidationPublisher) -> some View {
        self.modifier(ValidationModifier(validationPublisher: validationPublisher))
    }

}

A Better Form

Validation publishers need to be stored somewhere. Why do they need to be stored? Because if we create them inline like so:

TextField("First Name", text: $model.firstName)
    .validation(model.$firstName.nonEmptyValidator("First name must be provided."))
  1. The publisher will be recreated when $model.firstName is updated, meaning any validation publishers will be deallocated before they receive a message.
  2. The code isn’t very pretty!

For convenience they can be added to the model:

// MARK: Validation Publishers

// Names

lazy var firstNameValidation: ValidationPublisher = {
    $firstName.nonEmptyValidator("First name must be provided")
}()

lazy var lastNamesValidation: ValidationPublisher = {
    $lastNames.nonEmptyValidator("Last name(s) must be provided")
}()

// Personal Information

lazy var birthdayValidation: ValidationPublisher = {
    $birthday.dateValidation(beforeDate: Date(), errorMessage: "Date must be before today")
}()

// Address

lazy var addressHouseNumberOrNameValidation: ValidationPublisher = {
    $addressHouseNumberOrName.nonEmptyValidator("House number or name must not be empty")
}()

lazy var addressFirstLineValidation: ValidationPublisher = {
    $addressFirstLine.nonEmptyValidator("House number or name must not be empty")
}()

lazy var postcodeValidation: ValidationPublisher = {
    $addressPostcode.matcherValidation(postcodeRegex, "Postcode is not valid")
}()

Then the view can be updated far more cleanly:

TextField("First Name", text: $model.firstName)
    .validation(model.firstNameValidation)
// ...
TextField("Last Name", text: $model.lastNames)
    .validation(model.lastNamesValidation)
// ...
DatePicker(selection: $model.birthday,
           displayedComponents: [.date],
           label: { Text("Birthday") })
    .validation(model.birthdayValidation)
// ...
TextField("Street Number or Name", text: $model.addressHouseNumberOrName)
    .validation(model.addressHouseNumberOrNameValidation)
// ...
TextField("First Line", text: $model.addressFirstLine)
    .validation(model.addressFirstLineValidation)
// ...
TextField("Postcode", text: $model.addressPostcode)
    .validation(model.postcodeValidation)

And the final result …

In Part 2 (coming soon!) I’ll show how validation publishers can be combined to update the state of the ‘Submit’ button.

Or if you can’t wait until then, all of the code is here. πŸ‘‹

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.