I'm testing a simple tableView in a UIViewController for fun
class ViewController: UIViewController {
#IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
setup()
}
func setup() {
tableView.dataSource = self
tableView.delegate = self
tableView.register(CustomTableViewCell.self, forCellReuseIdentifier: "CustomTableViewCell")
}
var data = [1,2,3,4,5,6,7]
}
extension ViewController : UITableViewDelegate {
}
extension ViewController : UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return data.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CustomTableViewCell", for: indexPath)
cell.textLabel?.text = data[indexPath.row].description
return cell
}
}
and I want to write a test to check that the correct data is being displayed in a presented cell.
My test looks like the following:
var controller: ViewController?
override func setUp() {
controller = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "ViewController") as? ViewController
}
func testViewCell() {
guard let controller = controller else {
return XCTFail("Could not instantiate ViewController")
}
let tableCell = Bundle(for: CustomTableViewCell.self).loadNibNamed("CustomTableViewCell", owner: nil)?.first as! CustomTableViewCell
tableCell.textLabel?.text = "2"
controller.loadViewIfNeeded()
let actualCell = controller.tableView!.cellForRow(at: IndexPath(row: 0, section: 0) )
XCTAssertEqual(actualCell, tableCell)
}
But the actual cell is nil. How can I test the presented cell in my view controller against an expected cell?
In your case I believe you will need to call reloadData on the table view as well. Try:
func testViewCell() {
guard let controller = controller else {
return XCTFail("Could not instantiate ViewController")
}
let tableCell = Bundle(for: CustomTableViewCell.self).loadNibNamed("CustomTableViewCell", owner: nil)?.first as! CustomTableViewCell
tableCell.textLabel?.text = "2"
controller.loadViewIfNeeded()
controller.tableView!.reloadData()
let actualCell = controller.tableView!.cellForRow(at: IndexPath(row: 0, section: 0) )
XCTAssertEqual(actualCell, tableCell)
}
In general for these cases I would also be worried about the view controller size. Since this is not put to any window it might in some cases use some intrinsic size and if that is for instance set to 0 your cells will not be there either. Maybe you should consider creating a window with fixed size (the size you want to test on) and apply your view controller as a root to it.
Also what do you expect to get from XCTAssertEqual(actualCell, tableCell)? Not sure but I would say this tests only pointers and will always fail. You will need to implement your own logic to check equality.
Related
I have followed this tutorial to create a custom .xib, which I plan to use in a table view's cell:
https://medium.com/#brianclouser/swift-3-creating-a-custom-view-from-a-xib-ecdfe5b3a960
Here is the .xib's class I created:
class UserView: UIView {
#IBOutlet var view: UIView!
#IBOutlet weak var username: UILabel!
override init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
initialize()
}
private func initialize() {
Bundle.main.loadNibNamed("UserView", owner: self, options: nil)
addSubview(view)
view.frame = self.bounds
view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
}
}
Previously, I was creating my table view cell within the storyboard, but I've come to realize that I want a more flexible view so that I can use it in different parts of my app, so I created the above custom .xib, UserView.
I have updated the table view cell in the storyboard to use the custom .xib:
https://i.stack.imgur.com/t7Tr7.png
Here is what my table view controller class looked like prior to creating the custom .xib (i.e. making the layout in the storyboard):
class UserTableViewController: UITableViewController {
// MARK: Properties
let provider = MoyaProvider<ApiService>()
var users = [User]()
override func viewDidLoad() {
super.viewDidLoad()
tableView.estimatedRowHeight = 100
tableView.rowHeight = UITableViewAutomaticDimension
// Fetch the user by their username
provider.request(.getUsers()) { result in
switch result {
case let .success(response):
do {
let results = try JSONDecoder().decode(Pagination<[User]>.self, from: response.data)
self.users.append(contentsOf: results.data)
self.tableView.reloadData()
} catch {
print(error)
}
case let .failure(error):
print(error)
break
}
}
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
}
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return users.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = "UserTableViewCell"
guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? UserTableViewCell else {
fatalError("The dequeued cell is not an instance of UserTableViewCell.")
}
let user = users[indexPath.row]
cell.username.text = user.username
return cell
}
}
Here is the table view cell class:
class UserTableViewCell: UITableViewCell {
//MARK: Properties
#IBOutlet weak var userView: UserView!
override func awakeFromNib() {
super.awakeFromNib()
// Initialization code
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
// Configure the view for the selected state
}
}
My question is, how do I update the above table view controller class to use my custom .xib, instead of using the storyboard layout?
You can use 2 ways:
Create UITableViewCell (better)
1) Change UIView to UITableViewCell
class CustomTableViewCell: UITableViewCell {
...
class var identifier: String {
return String(describing: self)
}
}
2) Register your cell
override func viewDidLoad() {
super.viewDidLoad()
self.tableView.registerNib(UINib(nibName: CustomTableViewCell.identifier, bundle: nil), forCellReuseIdentifier: CustomTableViewCell.identifier)
...
}
3) Use cellForRow(at:)
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: CustomTableViewCell.identifier) as! CustomTableViewCell
cell.username.text = user.username
return cell
}
OR Add view as subview to cell (only in rare cases)
1) Add this to UserView
class UserView: UIView {
...
class func fromNib() -> UserView {
return UINib(nibName: String(describing: self), bundle: nil).instantiate(withOwner: nil, options: nil)[0] as! UserView
}
}
2) Use cellForRow(at:)
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cellIdentifier = "UserTableViewCell"
guard let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as? UserTableViewCell else {
fatalError("The dequeued cell is not an instance of UserTableViewCell.")
}
let userView = UserView.fromNib()
let user = users[indexPath.row]
userView.username.text = user.username
//Use frame size, but for me better to add 4 constraints
userView.frame = CGRect(x: 0, y: 0, width: cellWidth, height: cellHeight)
cell.contentView.addSubview(UserView)
return cell
}
Here is my current setup:
MainMenuViewController -> SubMenuViewController -> UserInputViewController -> ResultViewController
All of my view controllers contains a tableView.
When a user taps on a cell in MainMenuViewControlle, it will segue to SubMenuViewController. All fine and dandy.
Here is where it gets tricky, in SubMenuViewController, there are cells that needs to instantiate another SubMenuViewController because the sub menu options can be several levels deep.
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let selectedNode = node?.childenNode[indexPath.row] else {
return
}
if selectedNode.isLeaveNode() {
performSegue(withIdentifier: "userInput", sender: self)
} else {
let subMenuViewController = SubMenuViewController(node: selectedNode)
self.navigationController?.pushViewController(subMenuViewController, animated: true)
}
When there are no child nodes, then it will segue to UserInputViewController, but when there are more options, it needs to instantiate another SubMenuViewController and the tableView will populate itself based on cell the user had tapped previously until selectedNode.isLeaveNode() is true (which means there won't be any child nodes).
This problem is when this code runs :
let subMenuViewController = SubMenuViewController(node: selectedNode)
self.navigationController?.pushViewController(subMenuViewController, animated: true)
it give me the following error:
fatal error: unexpectedly found nil while unwrapping an Optional value
From where I registered my cells which is here:
let bundle = Bundle(for: type(of: self))
let nib = UINib(nibName: "SubMenuTableViewCell", bundle: bundle)
tableView.register(nib, forCellReuseIdentifier: "SubMenuCell")
All my tableView cells are instantiated using a xib file, and I have registered my cells in viewDidLoad()
Can anybody see the problem?
UPDATE
Here is the rest of my code:
UIViewController
class SubMenuViewController: UIViewController {
var node: Node?
init(node: Node) {
self.node = node
super.init(nibName: nil, bundle: nil)
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
#IBOutlet weak var tableView: UITableView!
override func viewDidLoad() {
super.viewDidLoad()
self.navigationController?.isNavigationBarHidden = false
self.navigationItem.title = node?.value.rawValue
let bundle = Bundle(for: type(of: self))
let nib = UINib(nibName: "SubMenuTableViewCell", bundle: bundle)
tableView.register(nib, forCellReuseIdentifier: "SubMenuCell")
}
}
UITableViewDataSource
extension SubMenuViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return node!.childCount
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
print("called")
let cell = tableView.dequeueReusableCell(withIdentifier: "SubMenuCell", for: indexPath) as! SubMenuTableViewCell
let desciptionModule = node?.childenNode[indexPath.row].value
let description = Modules.description(module: desciptionModule!)
cell.title.text = description.main
cell.subtitle.text = description.sub
return cell
}
}
UITableViewDelegate
extension SubMenuViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return 68
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let selectedNode = node?.childenNode[indexPath.row] else {
return
}
if selectedNode.isLeaveNode() {
performSegue(withIdentifier: "userInput", sender: self)
} else {
let subMenuViewController = SubMenuViewController(node: selectedNode)
self.navigationController?.pushViewController(subMenuViewController, animated: true)
}
}
}
In your viewDidLoad() method, make sure to do the following:
Based on the code you've posted, you might be having issues with this line:
let bundle = Bundle(for: type(of: self))
I would recommend replacing it with the following line:
let bundle = Bundle(forClass: self)
or
let bundle = Bundle.main
If you're still having trouble with this, then try modifying the following lines of code:
let nib = UINib(nibName: "SubMenuTableViewCell", bundle: bundle)
to
let nib = UINib(nibName: "SubMenuTableViewCell", bundle: nil)
In your tableView:cellForRowAtIndexPath UITableViewControllerDelegate method, include the following lines:
var cell = tableView.dequeueReusableCellWithIdentifier("SubMenuCell") as? UITableViewCell
if cell == nil {
tableView.registerNib(UINib(nibName: "SubMenuTableViewCell", bundle: nil), forCellReuseIdentifier: "SubMenuCell")
cell = tableView.dequeueReusableCellWithIdentifier("SubMenuCell") as SubMenuTableViewCell!
}
cell.configure(data: data[indexPath.row])
tableView.reloadData()
return cell
Note
Make sure that the UITableViewCell's reuseIdentifier is "SubMenuCell"
Make sure that the SubMenuTableViewCell.xib file's owner is SubMenuTableViewCell
Make sure the Module does not say "None" (i.e. the Module should be the name of your Project's Target).
Make sure to call tableView.reloadData() in your viewDidLoad() function.
Problem solved
The problem lies in the fact that I instantiated my view controller wrong. The line:
let subMenuViewController = SubMenuViewController(node: selectedNode)
only return a raw object with no outlets, that is why I was getting the optional nil error because there weren't a tableView to start with.
The correct approach would be to instantiate it using your storyboard. Each view controller has a storyboard property as an optional, since my view controller exists in a storyboard, that property will not be nil.
Instead of doing this:
let subMenuViewController = SubMenuViewController(node: selectedNode)
self.navigationController?.pushViewController(subMenuViewController, animated: true)
I actually needed to do this:
let subMenuViewController = storyboard!.instantiateViewController("SubMenuViewController")
That way I know for sure that my subMenuViewController will contain my tableView property which is an IBOutlet from storyboard.
I have a tableview inside a VC that has a navigation controller and it contains custom table cells. I was wondering what the best practice is for pushing onto the parent VC's navigation stack if a button in the custom table cell is tapped. I am able to get this to work if i pass the parent VC's navigation controller to the cell; but is this the most effective/efficient practice? Please see my current implementation below:
UserAccountVC:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell:TextPostTableViewCell = Bundle.main.loadNibNamed("TextPostTableViewCell", owner: self, options: nil)?.first as! TextPostTableViewCell
cell.setupCell(navigationController: self.navigationController!)
cell.selectionStyle = .none
return cell
}
CustomTableCell:
import UIKit
class TextPostTableViewCell: UITableViewCell {
var aNavigationController: UINavigationController!
//MARK: Actions
#IBAction func profilePicButtonTapped() { //We want to present a users profile
let sb = UIStoryboard(name: "SuccessfulLogin", bundle: nil)
let cc = (sb.instantiateViewController(withIdentifier: "otherUserViewController")) as! OtherUserAccountViewController
self.aNavigationController.pushViewController(cc, animated: true)
}
func setupCell(navigationController: UINavigationController) -> Void {
aNavigationController = navigationController
}
}
Thank you in advance!
No, this is not best practice. You can setup an IBAction in interface builder for your UIButton or add your UIViewController as a target in cellForRowAt. With either method you may need some method of identifying the indexPath, since you are not using didSelectRow in your tableview delegate:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell:TextPostTableViewCell = Bundle.main.loadNibNamed("TextPostTableViewCell", owner: self, options: nil)?.first as! TextPostTableViewCell
cell.button.tag = indexPath.row // Or use some other method of identifying your data in `myAction(_:)`
cell.button.addTarget(self, action:, #selector(myAction(_:)), for: .touchUpInside)
...
}
You can use delegate in this situation.
The code is a bit more here, but this is better way in iOS development IMO.
class ViewController: UIViewController {
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell:TextPostTableViewCell = Bundle.main.loadNibNamed("TextPostTableViewCell", owner: self, options: nil)?.first as! TextPostTableViewCell
cell.delegate = self
cell.selectionStyle = .none
return cell
}
}
extension ViewController: TextPostTableViewCellDelegate {
func didTappedProfilePicButton() {
let sb = UIStoryboard(name: "SuccessfulLogin", bundle: nil)
let cc = (sb.instantiateViewController(withIdentifier: "otherUserViewController")) as! OtherUserAccountViewController
navigationController?.pushViewController(cc, animated: true)
}
}
protocol TextPostTableViewCellDelegate: class {
func didTappedProfilePicButton()
}
class TextPostTableViewCell: UITableViewCell {
weak var delegate: TextPostTableViewCellDelegate?
//MARK: Actions
#IBAction func profilePicButtonTapped() { //We want to present a users profile
delegate?.didTappedProfilePicButton()
}
}
I have a swift app based on Master-Detail template. Every row in MasterView table is based on custom cell received from a nib. Every cell includes UIlabel and UIbutton. The logic of the app is following. If user taps on a row DetailView shows some details depending on selected row. The button on the row does not call tableView(_, didSelectRowAtIndexPath). If user taps on the button inside a row only an image belongs to DetailView should be changed (other elements on DetailView remain the same) but it isn't. If I select another row and than select previous row back, changed image is shown on the DetailView as it was foreseen. The question is how to redraw the image in the DetailView just by tapping on the button.
I've tried to do following but with no success:
class MasterViewCell: UITableViewCell {
weak var detailViewController: DetailViewController?
#IBAction func buttonTap(sender: AnyObject) {
//method to set new image
detailViewController!.setNewImage()
detailViewController!.view.setNeedsDisplay()
}
}
class MasterViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
let nib = UINib(nibName: "itemCell", bundle: nil)
tableView.registerNib(nib, forCellReuseIdentifier: "Cell")
if let split = self.splitViewController {
let controllers = split.viewControllers
self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as? MasterViewCell
cell?.detailView = self.detailViewController
return cell!
}
You need to use a handler
typealias ButtonHandler = (Cell) -> Void
class Cell: UITableViewCell {
var changeImage: ButtonHandler?
func configureButton(changeImage: ButtonHandler?) {
self.changeImage = changeImage
}
#IBAction func buttonTap(sender: UIButton) {
changeImage?(self)
}
}
And in your MasterView
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as! Cell
cell.configureButton(setNewImage())
return cell
}
private func setNewImage() -> ButtonHandler {
return { [unowned self] cell in
let row = self.tableView.indexPathForCell(cell)?.row //Get the row that was touched
//set the new Image
}
}
SOURCE: iOS Swift, Update UITableView custom cell label outside of tableview CellForRow using tag
I've found the solution. I've used protocol-delegate mechanism. Now the code is:
//protocol declaration:
protocol MasterViewCellDelegate: class {
func updateImage(sender: MasterViewCell, detVC: DetailViewController)
}
// cell class
class MasterViewCell: UITableViewCell {
weak var masterViewCellDelegate: MasterViewCellDelegate? // protocol property
weak var masterViewController: MasterViewController? {
didSet {
// set delegate
self.masterViewDelegate = masterViewController!.detailViewController
}
}
#IBAction func buttonTap(sender: AnyObject) {
var detVC: DetailViewController?
if let split = masterViewController!.splitViewController {
let controllers = split.viewControllers
detVC = (controllers[controllers.count - 1] as! UINavigationController).topViewController as? DetailViewController
}
// call delegate
masterViewCellDelegate?.updateImage(self, detVC: detVC)
}
class MasterViewController: UITableViewController {
override func viewDidLoad() {
super.viewDidLoad()
let nib = UINib(nibName: "itemCell", bundle: nil)
tableView.registerNib(nib, forCellReuseIdentifier: "Cell")
if let split = self.splitViewController {
let controllers = split.viewControllers
self.detailViewController = (controllers[controllers.count-1] as! UINavigationController).topViewController as? DetailViewController
}
}
override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("Cell", forIndexPath: indexPath) as? MasterViewCell
cell?.masterViewController = self
return cell!
}
// declare detailviewcontroller as delegate
class DetailViewController: UIViewController, MasterViewCellDelegate {
func updateImage(sender: MasterViewCell, detVC: DetailViewController){
detVC.setNewImage()
}
}
It may well be that this solution is excessively complex, but it works and easy could be adapted for various purposes.
My haphazard attempt isn't calling cellForRowAtIndexPath . I'm assuming my setup is not right.
let wordTableView = WordTableView(frame: CGRectZero)
wordTableView.delegate = self
wordTableView.dataSource = self
wordTableView.words = words
let currentCell = wordTableView.cellForRowAtIndexPath(indexPath)
cell.contentView.addSubview(wordTableView)
wordTableView.viewDidLoad()
wordTableView.reloadData()
When I run this, my UITableView's viewDidLoad() get's called. But nothing actually loads. If I look at the Debug View Hierarchy, I can see that nothing has been added. So I assume I'm missing something particular about instantiating this tableView.
For reference, this is my UITableView. But to be honest, I'm completely guessing about what exactly a UITableView would need to get instantiated. And this would be my best attempt :
class WordTableView: UITableView {
var words = [Word]()
func viewDidLoad() {
self.registerNib(UINib(nibName: "WordCell", bundle: nil), forCellReuseIdentifier: "wordCell")
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return words.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("WordCell", forIndexPath: indexPath) as! WordCell
let word = words[indexPath.row]
cell.sanskrit.text = "Sanskrit" //word.valueForKey("sanskrit")
cell.definition.text = "Definition" // word.valueForKey("definition")
return cell
}
}
You set wordTableView data source and delegate to the setter class, so table view data source and delegate function will not be called in WordTableView class.
Since you said your viewDidLoad is called, let set the delegate inside this function.
let wordTableView = WordTableView(frame: CGRectZero)
wordTableView.words = words
let currentCell = wordTableView.cellForRowAtIndexPath(indexPath)
cell.contentView.addSubview(wordTableView)
wordTableView.viewDidLoad()
wordTableView.reloadData()
And WordTableView class
class WordTableView: UITableView, UITableViewDelegate, UITableViewDataSource {
var words = [Word]()
func viewDidLoad() {
self.registerNib(UINib(nibName: "WordCell", bundle: nil), forCellReuseIdentifier: "wordCell")
self.delegate = self
self.dataSource = self
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return words.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("WordCell", forIndexPath: indexPath) as! WordCell
let word = words[indexPath.row]
cell.sanskrit.text = "Sanskrit" //word.valueForKey("sanskrit")
cell.definition.text = "Definition" // word.valueForKey("definition")
return cell
}
}
As a side note, you should not call viewDidLoad() directly and it is better if you make WordTableView inherit UIViewController and put your table view inside it.
I think your viewDidLoad is being called because you are doing so explicitly, but your frame is CGRectZero, so it technically has no size, so there is nothing to add as a subView, so nothing gets called.
I think a better way to initialise the table view is to create your own initialiser, e.g.
class WordTableView: UITableView {
var words = [Word]()
init(frame: CGRect, wordArray: [Word], delegate: UITableViewDelegate, dataSource: UITableViewDataSource) {
self.frame = frame
words = wordArray
self.delegate = delegate
self.dataSource = dataSource
self.reloadData()
}
func viewDidLoad() {
self.registerNib(UINib(nibName: "WordCell", bundle: nil), forCellReuseIdentifier: "wordCell")
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return words.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("WordCell", forIndexPath: indexPath) as! WordCell
let word = words[indexPath.row]
cell.sanskrit.text = "Sanskrit" //word.valueForKey("sanskrit")
cell.definition.text = "Definition" // word.valueForKey("definition")
return cell
}
}
And then in your setup:
let wordTableView = WordTableView(frame: cell.contentView.bounds, wordArray: words, delegate: self, dataSource: self)
let currentCell = wordTableView.cellForRowAtIndexPath(indexPath)
cell.contentView.addSubview(wordTableView)
cell.setNeedsDisplay()