Swift/UnwrapDiscover about Swift, iOS and architecture patterns

Making a form

July 26, 2022

Suppose we want to create a contact from our app. To do so we introduce a form that the user can fill out in order to generate a contact.

Traditional way for building the form would be throwing all fields in the view along their bindings in the object handling the view logic (like a ViewModel):

class ContactViewModel: ObservableObject {

  @Published var firstName = ""
  @Published var lastName = ""

  @Published var error: Error?
  @Published var contact: Contact?

  func save() {
      guard !firstName.isEmpty, !lastName.isEmpty else {
        error = ContactError.missingMandatoryFields
      }

      contact = Contact(firstName: firstName, lastName: lastName)
  }
}

It work for very simple case. But complexity can grow very quickly:

  • 🖊 How do you track the form is modified?
  • 🙋‍♀️ How can you fill the form with data from one contact?
  • 🪆 How do you handle nested forms? For example a contact having multiple phone numbers requiring validation?

Another issue might rise when ContactViewModel need to handle other complex cases not directly related to the form: for instance checking whether a contact already exist based on its phone number.

In this context it become clear something is wrong:

  • 🦛 The ViewModel will grow fast
  • 🤯 It is hard distinguishing what is about form filling and what is not.

To help us with all these things let's tackle the problem from another perspective: Domain.

The Domain approach

Instead of thinking about UI let's just think about what we want to achieve: being able to create a Contact by filling its properties one by one.

To do that let's introduce a new object whose sole purpose will be to build the contact with no UI in consideration: ContactBuilder. It will be our form Domain representation.

Building an object

As said before the builder is responsible for creating a valid Contact. So let's just add that:

struct ContactBuilder {
  func build() throws -> Contact {
    throw ContactBuilderError.missingMandatoryFields
  }
}

Having no way for building a valid contact for now it will just throw an error. A few things compared to the previous example:

  1. We have only one way of building our model (build method) and its content is now an implementation detail
  2. thrown error is coming from the builder itself (ContactBuilderError). This is important because these errors are specific to our builder, not to our result model
  3. We have either an error or a valid contact. Compare to before where we could have both at the same time

Properties

Next step is to add contact attributes by declaring their equivalent into the builder. Let's add firstName and lastName and use the builder in the ViewModel:

struct ContactBuilder {
  var firstName = ""
  var lastName = ""

  func build() throws -> Contact {
    guard !firstName.isEmpty && !lastName.isEmpty else {
      throw ContactBuilderError.missingMandatoryFields
    }

    return Contact(firstName: firstName, lastName: lastName)
  }
}
class ContactViewModel: ObservableObject {
  @Published var builder = ContactBuilder() {
    didSet { contact = Result(builder.build()) }
  }

  @Published var contact: Result<Contact, Error>?
}

You might notice how our form builder is now a unique entity: making multiple changes at once will trigger only one update on the ViewModel.

Adding properties canSave or isModified is also now very easy:

class ContactViewModel {
  @Published var builder = ContactBuilder() {
    didSet {
      contact = Result(builder.build())
      isModified = true
    }
  }

  @Published var isModified = false

  var canSave: Bool { (try? contact.get() != nil) ?? false }
}

We can even go further and check if content was actually modified by making ContactBuilder conforming to Equatable:

struct ContactBuilder: Equatable {
  // easy peasy 🍋
  var isModified: Bool {
    builder != ContactBuilder()
  }
}

This give you lot of flexibility to implement things as you need based on your functional rules.

Nested form

Previous example focused on creating a simple object (a contact) but sometimes you need to go further and create additional objects along it.

In this scenario your form quickly contain dozen of fields. Following the traditional way it would just become a nightmare 🤯. This is where the form builder approach shine 💪

Let's continue with our contact and add the ability to set multiple phone numbers composed of two fields: a country and the number itself. Because it is a separate entity the best thing is to create a dedicated builder:

struct PhoneNumberBuilder {
  var country = Country.us
  var number = "+1"

  func build() throws PhoneNumber {
    // again, implementation detail
  }
}

Now all we need to do is to add a PhoneNumberBuilder in ContactBuilder and... that's it 🤷‍♂️:

struct ContactBuilder {
    var phoneNumbers: [PhoneNumberBuilder] = []

    func build() throws Contact {
      ...
      let phoneNumbers = try self.phoneNumbers.maptry $0.build() }

      return Contact(firstName: firstName, lastName: lastName, phoneNumbers: phoneNumbers)
    }
}

Just the same way we can make a View builder for phone number and use it in our parent view:

struct PhoneNumberForm: View {
  @Binding builder: PhoneNumberBuilder

  var body: some View {
    TextField("Country", text: $builder.country)
    TextField("Number", text: $builder.number)
  }
}

struct EditContactScreen: View {
  @StateObject var viewModel: ContactViewModel

  var body: some View {
    ...
    List($viewModel.builder.phoneNumbers) { $phoneNumber in
      PhoneNumberForm(builder: $phoneNumber)
    }
  }
}

This code won't compile though as List require each item to be Identifiable. We can fix it by using number as PhoneNumberBuilder id:

extension PhoneNumberBuilder: Identifiable {
  var id: String { number }
}

But while the code now compile we entered a dangerous zone: duplicated id. Nothing prevent us from having the same number twice!

As you probably already know this is a bad idea to rely on potential unique id like phone/social numbers. Instead we'll use a generated identifier which is a perfect solution for builders not having a provided id:

struct PhoneNumberBuilder: Identifiable {
  let id = UUID()
}

Conclusion

By focusing on domain rather on UI we now have a testable form builder. And by using nested builders we are now able to easily add new fields without increasing the complexity.