Better Storyboards with Xcode 11

With the rise of SwiftUI it’s easy to miss that Apple improved storyboards back in Xcode 11 and iOS 13. If you’re not a fan of storyboards this post is unlikely to change your mind but I’m still happy to see them get better.

Note: See Using @IBSegueAction with Tab Bar Controllers if you’re trying to create a segue action for a tab bar (or navigation) controller relationship segue.

Segue Actions

Let’s suppose I’m using storyboards to segue between two view controllers:

Storyboard segue

The BookController on the left has a button which, when tapped, transitions to the PreviewController on the right to show a preview of the book. Using a storyboard, you create this transition (segue) by control-dragging from the button to the destination view controller.

Creating a segue

To complete the configuration of the segue you must add a unique identifier in the attributes inspector:

Segue in the attributes inspector

To make this segue useful, we need to pass the model data (in this case a Book) from the source to the destination view controller. In Xcode 10 and iOS 12, we did that with prepare(for:sender) in the source view controller:

// BookController
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  guard let previewController = segue.destination as? PreviewController else {
    fatalError("Missing PreviewController")
  }
  previewController.book = book
}

Note that we do not create the destination view controller. UIKit initializes the new view controller by calling its init(coder:) method. We can configure and pass data to the view controller, but only after UIKit has created it. My PreviewController has a property for the Book but it’s an optional:

// PreviewController
import UIKit

final class PreviewController: UIViewController {
  @IBOutlet private var textView: UITextView!

  var book: Book?

  override func viewDidLoad() {
    super.viewDidLoad()
    title = book?.title
    textView.text = book?.preview
  }
}

The book property is an optional as I cannot set it during the initialization of the view controller. This is unfortunate as I never change it once set. I’d like to make it non-mutable and set its value when creating the view controller.

Starting with Xcode 11 we have another way to pass data to the destination view controller. A segue action is a method in your view controller that UIKit calls during the segue so you can create the destination view controller.

You create a segue action by marking a method in your source view controller with @IBSequeAction:

@IBSegueAction
private func showPreview(coder: NSCoder, sender: Any?, segueIdentifier: String?)
    -> PreviewController? {
    return PreviewController(coder: coder, book: book)
}

The segue action method has three parameters. A required NSCoder parameter and optional sender and segue identifier properties. We can omit the optional parameters if not needed:

@IBSegueAction
private func showPreview(coder: NSCoder)
    -> PreviewController? {
    return PreviewController(coder: coder, book: book)
}

If you return nil from the method, UIKit falls back to calling the init(coder:) method to create the view controller. It does not prevent the segue from happening. Either way, the newly created view controller is passed in the segue object to prepare(for:sender). Since I no longer need it, I removed the prepare(for:sender:) code from my view controller.

Note that Swift 5.1 also allows us to omit the return statement for methods with a single expression (see SE-0255) so we can further shorten the segue action:

@IBSegueAction
private func showPreview(coder: NSCoder)
    -> PreviewController? {
    PreviewController(coder: coder, book: book)
}

To connect the storyboard segue to the segue action in the view controller, control-drag from the segue object to the view controller object and select the segue action:

Segue Action

If you connected the segue action correctly you should see the selector for the method in the attributes inspector for the segue:

Segue action selector

The PreviewController can now have a custom initializer that takes a Book passed from our segue action when creating the view controller:

// PreviewController
import UIKit
final class PreviewController: UIViewController {
  @IBOutlet private var textView: UITextView!

  let book: Book

  init?(coder: NSCoder, book: Book) {
    self.book = book
    super.init(coder: coder)
  }

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

  override func viewDidLoad() {
    super.viewDidLoad()
    title = book.title
    textView.text = book.preview
  }
}

The custom initializer must call super.init(coder:) passing the coder argument it received from the segue action. An added bonus, the Book property is no longer optional so we can change it from var to let.

Custom Initializers

You can use storyboards without having to use segues. For example, instead of creating a segue, I could connect my button to an action in the view controller:

@IBAction private func showPreview(_ sender: UIButton) {
  guard let previewController = storyboard?.instantiateViewController(
      withIdentifier: "PreviewController") as? PreviewController else {
      fatalError("Unable to create PreviewController")
  }
  previewController.book = book
  show(previewController, sender: self)
}

The showPreview method instantiates the view controller from the storyboard, configures and then presents it. This suffers from some of the same problems as the segue. We call instantiateViewController(withIdentifier:) on the storyboard and get back an initialized view controller. Any model data, like our Book, has to be an optional property of the destination view controller.

In iOS 13, there is a new version of instantiateViewController that accepts a creation block. Apple buried the details in the iOS 13 release notes:

You can now invoke a custom initializer from a creation block that’s passed through instantiateInitialViewController(creator:) or instantiateViewController(identifier:creator:).

For example, using our custom initializer to pass the model data directly:

@IBAction private func showPreview(_ sender: UIButton) {
  guard let previewController = storyboard?.instantiateViewController(
        identifier: "PreviewController", 
        creator: { coder in
        PreviewController(coder: coder, book: self.book)
    }) else {
    fatalError("Unable to create PreviewController")
  }
  show(previewController, sender: self)
}

The creator block provides the coder object we need to pass to our custom view controller initializer, which as before, must call super.init(coder:).

Too Little Too Late?

The future may be SwiftUI, but I’m still happy that storyboards are getting some polish. These two changes require Xcode 11 and iOS 13, but storyboard based projects are likely to be around for a while. So maybe it’s a case of better late than never?