iosswiftviewcontrollerexc-bad-instruction

Swift: app crashes with EXC_BAD_INSTRUCTION error?


At this point in the quiz, the app is supposed to calculate the final score. At the moment, it crashes after selecting an answer on the final question, with the title error appearing on the third line:

var score = 0
for question in QuestionController.questionsList {
score += question.selectedAnswerIndex!

Full code for that file below. It works fine in previous iterations of the app, but just started today. I know there's an issue with force-unwrapping optionals, but unclear why it only occurs in this instance:

    import UIKit

class ViewController: UIViewController {

var window: UIWindow?

override func viewDidLoad() {
  super.viewDidLoad()
  self.title="Quiz"
  self.view.backgroundColor=UIColor.white

  setupViews()
}

@objc func btnGetStartedAction() {
  let v=QuestionController()
  self.navigationController?.pushViewController(v, animated: true)
}

func setupViews() {
  self.view.addSubview(lblTitle)
  lblTitle.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 150).isActive=true
  lblTitle.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive=true
  lblTitle.widthAnchor.constraint(equalToConstant: 250).isActive=true
  lblTitle.heightAnchor.constraint(equalToConstant: 250).isActive=true

  self.view.addSubview(btnGetStarted)
  btnGetStarted.topAnchor.constraint(equalTo: lblTitle.bottomAnchor, constant: 20).isActive=true
  btnGetStarted.heightAnchor.constraint(equalToConstant: 50).isActive=true
  btnGetStarted.widthAnchor.constraint(equalToConstant: 150).isActive=true
  btnGetStarted.centerXAnchor.constraint(equalTo: self.view.centerXAnchor).isActive=true
  btnGetStarted.centerYAnchor.constraint(equalTo: self.view.centerYAnchor, constant: 0).isActive=true
}

let lblTitle: UILabel = {
  let lbl=UILabel()
  lbl.text="Have you ever wondered which character you are on Friends? Answer the question on this quiz."
  lbl.textColor=UIColor.black
  lbl.textAlignment = .center
  lbl.font = UIFont.systemFont(ofSize: 30)
  lbl.adjustsFontSizeToFitWidth = true
  lbl.numberOfLines=0
  lbl.sizeToFit()
  lbl.translatesAutoresizingMaskIntoConstraints=false
  return lbl
}()

let btnGetStarted: UIButton = {
  let btn=UIButton()
  btn.setTitle("Get Started", for: .normal)
  btn.setTitleColor(UIColor.white, for: .normal)
  btn.backgroundColor=UIColor.blue
  btn.layer.cornerRadius=5
  btn.layer.masksToBounds=true
  btn.translatesAutoresizingMaskIntoConstraints=false
  btn.addTarget(self, action: #selector(btnGetStartedAction), for: .touchUpInside)
  return btn
}()
}

struct Question {
  var questionString: String?
  var answers: [String]?
  var selectedAnswerIndex: Int?

}

class QuestionController: UIViewController, UITableViewDelegate, UITableViewDataSource {

  let cellId = "cellId"
  let headerId = "headerId"
  var tableView: UITableView?

  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    self.tableView?.frame = CGRect(x: 0, y: 64, width: self.view.frame.width, height: self.view.frame.height - 64)
  }

  static var questionsList: [Question] = [Question(questionString: "What is your favorite type of food?", answers: ["Sandwiches", "Pizza", "Seafood", "Unagi"], selectedAnswerIndex: nil), Question(questionString: "What do you do for a living?", answers: ["Paleontologist", "Actor", "Chef", "Waitress"], selectedAnswerIndex: nil), Question(questionString: "Were you on a break?", answers: ["Yes", "No"], selectedAnswerIndex: nil)]

  override func viewDidLoad() {
    super.viewDidLoad()

    navigationItem.title = "Question"

    navigationController?.navigationBar.tintColor = UIColor.white
    navigationItem.backBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: nil, action: nil)

    tableView = UITableView()
    tableView?.dataSource = self
    tableView?.delegate = self
    tableView?.estimatedRowHeight = 140
    tableView?.sectionHeaderHeight = 100
    self.tableView?.rowHeight = UITableViewAutomaticDimension
    self.view.addSubview(self.tableView!)
    tableView?.register(AnswerCell.self, forCellReuseIdentifier: cellId)
    tableView?.register(QuestionHeader.self, forHeaderFooterViewReuseIdentifier: headerId)
    tableView?.tableFooterView = UIView()
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

    if let index = navigationController?.viewControllers.index(of: self) {
      let question = QuestionController.questionsList[index]
      if let count = question.answers?.count {
        return count
      }
    }
    return 0
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath as IndexPath) as! AnswerCell
    if let index = navigationController?.viewControllers.index(of: self) {
      let question = QuestionController.questionsList[index]
      cell.nameLabel.text = question.answers?[indexPath.row]
      cell.nameLabel.numberOfLines = 0
      cell.nameLabel.lineBreakMode = .byWordWrapping
    }

    return cell
  }

  func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
    let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: headerId) as! QuestionHeader

    if let index = navigationController?.viewControllers.index(of: self) {
      let question = QuestionController.questionsList[index]
      header.nameLabel.text = question.questionString
      header.nameLabel.numberOfLines = 0
      header.nameLabel.lineBreakMode = .byWordWrapping
    }

    return header
  }

  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

    if let index = navigationController?.viewControllers.index(of: self) {
      QuestionController.questionsList[index].selectedAnswerIndex = indexPath.item

      if index < QuestionController.questionsList.count - 1 {
        let questionController = QuestionController()
        navigationController?.pushViewController(questionController, animated: true)
      } else {
        let controller = ResultsController()
        navigationController?.pushViewController(controller, animated: true)
      }
    }
  }

}

class ResultsController: UIViewController {

let resultsLabel: UILabel = {
let label = UILabel()
label.text = "Congratulations! You'd make a great Ross!"
label.contentMode = .scaleToFill
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
label.textAlignment = .center
label.font = UIFont.boldSystemFont(ofSize: 30)
return label
}()

override func viewDidLoad() {
super.viewDidLoad()

navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Done", style: .plain, target: self, action: #selector(done(sender:)))

navigationItem.title = "Results"

view.backgroundColor = UIColor.white

view.addSubview(resultsLabel)
view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "H:|[v0]|", options: NSLayoutFormatOptions(), metrics: nil, views: ["v0": resultsLabel]))
view.addConstraints(NSLayoutConstraint.constraints(withVisualFormat: "V:|[v0]|", options: NSLayoutFormatOptions(), metrics: nil, views: ["v0": resultsLabel]))

let names = ["Ross", "Joey", "Chandler", "Monica", "Rachel", "Phoebe"]

var score = 0
for question in QuestionController.questionsList {
  score += question.selectedAnswerIndex!
}

let result = names[score % names.count]
resultsLabel.text = "Congratulations! \(result)."
}

@objc func done(sender: UIBarButtonItem) {

navigationController?.popToRootViewController(animated: true)

}

}

Solution

  • The problem is caused by your use of the view controller's position in the navigation controller's viewControllers array to determine the question index. The "get started" view controller is at position 0, and your first "question" view controller is at position 1.

    This means that you never ask question '0' about the user's food preferences.

    Then when you iterate through the questions and force unwrap selectedAnswerIndex you get a crash.

    In general you should never force unwrap unless:

    1. You know the value isn't nil
    2. There is nothing else you could do anyway

    A second point is don't try to be too clever. You could pass a simple int of the current question index to your view controller. While this isn't as "clever" as using the viewControllers array to determine the question index, it is much easier to see what is going on and won't break if the number of view controllers in the stack changes.

    class QuestionController: UIViewController, UITableViewDelegate, UITableViewDataSource {
    
        let cellId = "cellId"
        let headerId = "headerId"
        var tableView: UITableView?
        var questionIndex = 0
    
        ....
    
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    
            return QuestionController.questionsList[index].answers?.count ?? 0
        }
    
    
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: cellId, for: indexPath as IndexPath) as! AnswerCell
    
            let question = QuestionController.questionsList[questionIndex]
            cell.nameLabel.text = question.answers?[indexPath.row]
            cell.nameLabel.numberOfLines = 0
            cell.nameLabel.lineBreakMode = .byWordWrapping
    
            return cell
        }
    
        func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
            let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: headerId) as! QuestionHeader
            let question = QuestionController.questionsList[questionIndex]
            header.nameLabel.text = question.questionString
            header.nameLabel.numberOfLines = 0
            header.nameLabel.lineBreakMode = .byWordWrapping
    
            return header
       }
    
    
       func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    
           QuestionController.questionsList[questionIndex].selectedAnswerIndex = indexPath.item
    
           if questionIndex < QuestionController.questionsList.count - 1 {
               let questionController = QuestionController()
               questionController.questionIndex = questionIndex+1
               navigationController?.pushViewController(questionController, animated: true)
           } else {
               let controller = ResultsController()
               navigationController?.pushViewController(controller, animated: true)
           }
        }
    }
    

    Not only does this code work, it is easier to see what is going on and it is fewer lines of code because you don't need to keep unwrapping the optional index from the view controllers array; for example, numberOfRowsInSection goes from 7 lines to 1.

    Also, in this case I can't see a good reason for the questionString and answers properties of the Question struct to be optional; A question must have a question and a set of answers. I would probably also change questionString to just question - putting the type in the property name is redundant.

    The tableView property of your QuestionController is a good place to use an implicitly unwrapped optional - You know there is going to be tableview because one of the first things your code does is create it. Using an implicitly unwrapped optional (UITableView! instead of UITableView?) will save you from having to continually unwrap it.