Painless View Controller Configuration in Swift – Grape Up

Back in the pre-storyboard era of iOS development, developers had to write hundreds of lines of boilerplate code that served to manage UIViewController object hierarchies. Back then, some were inventing generic approaches to the configuring of controllers and transitions between them. Others were just satisfied with the ad-hoc view controller creation and presented them directly from other code controllers. But things changed when Apple introduced storyboards in iOS 5. It was a huge step forward in the UI design for iOS. Storyboards introduced an ability to visually define app screens and – what is the most important – transitions between them (called segues) in a single file. Storyboard segues allow to discard all the boilerplate code related to transitions between view controllers.

Of course, every solution has its advantages and disadvantages. When it comes to storyboards, some may note issues such as hard to resolve merge conflicts, coupling of view controllers, poor reusability etc. Some developers don’t even use storyboards because of such disadvantages. For others the advantages play a more important role. However, the real bottleneck of the storyboards is the initialization of view controllers. In fact, there is no true initialization for the view controllers presented by storyboard segues.

Problems With The View Controller Configuration

Let’s start from some basics. In Objective-C/Swift, in order to give an object an initial state, the initializer (init()) is called. This call assigns values to properties of the class. It always happens at the point where the object is created. When subclassing any class, we may provide the initializer and this is the only proper way. We may also provide such initializer for the UIViewController subclass. However, in case such controller is created/presented using the storyboard, the segue creation takes place through a particular initializer – init(coder:). Overriding it in subclass may give us the ability to initialize properties added by the subclass. However, we don’t have the ability to pass additional arguments to the overridden method. Moreover, even if we had such an ability, it would make no sense. This is because for storyboard-driven view controllers there is no particular point in code which allows them to pass data to the initializer. That is, we cannot catch the moment of creation of such controller. The creation of view controllers managed by storyboard segues is hidden from the programmer. It happens when segue to the controller is triggered – either entirely handled by the system (when triggering action is set up in the storyboard file) or using performSegue() method.

Apple, however, provides a place where we can pass some data to an already created view controller after the segue is triggered. It’s a prepare(for : sender:) method. From its first parameter (of UIStoryboardSegue type), we can get the segue’s destination view controller. Because the controller has already been created (initialization is already performed when triggering segue) the only option for passing the required data is to configure it. This means that after the initialization, but before the prepare(for : sender:) is called, the properties of the controller that hold such data should not have initial value or should have fake ones. While the second option is meaningless in most cases, the first one is widely used. Such absence of data means that the corresponding controller’s properties should be of an optional type(s). Let’s take a look on the following sample:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if segue.identifier == "ToSomeViewControllerSegueID",
     let controller = segue.destination as? SomeViewController {
      controller.someValue = 12345
    }
  }
}

This is how the view controller configuring is implemented in most cases when dealing with segues:

  • check segue id;
  • get the destination view controller from the segue object;
  • try to cast it to the type of the expected view controller subclass.

In case all conditions are satisfied we can set values to the properties of the controller that need to be configured. The problem with the approach is that it has too much service code related to verification and data extraction. It may not be visible in simple cases like the one shown above. However, taking into account the fact that each view controller in application often has transitions to several other view controllers such service code becomes a real boilerplate code we’d like to avoid. Take a look at the following example that generalizes the problem with prepare(for : sender:) implementation.

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if segue.identifier == "ToViewControllerA",
     let controller = segue.destination as? ViewControllerA {
      // configure  View Controller A
    }
  } else if segue.identifier == "ToViewControllerB",
     let controller = segue.destination as? ViewControllerB {
      // configure  View Controller B
    }
  } else if segue.identifier == "ToViewControllerC",
     let controller = segue.destination as? ViewControllerC {
      // configure  View Controller C
    }
  } else ...
    ...
  } else if segue.identifier == "ToViewControllerZ",
     let controller = segue.destination as? ViewControllerZ {
      // configure  View Controller Z
    }
  }
}

All those if… else if… blocks are making code hard to read. Moreover, each block is for the a different view controller that has to be configured. That is, the more view controllers are going to be present by this one, the more if… else if… will be added. This, in turn, reveals another problem with such configuration. There is a single method for a particular controller that does all configurations for every controller we’re going to present.

Solution

Let’s try to find the approach to the view controller configuration that may eliminate the outlined problems. We’re limited to the usage of prepare(for : sender:) since it’s the only point where the configuration can be done. So we cannot do anything with the type of the destination view controller and with the check of segue identifier. Instead we’d like to generalize the process of configuration in a way that allows us to have a single type check and single verification for identifier. That is, check with some generalized type of destination view controller and variable segue identifier rather than enumerating all the possible concrete types/identifiers. For this, we need to pass somehow the information about the type and the segue identifier to the prepare(for : sender:) method. We would like to have something like the following:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if segue.identifier == ,
     let viewerController = segue.destination as?  {
    // configure viewerController
  }
}

In order to have a single configuration code for all the controllers we need two things: unified interface to configure the controller, and a way to get the configuration data for the particular destination controller and segue identifier. Let’s define each part of the solution.

1. Unified Interface for View Configuration

As defined previously, configuration means setting values to one or more properties of destination view controller. So it’s natural to associate the configuration interface with the destination controller rather than with the one that triggers the segue. Obviously, each destination view controller has a different number of properties of different types to configure.

In order to provide a unified configuration interface we may implement a method for configuring each controller. We should pass there the values that will be assigned to the corresponding controller properties. To unify such method, every configured controller should have the same signature. To achieve this, we should wrap a set of passed configuring values into a single object. Then such method will always have one argument – no matter how many properties should be set. The type of the argument is a type of the wrapping object and is different for each view controller. This means that the view controller should implement a method for configuring and somehow define a type of the argument of the method. This is a perfect task for protocols with associated types. Let’s define the following protocol:

protocol Configurable {
  associatedtype ConfigurationType
  func configure(with configuration: ConfigurationType)
}

Each view controller that is going to be configured (is configurable) should conform to this protocol by implementing the configure(with:) method and defining a concrete type for ConfigurationType. In the easiest case where we only have one property that needs to be configured, the ConfigurationType is the type of that property. Otherwise, the ConfigurationType may be defined as a structure or tuple to represent several values. Consider the following examples:

class SomeViewController: UIViewController, Configurable {
  var someValue: Int?
  var someObject: MyModelType?
  …
  func configure(with configuration: (value: Int, object: MyModelType)) {
    value = configuration.value
    someObject = configuration.object
    }
}

class OtherViewController: UIViewController, Configurable {
  var underlyingObject: MyObjectType?
  …
  func configure(with object: MyObjectType) {
    underlyingObject = object
    }
}

2. Defining the Configuration Data for View Controller

Now, let’s go back to the controller that is triggering a segue. We’re going to use the configuration protocol we’ve defined. For this, we need to have data for passing it to the configure(with:) method. This should be something as follows:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if let segueIdentifier = segue.identifier {
    // 1. Get configuration object
    // for segue.destination and segueIdentifier
    // 2. Pass configuration object to the destination controller
  }
}

Let’s focus on how should we obtain the configuration object. Each segue is unique within a controller that triggers those segues. For each segue we have a single destination controller that has its own type of configuration. This means that segue id unambiguously defines a configuration type that should be used for configuring the destination view controller.

On the other hand, just returning the configuration of a concrete type per each segue id is not enough. If we did so, we would need to pass it somehow to a destination controller that has a type UIViewCotroller. It has nothing to do with the configuration. On the other hand, we cannot use the Configurable protocol as a type of an object directly because it has an associated type constraint. That is, we cannot cast the destination view controller to the Configurable type like as follows:
(segue.destination as? Configurable)?.configure(with: data). Instead, we need to use some proxy generic type that is constrained to being a Configurable.

Also, creating all the configuration objects for the controllers in a single method has no sense since it brings the same issue as the one with prepare(for ). That is, in this case we have a concentration of code intended to configuring different objects in a single method. Instead, the better solution is to group the code for creating the particular configuration and the type of the controller which is configured into a separate object. Consider the following example:

class Configurator {
  let configurationProvider: () -> ConfigurableType.ConfigurationType
  
  init(configuringCode: @escaping () -> ConfigurableType.ConfigurationType) {
    self.configurationProvider = configuringCode
  }
  
  func performConfiguration(of object: ConfigurableType) {
    let configuration = configurationProvider()
    object.configure(with: configuration)
  }
}

In the code above, a single Configurator<T> instance is responsible for configuring the controller of a particular type. The code that creates the configuration is injected to the configurator in the init() method during creation.

According with the reasoning given above, we should associate a segue ID with the particular configuration and type. Considering the approach with the Configurator<T>, the easiest way to do it is to create a mapping object where the key is a segue ID and a value is the corresponding Configurator<T> instance. We may also create those Configurator<T> objects in place of the map definition. This will make the code more clear and readable. The following example demonstrates such map:

var segueIDToConfigurator: [String : Any] {
  return [
    "ToSomeViewControllerSegueID": Configurator {
      return (value: 123, object: MyModelType())
    },
    "ToOtherViewControllerSegueID": Configurator {
      return MyObjectType()
    }
  ]
}

Let’s now try to use the configuration from the dictionary above in prepare(for ) method. Let’s take a look at the following example

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if let segueIdentifier = segue.identifier,
     let configuring = segueIDToConfigurator[segueIdentifier] as? Configurator {
      configuring.performConfiguration(of: segue.destination)
  }
}

The problem is that the value type of the dictionary segueIDToConfigurator is Any. We cannot call on it any method directly. Instead, we need to cast it to the type that contains the performConfiguration(of:) method. On the other hand, the only type in our implementation that contains the performConfiguration(of:) method is the generic type Configurator<T>. And to use it we should pass a certain type of the destination view controller in place of the generic type placeholder. At this point, the problem is in prepare(for ) method. In this method we don’t have the information about that view controller type. Let’s try to resolve the problem. We need Configurator<T> only to call the performConfiguration(of:) method. Instead of having the whole interface of Configurator<T> type inside the prepare(for ) method we may use some intermediate interface that does not depend on a generic type and allows us to call performConfiguration(of:).

var segueIDToConfigurator: [String : Configuring] {
  return [
    "ToSomeViewControllerSegueID": Configurator {
      return (value: 123, object: MyModelType())
    },
    "ToOtherViewControllerSegueID": Configurator {
      return MyObjectType()
    }
  ]
}

For this, let’s create a protocol Configuring and modify the Configurator<T> type to make it conform to it. The example below demonstrates the refined approach.

protocol Configuring {
  func performConfiguration(of object: SomeType) throws
}

class Configurator: Configuring {
  let configurationProvider: () -> ConfigurableType.ConfigurationType
  
  init(configuringCode: @escaping () -> ConfigurableType.ConfigurationType) {
    self.configurationProvider = configuringCode
  }
  
  func performConfiguration(of object: SomeType) throws {
    if let configurableObject = object as? ConfigurableType {
      let configuration = configurationProvider()
      configurableObject.configure(with: configuration)
    } else {
      throw ConfigurationError()
    }
  }
}

Now, the performConfiguration(of:) is a generic method. This allows us to call it without knowing the exact type of the object which is configured. The method however became throwable. This is because the type of its argument is widened so that the arbitrary type can be passed. But the method can still handle only the objects that conform to the Configurable protocol. And if the passed object is not Configurable we don’t have anything to do with it. In this case we throw an error.

We may now use the newly defined Configuring protocol to define the dictionary for segue-to-configurator mapping:

This allows us to use the Configuring objects inside the prepare(for ) method as shown below:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  if let segueIdentifier = segue.identifier,
     let configuring = segueIDToConfigurator[segueIdentifier] {
    do {
      try configuring.performConfiguration(of: segue.destination)
    } catch let configurationError {        
      fatalError("Cannot configure (segue.destination). " +
                 "Error: (configurationError)")
    }
  }
}

Refining the Solution

The above prepare(for ) implementation is the same for any controller that is going to use the described approach. There are several ways to avoid such code duplication. But you must keep in mind that each has its downsides.
The first and the most obvious way is to use some base view controller across the project that will implement the method prepare(for ) and the segueIDToConfigurator property for holding configurations:

class BaseViewController {

  var segueIDToConfigurator: [String: Configuring] {
    return [String: Configuring]()
  }

  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if let segueIdentifier = segue.identifier,
       let configuring = segueIDToConfigurator[segueIdentifier] {
      do {
        try configuring.performConfiguration(of: segue.destination)
      } catch let configurationError {
        // throw an error or just write to log
        // if you just want to silently it ignore       
        fatalError("Cannot configure (segue.destination). " +
                   "Error: (configurationError)")
      }
    }
  }

}

class MyViewController: BaseViewController {

  // Define needed configurators
  override var segueIDToConfigurator: [String: Configuring]{
    ...
  }

}

The advantage of the first way is that any controller that subclasses BaseViewController needs to define strictly the data that is needed for the configuration. That is, override the segueIDToConfigurator property. However, it forces all the view controllers to subclass BaseViewController. This makes it impossible to use the system UIViewController subclasses like UITableViewViewController, etc.

The second way is to use a special protocol that defines the interface of the controller that can configure other controllers. Consider the following example:

protocol ViewControllerConfiguring {
  var segueIDToConfigurator: [String: Configuring] { get }
}

extension ViewControllerConfiguring {

  func configure(segue: UIStoryboardSegue) {
    if let segueIdentifier = segue.identifier,
       let configuring = segueIDToConfigurator[segueIdentifier] {
      do {
        try configuring.performConfiguration(of: segue.destination)
      } catch let configurationError {        
        fatalError("Cannot configure (segue.destination). " +
                   "Error: (configurationError)")
      }
    }
  }

}

class MyViewController: UIViewController, ViewControllerConfiguring {

  // Define needed configurators
  var segueIDToConfigurator = ...

  // Each view controller still have to implement this method
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    configure(segue: segue)
  }

}

This way is more flexible in comparison to the first one. The protocol can be implemented by any object that is going to configure the segue destination controller. It means that not only UIViewController subclass can use it. Moreover, it doesn’t limit us to use only the BaseViewController as a superclass. On the other hand, each view controller still needs to override prepare(for ) and call configure(segue:) method in its implementation.

Summary

In this article, I described the approach to configuring destination view controllers with clean and straightforward code when using storyboard segues. The approach is possible thanks to useful Swift concepts, such as Generics and Protocols with associated types. The code is also safe as it uses static typing wherever possible and handles errors. Meanwhile the dynamic types are concentrated in single place and the possible errors are handled only there. This approach allows us to avoid unnecessary boilerplate code in the prepare(for ) methods. On the other hand, it makes configuring particular view controllers clearer and more robust by using a specific Configurable protocol.


Source link