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:
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.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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 = ""
}
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 = ""
}
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.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
@ObservedObject var model = Model()
@State var isSaveDisabled = true
@ObservedObject var model = Model()
@State var isSaveDisabled = true
@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.
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
enum Validation {
case success
case failure(message: String)
var isSuccess: Bool {
if case .success = self {
returntrue
}
returnfalse
}
}
enum Validation {
case success
case failure(message: String)
var isSuccess: Bool {
if case .success = self {
return true
}
return false
}
}
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:
return date > after && date < before ? .success : .failure(message: errorMessage())
}.eraseToAnyPublisher()
}
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()
}
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:
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.")
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:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
extension Published.Publisher where Value == String {
Now creating a validation publisher looks like this:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
model.$firstName.nonEmptyValidator("First name must be provided.")
model.$firstName.nonEmptyValidator("First name must be provided.")
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 ?
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
struct ValidationModifier: ViewModifier {
@State var latestValidation: Validation = .success
let validationPublisher: ValidationPublisher
func body(content: Content) -> some View {
returnVStack(alignment: .leading){
content
validationMessage
}.onReceive(validationPublisher){ validation in
self.latestValidation = validation
}
}
var validationMessage: some View {
switch latestValidation {
case .success:
returnAnyView(EmptyView())
case .failure(let message):
let text = Text(message)
.foregroundColor(Color.red)
.font(.caption)
returnAnyView(text)
}
}
}
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)
}
}
}
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:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
extension View {
func validation(_ validationPublisher: ValidationPublisher) -> some View {
Validation publishers need to be stored somewhere. Why do they need to be stored? Because if we create them inline like so:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
TextField("First Name", text: $model.firstName)
.validation(model.$firstName.nonEmptyValidator("First name must be provided."))
TextField("First Name", text: $model.firstName)
.validation(model.$firstName.nonEmptyValidator("First name must be provided."))
TextField("First Name", text: $model.firstName)
.validation(model.$firstName.nonEmptyValidator("First name must be provided."))
The publisher will be recreated when $model.firstName is updated, meaning any validation publishers will be deallocated before they receive a message.
The code isn’t very pretty!
For convenience they can be added to the model:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
// 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")
}()
// 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")
}()
// 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:
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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)
Awesome! Thanks for sharing.