An elegant way to organize communication between view and view controller

prokhorovxo

Fedor Prokhorov

Posted on February 8, 2023

An elegant way to organize communication between view and view controller

Motivation

There are numerous architectures available today for use when developing an iOS app. MVC, MVVM, MVP, VIPER, TCA, and others are examples. These are excellent and practical architectures that are actively used by developers. However, there is one nuance: the organization of view controller and view is frequently contentious. Where should the code for view configuration and setting constraints be kept? Is it better to put it in a separate class or directly in the view controller? How should they communicate?

In this article, I'd like to look at a variety of cases, ranging from the most heinous, in my opinion, to the solution, which I frequently employ in various projects with varying architectures.

Worst case

In the worst case, the code that relates to view configuration in some UIViewController is located in the viewDidLoad() method. This is completely wrong in terms of the view controller's lifecycle. viewDidLoad() method is called after the controller's view is loaded into memory and it's not intended to place elements on the main view. Of course, there are exceptions here, for example, in viewDidLoad() you can set text for a view's label.

final class MyViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let label = UILabel()
        label.textColor = .systemBlue
        label.text = "Hello, World!"
        view.addSubview(label)
        label.translatesAutoresizingMaskIntoConstraints = false
        label.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
        label.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
    }
}
Enter fullscreen mode Exit fullscreen mode

A little better case

A better solution is to put the view and its subviews initialization code into the loadView() method. This method loads or creates a view and assigns it to the view property. Here's what it looks like now:

final class MyViewController: UIViewController {

    override func loadView() {
        let view = UIView()
        let label = UILabel()
        // Configure label, add it as view's subview and setup its constraints...
        self.view = view
    }
}
Enter fullscreen mode Exit fullscreen mode

But there is another problem here. What if we want to set different text to the label later on, in viewDidLoad() method f.e.? Yes, we can store a reference to the label in a view controller's property. But imagine how many such properties there would be if we are talking about a UI with a lot of elements.

Let's move view out to solve these two problems:

  1. A lot of UI configuring code in MyViewController class
  2. view.label is inaccessible.

View encapsulation

A much better solution would be to encapsulate the view in a separate class and set it as the view controller's view in the loadView() method. Here's how it can be done:

MyView.swift

final class MyView: UIView {

    let label = UILabel()

    init() {
        super.init(frame: .zero)
        setupViewsAndConstraints()
    }

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViewsAndConstraints()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupViewsAndConstraints()
    }

    private func setupViewsAndConstraints() {
        // Configure label, add it as view's subview and setup its constraints...
    }
}
Enter fullscreen mode Exit fullscreen mode

Once MyView class has been created, let's set this view in the view controller:

MyViewController.swift

final class MyViewController: UIViewController {
    // Reference to the view of our custom SomeView type
    private var someView: SomeView? {
        return view as? SomeView
    }

    override func loadView() {
        self.view = SomeView()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        someView?.label.text = "New text"
    }
}
Enter fullscreen mode Exit fullscreen mode

We can now access the label in the view controller to change the text in it at any time. This is already a good result, but there is also space for improvement:

  1. Boilerplate coding. I mean, we should override loadView() method in each new view controller. And also we need to store a reference to the view of specific type as a view controller's property.
  2. Because we have to safely type casting in the view type to access all subviews in a custom view, this view will be an optional type. Unfortunately, it isn't always convenient to work with optional types.

At this point, we came smoothly to a solution that my colleague and I developed several years ago. I use this solution to this day in combination with different architectures.

Implementation

First of all, let's declare a protocol that will describe the view controller's interface:

ViewControllerInterface.swift

protocol ViewControllerInterface: AnyObject {
    // 1
    associatedtype ContentView
    // 2
    var contentView: ContentView { get }
    // 3
    func loadContentView() -> ContentView
}

extension ViewControllerInterface where Self: UIViewController {
    // 4
    var contentView: ContentView {
        return view as! ContentView
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Associated type for view's any type.
  2. Non-optional view's reference to access any subview and other properties from view controller.
  3. View loading method (more details will be described below).
  4. Default implementation for avoiding boilerplate code and type casting in future.

After ViewControllerInterface protocol is described, let's implement a base generic class from which all view controllers will inherit in the future:

ViewController.swift

/// Base ViewController, in which view must be defined programmatically in the loadContentView() method
class ViewController<ContentView: UIView>: UIViewController, ViewControllerInterface {
    // Empty initializer. Override this for custom initializing
    init() {
        super.init(nibName: nil, bundle: nil)
    }
    // We create our UI programmatically only, so we don't need this initialiser.
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    // Set view controller's view
    final override func loadView() {
        view = loadContentView()
    }
    // Method for determining your own view
    func loadContentView() -> ContentView {
       return ContentView()
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, it's time to refactor MyViewController and MyView using the base ViewController class:

MyViewController.swift

final class MyViewController: ViewController<MyContentView> {

    override func viewDidLoad() {
        super.viewDidLoad()
        // You can access view's properties if you use contentView property instead of view
        contentView.label.text = "Hello, World!"
    }
}
Enter fullscreen mode Exit fullscreen mode

MyContentView.swift

final class MyContentView: UIView {

    let label = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupViewsAndConstraints()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupViewsAndConstraints()
    }

    private func setupViewsAndConstraints() {
        // Configure label, add it as view's subview and setup its constraints...
    }
}
Enter fullscreen mode Exit fullscreen mode

Now compare the code at the beginning of this article with this one. It's much better, right? I have demonstrated the most basic case, but let's touch on a few more special cases...

Advanced usage of loadContentView() method

Suppose we are working with the MVVM architecture and we want to initialize MyContentView with some parameters for the initial layout of the UI. These parameters are stored in the view model and we have to pass them to the view during initialization. No problem, we can just pass view model to the view in loadContentView() method:

MyViewController.swift

final class MyViewController: ViewController<MyContentView> {

    private let viewModel: MyViewModelInterface

    init(viewModel: MyViewModelInterface) {
        self.viewModel = viewModel
        super.init()
    }

    override func loadContentView() -> MyContentView {
        return MyContentView(viewModel: viewModel)
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Custom UIView with XIB file

If you still prefer to layout your UI using Interface Builder, I suggest defining a protocol for such views. Let's write some additional code and create the NibViewInterface protocol. This protocol will describe the interface for views that are connected with some XIB file:

NibViewInterface.swift

protocol NibViewInterface: AnyObject {
    // 1
    static var nib: UINib { get }   
}

extension NibViewInterface where Self: UIView {
    // 2
    static var nib: UINib {
        return UINib(nibName: String(describing: self), bundle: Self.bundle)
    }
}

extension NibViewInterface {
    // 3
    static var bundle: Bundle {
        return Bundle(for: Self.self)
    }
    // 4
    static func loadFromNib(owner: Any? = nil, options: [UINib.OptionsKey: Any]? = nil) -> Self {
        return nib.instantiate(withOwner: owner, options: options).first as! Self
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. You need to keep a reference to the XIB file to initialize the custom view with this file.
  2. Default implementation of the nib property. Attention: The name of the XIB file has to be the same as the view class name. For example, for the class MyNibContentView, MyNibContentView.xib file must be created. If you use single bundle you can pass nil to bundle parameter.
  3. The bundle in which to search for the XIB file.
  4. Method to load a view from the XIB file.

Now let's add support for the view created in the XIB file to our ViewControllerInterface. And also create a base generic class NibViewController, which we will inherit in the future to use the view associated with the XIB file:

ViewControllerInterface.swift

// ...

extension ViewControllerInterface where ContentView: NibViewInterface {

    func loadContentView() -> ContentView {
        return ContentView.loadFromNib(owner: self, options: nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

NibViewController.swift

/// Base ViewController, for which a view must be created in the XIB file
class NibViewController<ContentView: UIView & NibViewInterface>: ViewController<ContentView> {

    final override func loadContentView() -> ContentView {
        return ContentView.loadFromNib(owner: self, options: nil)
    }
}
Enter fullscreen mode Exit fullscreen mode

Great, now you can create MyNibViewController, MyNibContentView, and the XIB file MyNibContentView.xib with the same name. Don't forget to link the outlets from the Interface Builder with the swift file:

MyNibViewController.swift

final class MyNibViewController: NibViewController<MyNibContentView> {

    override func viewDidLoad() {
        super.viewDidLoad()
        contentView.label.text = "Hello, World!"
    }
}
Enter fullscreen mode Exit fullscreen mode

MyNibContentView.swift

final class MyNibContentView: UIView, NibViewInterface {
    // This is outlet from Interface Builder
    @IBOutlet weak var label: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
        // You can setup your view and subviews here
    }
}
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

You can find the source code in this repo. It is published under the “Unlicense”, which allows you to do whatever you want with it.

💖 💪 🙅 🚩
prokhorovxo
Fedor Prokhorov

Posted on February 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related