Working with input fields on iOS

In an input-heavy interface it may be pretty hard to gather information from a form before sending a network request. And it’s even harder to handle all the errors that may come in the response.

The typical use-case may look like this:

In this case everything is straightforward. The value is taken from the text field and sent in a request. If an error is returned, it’s displayed in red beneath the field.

But what happens when you have more than one input on the screen, or even inputs of different types (switches, radio buttons etc.)?

Introduction

There are two types of errors that we can get:

  1. A field error is an error associated with a concrete input field in the UI (f. ex. “This field is required” or “This field should be entirely alphabetical”). Field errors typically are validation errors. You may get several field errors for the same field.
  2. A non-field error means that while the information you provided is valid, it is simply incorrect or conflicting. For example, you have entered a wrong password.

For example, you might have a request of the following format:

{
  "phone": "+380123",
  "username": "beastmaster64",
  "password": "beastmaster64",
  "first_name": "John",
  "last_name": "Doe"
}

For which you get an error response:

{
  "phone": [
    {
      "message": "Enter a valid phone number",
      "code": "invalid"
    }
  ],
  "non_field_errors": [
    {
      "message": "Password can’t be the same as username",
      "code": "password_matches_username"
    }
  ]
}

Now you might want to display the errors in the UI. In the example above, the error associated with the phone should be displayed by the phone text field. The password_matches_username error should be displayed by the password text field.

Getting values from the fields and displaying errors afterwards may end up with a lot of boilerplate code. In this article, we aim to address this issue by providing an elegant way of generalizing such work.

Abstraction of the field concept

For something to be a field, you should be able to obtain its current value as well as inform it that some errors that relate to it have occurred. In the language of Swift protocols:

protocol FieldType {
    associatedtype Value: Encodable
    associatedtype Error: Swift.Error

    var value: Value { get }

    func putErrors(_ errors: [Error])
}

Note that the value should conform to Encodable for our purposes.

Let’s look at an example. Disregarding the actual UI details, we might have something like the following:

class PhoneTextField: UITextField, FieldType {

  var value: String? {
    text
  }

  func putErrors(_ errors: [Swift.Error]) {
    text = nil
    placeholder = error.description
  }
}

Error representation

We assume that we get errors in the format from the example. Let’s call it a MessageError:

struct MessageError: Error {
    let message: String
    let code: String
}

Let’s introduce a protocol for converting the server-side MessageError into client-side errors:

protocol MessageErrorInitializable {
    init?(messageError: MessageError)
}

extension MessageError: MessageErrorInitializable {
    init?(messageError: MessageError) {
        self = messageError
    }
}

Now errors in your app can be initialized from MessageError, for example:

enum PasswordError: String, Error {
    case incorrect = "incorrect"
    case tooShort = "too_short"
    case noDigits = "no_digits"
}

extension PasswordError: MessageErrorInitializable {
    init?(messageError: MessageError) {
        guard let error = PasswordError(rawValue: messageError.code) else {
            return nil
        }
        self = error
    }
}

The latter extension can be generalized as follows:

extension MessageErrorInitializable where Self: RawRepresentable, Self.RawValue == String {
    init?(messageError: MessageError) {
        guard let error = Self(rawValue: messageError.code) else {
            return nil
        }
        self = error
    }
}

extension PasswordError: MessageErrorInitializable {}

A plugin for Moya

As you may know, Moya is one of the most popular libraries for network requests in Swift. Moya provides a convenient abstraction layer for Alamofire, the absolute most popular networking library. We will use it for this tutorial. A basic knowledge of Moya is expected for understanding the code.

Moya has a handy way of getting access to network requests through so-called plugins. Among other things, a plugin can modify a request before it is sent and get access to the response.

func prepare(_ request: URLRequest, target: TargetType) -> URLRequest

func didReceive(_ result: Result<Moya.Response, MoyaError>, target: TargetType)

We will take advantage of these two capabilities.

The idea is that each field would be able to provide a plugin that handles injecting it’s value into the request and extracting the errors that should be displayed by this field:

protocol KeyValueInjector {
    associatedtype Value: Encodable

    func inject(
        value: Value,
        forKey key: String,
        into request: inout URLRequest,
        target: TargetType
    )
}
protocol ErrorExtractor {
    func extractErrors(
        forKey key: String,
        nonFieldErrors: [String],
        from result: Result<Response, MoyaError>
    ) -> [MessageError]
}

Now you can write your implementations of injector and extractor. Implement them based on the formats of the requests and responses that you need to deal with.

Now, let’s assemble it into a single Moya plugin:

struct FieldBasedMoyaPlugin<Field: FieldType>: PluginType
where 
    Field.Error: MessageErrorInitializable

It will store the field, the field’s key, and the list of non-field errors that relate to this field:

let field: Field
let key: String
let handledNonFieldErrors: [String]

As well as the extractor:

let extractor: ErrorExtractor

In Swift, protocols with associated types can only be used as generic constraints. Which is why we should type-erase the injector using a closure:

let inject: (URLRequest, TargetType) -> URLRequest

The constructor looks like this:

init<Injector: KeyValueInjector>(
    field: Field,
    key: String,
    handledNonFieldErrors: [String] = [],
    injector: Injector,
    extractor: ErrorExtractor
) 
where 
    Injector.Value == Field.Value
{
    self.field = field
    self.key = key
    self.handledNonFieldErrors = handledNonFieldErrors
    self.extractor = extractor

    inject = { [field, key] request, target in
        var request = request
        injector.inject(value: field.value, forKey: key, into: &request, target: target)
        return request
    }
}

Now let’s implement prepare and didReceive methods:

func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
    inject(request, target)
}

func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
    let messageErrors = extractor.extractErrors(forKey: key, nonFieldErrors: handledNonFieldErrors, from: result)

    if !messageErrors.isEmpty {
        field.putMessageErrors(messageErrors)
    }
}

If your application has just a single API that it needs to communicate with, then you probably need just a single injector and a single extractor. So, let’s create a handy constructor with default arguments for them. We can’t add a default argument for the injector in the existing constructor, since it has a generic type. That’s why we have to write another constructor in an extension:

extension FieldBasedMoyaPlugin {

    init(field: Field, key: String, handledNonFieldErrors: [String] = []) {
        self.init(
            field: field,
            key: key,
            handledNonFieldErrors: handledNonFieldErrors,
            injector: DefaultKeyValueInjector(),
            extractor: DefaultErrorExtractor()
        )
    }
}

Finally, let’s introduce a convenient factory method for creating field-based plugins:

extension FieldType where Error: MessageErrorInitializable {

    func moyaPlugin(key: String, nonFieldErrors: [String] = []) -> PluginType {
        FieldBasedMoyaPlugin(field: self, key: key, handledNonFieldErrors: nonFieldErrors)
    }
}

Now you can use the plugin by simply writing:

let firstNamePlugin = firstNameTextField.moyaPlugin(key: "first_name")

let passwordPlugin = passwordTextField.moyaPlugin(
    key: "password",
    nonFieldErrors: ["password_matches_username"]
)

let provider = MoyaProvider<SignupAPI>(plugins: [firstNamePlugin, passwordPlugin])