Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dispatch issue with generic subclass of custom table view controller

My application has a common base class for all table controllers, and I'm experiencing a strange bug when I define a generic subclass of that table controller base class. The method numberOfSections(in:) never gets called if and only if my subclass is generic.

Below is the smallest reproduction I could make:

class BaseTableViewController: UIViewController {
  let tableView: UITableView

  init(style: UITableViewStyle) {
    self.tableView = UITableView(frame: .zero, style: style)

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

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  // MARK: - Overridden methods

  override func viewDidLoad() {
    super. viewDidLoad()

    self.tableView.frame = self.view.bounds
    self.tableView.delegate = self
    self.tableView.dataSource = self

    self.view.addSubview(self.tableView)
  }
}

extension BaseTableViewController: UITableViewDataSource {
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return 0
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return UITableViewCell(style: .default, reuseIdentifier: nil)
  }
}

extension BaseTableViewController: UITableViewDelegate {
}

Here's the very simple generic subclass:

class ViewController<X>: BaseTableViewController {
  let data: X

  init(data: X) {
    self.data = data
    super.init(style: .grouped)
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func numberOfSections(in tableView: UITableView) -> Int {
    // THIS IS NEVER CALLED!
    print("called numberOfSections")
    return 1
  }

  override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    print("called numberOfRows for section \(section)")
    return 2
  }

  override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    print("cellFor: (\(indexPath.section), \(indexPath.row))")
    let cell = UITableViewCell(style: .default, reuseIdentifier: nil)
    cell.textLabel!.text = "foo \(indexPath.row) \(String(describing: self.data))"

    return cell
  }

  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    print("didSelect: (\(indexPath.section), \(indexPath.row))")
    self.tableView.deselectRow(at: indexPath, animated: true)
  }
}

If I create a simple app that does nothing but display ViewController:

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
  var window: UIWindow?

  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    self.window = UIWindow(frame: UIScreen.main.bounds)

    let nav = UINavigationController(rootViewController: ViewController(data: 3))
    self.window?.rootViewController = nav
    self.window?.makeKeyAndVisible()
    return true
  }
}

The table draws correctly but numberOfSections(in:) is never called! As a result, the table only shows one section (presumably because, according to the docs, UITableView uses 1 for this value if the method isn't implemented).

However, if I remove the generic declaration from the class:

class ViewController: CustomTableViewController {
  let data: Int

  init(data: Int) {
  ....
  }

  // ...
}

then numberOfSections DOES get called!

This behavior doesn't make any sense to me. I can work around it by defining numberOfSections in CustomTableViewController and then having ViewController explicitly override that function, but that doesn't seem like the correct solution: I would have to do it for any method in UITableViewDataSource that has this problem.

like image 295
Bill Avatar asked Jan 11 '18 20:01

Bill


2 Answers

If you provide default implementations of the delegate methods (numberOfSections(in:), etc.) in your base class and override them in your subclasses where appropriate, they will be called:

extension BaseTableViewController: UITableViewDataSource {
  func numberOfSections(in tableView: UITableView) -> Int {
    return 1
  }
  ...

class ViewController<X>: BaseTableViewController {
  ...
  override func numberOfSections(in tableView: UITableView) -> Int {
    // now this method gets called :)
    print("called numberOfSections")
    return 1
  }
  ...

An alternative approach would be to develop your base class based on UITableViewController which already brings most of the things you need (a table view, delegate conformance, and default implementations of the delegate methods).

EDIT

As pointed out in the comments, the main point of my solution is of course what the OP explicitly didn't want to do, sorry for that... in my defense, it was a lengthy post ;) Still, until someone with a deeper understanding of Swift's type system comes around and sheds some light on the issue, I'm afraid that it still is the best thing you can do if you don't wand to fall back to UITableViewController.

like image 28
dr_barto Avatar answered Oct 31 '22 17:10

dr_barto


This is a bug / shortcoming within the generic subsystem of swift, in conjunction with optional (and therefore: @objc) protocol functions.

Solution first

You'll have to specify @objc for all the optional protocol implementations in your subclass. If there is a naming difference between the Objective C selector and the swift function name, you'll also have to specify the Objective C selector name in parantheses like @objc (numberOfSectionsInTableView:)

@objc (numberOfSectionsInTableView:)
func numberOfSections(in tableView: UITableView) -> Int {
    // this is now called!
    print("called numberOfSections")
    return 1
}

For non-generic subclasses, this already has been fixed in Swift 4, but obviously not for generic subclasses.

Reproduce

You can reproduce it quite easy in a playground:

import Foundation

@objc protocol DoItProtocol {
    @objc optional func doIt()
}

class Base : NSObject, DoItProtocol {
    func baseMethod() {
        let theDoer = self as DoItProtocol
        theDoer.doIt!() // Will crash if used by GenericSubclass<X>
    }
}

class NormalSubclass : Base {
    var value:Int

    init(val:Int) {
        self.value = val
    }

    func doIt() {
        print("Doing \(value)")
    }
}

class GenericSubclass<X> : Base {
    var value:X

    init(val:X) {
        self.value = val
    }

    func doIt() {
        print("Doing \(value)")
    }
}

Now when we use it without generics, everything works find:

let normal = NormalSubclass(val:42)
normal.doIt()         // Doing 42
normal.baseMethod()   // Doing 42

When using a generic subclass, the baseMethod call crashes:

let generic = GenericSubclass(val:5)
generic.doIt()       // Doing 5
generic.baseMethod() // error: Execution was interrupted, reason: signal SIGABRT.

Interestingly, the doIt selector could not be found in the GenericSubclass, although we just called it before:

2018-01-14 22:23:16.234745+0100 GenericTableViewControllerSubclass[13234:3471799] -[TtGC34GenericTableViewControllerSubclass15GenericSubclassSi doIt]: unrecognized selector sent to instance 0x60800001a8d0 2018-01-14 22:23:16.243702+0100 GenericTableViewControllerSubclass[13234:3471799] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[TtGC34GenericTableViewControllerSubclass15GenericSubclassSi doIt]: unrecognized selector sent to instance 0x60800001a8d0'

(error message taken from a "real" project)

So somehow the selector (e.g. Objective C method name) cannot be found. Workaround: Add @objc to the subclass, as before. In this case we don't even need to specify a distinct method name, since the swift func name equals the Objective C selector name:

class GenericSubclass<X> : Base {
    var value:X

    init(val:X) {
        self.value = val
    }

    @objc
    func doIt() {
        print("Doing \(value)")
    }
}

let generic = GenericSubclass(val:5)
generic.doIt()       // Doing 5
generic.baseMethod() // Doing 5 
like image 166
Andreas Oetjen Avatar answered Oct 31 '22 18:10

Andreas Oetjen