David Patterson
David Patterson

Reputation: 1920

Custom view controller class isn't listed in storyboard's class menu

My app has a hierarchy of classes for creating custom view controllers.

The first class is AppViewController. It extends NSViewController and contains methods common to all of my view controllers, like displaying alerts, retrieving data from the database, and so forth. It does not define any variables.

class AppViewController: NSViewController
{
    ...
}

The next class is ListViewController and is common to all of my "list" views. These are views that contain a single NSTableView with a list of all of the records from the associated database table. It extends AppViewController and conforms to the usual protocols.

Note that this class is generic so that it can properly handle the different views and data models.

class ListViewController<Model: RestModel>: AppViewController,
                                            NSWindowDelegate,
                                            NSTableViewDataSource,
                                            NSTableViewDelegate
{
    ...
}

ListViewController defines a number of variables, including an IBOutlet for an NSTableView. That outlet is not wired to anything in the storyboard. The plan is to set it at run-time.

ListViewController also defines various functions including viewDidLoad(), viewWillAppear(), a number of app-specific functions, and so on.

The last class is specific to a database model and view, in this case, the Customers view. It extends ListViewController.

class Clv: ListViewController<CustomerMaster>
{
    ...
}

CustomerMaster is a concrete class that conforms to the RestModel protocol.

The problem:
The strange thing is that the last class, Clv, does not show up in the storyboard's Custom Class: Class pull-down menu, meaning that I cannot specify it as the custom class for my view.

I tried just typing it in, but that results in a run-time error

Unknown class _TtC9Inventory3Clv in Interface Builder file ...

If I remove the <Model: RestModel> from the ListViewController class definition and also remove the <CustomerMaster> from the Clv class definition, the Clv class then appears in the Class menu (of course that doesn't really help, just an observation).

AppViewController and ListViewController both do appear in that menu.

I am at a loss.

Upvotes: 2

Views: 1050

Answers (2)

David Patterson
David Patterson

Reputation: 1920

The answer by @vikingosegundo, while explaining Xcode's complaint and being generally very informative, didn't help me solve my particular problem. My project was started in Xcode 8.3.3 and I already have lots of windows and views in the storyboard so I don't really want to abandon or work around the storyboard/generic issue.

That being said, I did some more research and came to the realization that many people prefer delegation to class inheritance so I decided to explore that approach. I was able to get something working that satisfies my needs.

I present here, a simplified, but functional approach.

First, a protocol that our data models must conform to:

protocol RestModel
{
  static var entityName: String { get }
  var id: Int { get }
}

Next, a data model:

///
/// A dummy model for testing. It has two properties: an ID and a  name.
///
class ModelOne: RestModel
{
  static var entityName: String = "ModelOne"
  var id: Int
  var name: String

  init(_ id: Int, _ name: String)
  {
    self.id = id
    self.name = name
  }
}

Then, a protocol to which all classes that extend our base class must conform:

///
/// Protocol: ListViewControllerDelegate
///
/// All classes that extend BaseListViewController must conform to this
/// protocol. This allows us to separate all knowledge of the actual data
/// source, record formats, etc. into a view-specific controller.
///
protocol ListViewControllerDelegate: class
{
  ///
  /// The actual table view object. This must be defined in the extending class
  /// as @IBOutlet weak var tableView: NSTableView!. The base class saves a weak
  /// reference to this variable in one of its local variables and uses that
  /// variable to access the actual table view object.
  ///
  weak var tableView: NSTableView! { get }

  ///
  /// This method must perform whatever I/O is required to load the data for the
  /// table view. Loading the data is assumed to be asyncronous so the method
  /// must accept a closure which must be called after the data has been loaded.
  ///
  func loadRecords()

  ///
  /// This method must simply return the number of rows in the data set.
  ///
  func numberOfRows() -> Int

  ///
  /// This method must return the text that is to be displayed in the specified
  /// cell. 
  /// - parameters:
  ///   - row:    The row number (as supplied in the call to tableView(tableView:viewFor:row:).
  ///   - col:    The column identifier (from tableColumn.identifier).
  /// - returns:  String
  ///
  func textForCell(row: Int, col: String) -> String

} // ListViewControllerDelegate protocol

Now the actual base class:

class BaseListViewController: NSViewController,  
                              NSTableViewDataSource,  
                              NSTableViewDelegate
{
  //
  // The instance of the extending class. Like most delegate variables in Cocoa
  // applications, this variable must be set by the delegate (the extending
  // class, in this case).
  //
  weak var delegate: ListViewControllerDelegate?

  //
  // The extending class' actual table view object.
  //
  weak var delegateTableView: NSTableView!

  //
  // Calls super.viewDidLoad()
  // Gets a reference to the extending class' table view object.
  // Sets the data source and delegate for the table view.
  // Calls the delegate's loadRecords() method.
  //
  override func viewDidLoad()
  {
    super.viewDidLoad()
    delegateTableView = delegate?.tableView
    delegateTableView.dataSource = self
    delegateTableView.delegate = self
    delegate?.loadRecords()
    delegateTableView.reloadData()
  }


  //
  // This is called by the extending class' table view object to retreive the
  // number of rows in the data set.
  //
  func numberOfRows(in tableView: NSTableView) -> Int
  {
    return (delegate?.numberOfRows())!
  }


  //
  // This is called by the extending class' table view to retrieve a view cell
  // for each column/row in the table. We call the delegate's textForCell(row:col:)
  // method to retrieve the text and then create a view cell with that as its
  // contents.
  //
  func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView?
  {
    if let col = tableColumn?.identifier, let text = delegate?.textForCell(row: row, col: col)
    {
      if let cell = delegate?.tableView.make(withIdentifier: (tableColumn?.identifier)!, owner: nil) as? NSTableCellView
      {
        cell.textField?.stringValue = text
        return cell
      }
    }
    return nil
  }
} // BaseListViewController{}

And, finally, an extending class:

///
/// A concrete example class that extends BaseListViewController{}.
/// It loadRecords() method simply uses a hard-coded list.
/// This is the class that is specified in the IB.
///
class ViewOne: BaseListViewController, ListViewControllerDelegate
{
  var records: [ModelOne] = []

  //
  // The actual table view in our view.
  //
  @IBOutlet weak var tableView: NSTableView!

  override func viewDidLoad()
  {
    super.delegate = self
    super.viewDidLoad()
  }

  func loadRecords()
  {
    records =
    [
      ModelOne(1, "AAA"),
      ModelOne(2, "BBB"),
      ModelOne(3, "CCC"),
      ModelOne(4, "DDD"),
    ]
  }

  func numberOfRows() -> Int
  {
    return records.count
  }

  func textForCell(row: Int, col: String) -> String
  {
    switch col
    {
    case "id":
      return "\(records[row].id)"

    case "name":
      return records[row].name

    default:
      return ""
    }
  }
} // ViewOne{}

This is, of course, a simplified prototype. In a real-world implementation, loading the records and updating the table would happen in closures after asynchronously loading the data from a database, web service, or some such.

My full prototype defines two models and two view controllers that extend BaseListViewClass. It works as desired. The production version of the base class will contain numerous other methods (which is why a wanted it to be a base class in the first place :-)

Upvotes: 0

vikingosegundo
vikingosegundo

Reputation: 52227

Earlier this year I created a similar architecture for an app, and I have to tell you: It can't work with storyboards, as those don't know anything about generics during instantiation.

What works is using nibs though, as you than still can init your view controller yourself.

an example:

import UIKit

class ViewController<Model: Any>: UIViewController {
    var model:Model?
}

You can instantiate this view controller like

let vc = ViewController<ListItem>(nibName: "ListViewController", bundle: nil)

or subclass it

class ListViewController: ViewController<ListItem> {
}

and instantiate it like

let vc = ListViewController(nibName: "ListViewController", bundle: nil)

Now it compiles and runs, but you haven't gained much yet, as you cannot wire up your nib with generic properties.

But what you could do is to have a UIView-typed IBOutlet in a non-generic base view controller, subclass it with a generic view controller that has two generic contracts: one for the model, one for the view, ass you most likely want this to be adapted for your model. But now you must have some code that knows how to bring your model on the view. I call this renderer, but you will also find many examples were such an class is called Presenter.

The view controllers:

class BaseRenderViewController: UIViewController {
    var renderer: RenderType?
    @IBOutlet private weak var privateRenderView: UIView!

    var renderView: UIView! {
        get { return privateRenderView }
        set { privateRenderView = newValue }
    }
}


class RenderedContentViewController<Content, View: UIView>: BaseRenderViewController {

    var contentRenderer: ContentRenderer<Content, View>? {
        return renderer as? ContentRenderer<Content, View>
    }

    open
    override func viewDidLoad() {
        super.viewDidLoad()

        guard let renderer = contentRenderer, let view = self.renderView as? View else {
            return
        }
        do {
            try renderer.render(on: view)

        } catch (let error) {
            print(error)
        }
    }
}

The renderers:

protocol RenderType {}

class Renderer<View: UIView>: RenderType {
    func render(on view: View) throws {
        throw RendererError.methodNotOverridden("\(#function) must be overridden")
    }
}

class ContentRenderer<Content, View: UIView>: Renderer<View> {
    init(contents: [Content]) {
        self.contents = contents
    }
    let contents: [Content]

    override func render(on view: View) throws {
        throw RendererError.methodNotOverridden("\(#function) must be overridden")
    }
}

You can now subclass ContentRenderer and overwrite the render method to show your content on the view.

tl;dr

By using the approach I just illustrated you can combine any generic view controller with different models, renderers and views. You gain an incredible flexibility — but you won't be able to use storyboards with it.

Upvotes: 1

Related Questions