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:
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.
To complete the configuration of the segue you must add a unique identifier 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:
If you connected the segue action correctly you should see the selector for the method in the attributes inspector for the segue:
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?