Generic UIViewControllers

Update 1/09/2015: Code tested on Xcode 7.0 beta 5 (7A176x)

Note: Kudos to Robert for lending me his brain in some of this decisions.

Context

Imagine you want to display some photos and videos in a UITableView. Both the photos and the videos have their own feed, which you need to fetch, parse, persist and display. Intrinsically they are different, but they share some similarities. In this post I will explore how we can extract their homogeneous behaviour, at a UI level.

Parameterised UIViewController

The initial draft of a parameterised UIViewController can be something along these lines:

class MyVC<T> : UIViewControlller {

}

This will work, but if you instantiate it via a Storyboard, it will fail. The problem is related to how you set the type on the Storyboard. You basically say "Hey Storyboard! This VC is of type MyVC". And it will happily compile, but at runtime it will complain:

Unknown class _TtC6asdasd4MyVC in Interface Builder file.

Another way to this, is by programatically creating the UIViewController.

let myVc = MyVC<Video>()  
self.pushViewController(myVC, animated: true) 

//Inside the VC on loadView you create your UI

But sometimes it can be a pain to do it programmatically, so let's use a NIB instead. I wasn't able to call nibName:bundle: from the outside:1

let myVc = MyVC<Video>(nibName:"MyVC", bundle: nil) // It won't work  

But if you do it from the inside, it will work:

class MyVC<T> : UIViewControlller {

    init(nibName: String) {
        super.init(nibName: nibName, bundle: nil)
    }
}

Or if you prefer:

class MyVC<T> : UIViewControlller {

    init(nibName: String = "MyVC") {
        super.init(nibName: nibName, bundle: nil)
    }
}
Now what?

Now that you have a generic UIViewController, how do you set a UITableViewDataSource that is specific for the Photos or Videos? Remember, although they have a lot of similarities, the way to render them can be quite different:

class MyVC<T> : UIViewControlller {

   @IBOutlet weak var tableView: UITableView!

   private let dataSource: DataSource<T>

   init(nibName: String, dataSource: DataSource<T>) {
        self.dataSource = dataSource

        super.init(nibName: nibName, bundle: nil)
    }

   override viewDidLoad() {
        super.viewDidLoad()
        tableView.dataSource = dataSource
   }
}

Since the DataSource is different, we will need to use a generic one as well. The trick here is to fix the type, when you are creating the VC:

let dataSource = DataSource<Video>()  
let myVC = MyVC<Video>(nibName:"MyVC", dataSource: dataSource)  

But we are not done yet. In order for it to work, the DataSource needs to do two things2:

  1. Return a UITableViewCell.
  2. Provide the number of cells the UITableView needs.

To fulfil 1. I will pass the following:

  1. A cell identifier, so I can dequeue a new UITableViewCell.
  2. A function that takes a T an UITableViewCell and returns nothing.

The initialiser will then be:

init(cellIdentifier: String, cellUpdater: (T, UITableViewCell) -> ())  

To fulfil point 2., I need to pass the number of cells. Depending of your approach, you can either pass the number of cells, before you have the UIViewController, or build the DataSource inside the UIViewController (when you have access to the number of elements). You have some options depending on your needs:

  1. You pass the DataSource ready to be used, with the number of elements fixed.
  2. You pass the DataSource ready to be used, but with an exposed numberOfElements var that will be updated inside the VC, once you got the number of elements.
  3. You pass the cellConstructor to the VC and you build the DataSource once you got the elements.

In my particular case, 3. is my preferred way. The compromise is that it will force me to declare the DataSource as force unwrapped, since I don't have a DataSource at initialisation time:3

class MyVC<T> : UIViewControlller {

   @IBOutlet weak var tableView: UITableView!

   private var dataSource : DataSource<T>! 
   private let cellUpdater : (T, UITableViewCell) -> ()

    init(nibName: String, cellUpdater: (T, UITableViewCell) -> ()) {
       self.cellUpdater = cellUpdater
       super.init(nibName: nibName, bundle: nil)
   }
}

The VC creation then becomes:

let cellUpdater = CellBuilder.videoUpdater  
let myVC = MyVC<Video>(nibName:"MyVC", cellUpdater: cellUpdater)  

The videoUpdater is as you would expect:

static var videoUpdater : (Video,UITableViewCell) -> () {  
   return { video, cell in 

      // you might go with a more defensive approach
      let videoCell = cell as! VideoCell 

      videoCell.setupWithVideo(video)
   }
}

Once you get the number of cells you just have to:

let content : [T] = ...  
self.dataSource = DataSource<T>(cellIdentifier, cellUpdater: cellUpdater, content: content)  

And on your cellForRowAtIndexPath and numberOfRowsForSection:

func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

    let cell = tableView.dequeueReusableCellWithIdentifier(cellIdentifier, forIndexPath: indexPath)

    cellUpdater(content[indexPath.row], cell)

    return cell
}

func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {  
     return content.count
}

Another benefit of this approach, is that you can use that DataSource in other places as well, since it doesn't really care about the Type you are working with.

And that's really it. With this, both the Video and Photo will benefit from all the good stuff that the UIViewController can give, like error handling from network, animations and custom transitions, etc. The UITableViewDelegate would follow a similar strategy, where you pass the "concrete" implementation of the didDeselectRowAtIndexPath handler.

  1. I am probably missing something, so please leave a comment.

  2. Let's keep it simple. A more complex layout, would require other things, like section's title and number of sections, etc.

  3. In this case I am actually ok with it, because if I am not able to provide a valid data source, then there is something seriously wrong with my app. (my opinion might change on this approach...)