We’re big fans of building UIs in Interface Builder with Storyboards. But unfortunately, when it comes to passing data around your app, they sometimes fall short.
We wrote Perform to help us solve the problems we were having using Dependency Injection with Storyboards and Segues.
The problem with prepare(for:sender:)
(Known as prepareForSegue(_:sender:)
in Swift 2.)
Imagine you’re writing an iOS app that displays an activity feed. You begin
with a simple UITableViewController
subclass, and the only type of cell is a
status update. When a status update is tapped, you push a detail screen
showing the conversation on that post.
Here’s how you pass data along to the conversation screen:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
viewModel.selectStatus(at: indexPath)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
if let conversation = segue.destinationViewController as? ConversationViewController {
conversation.status = viewModel.selectedStatus
viewModel.clearSelection()
}
}
There are some things about this code you find problematic:
- The code to show the conversation screen is spread over two methods.
- You’re keeping track of extra state so that in
prepare(for:sender:)
you can pass along the selected status. - It’s not obvious, but
ConversationViewController.status
is of typeStatus!
, andviewModel.selectedStatus
isStatus?
. IfselectStatus
was unexpectedlynil
, you won’t know until some code inConversationViewController
causes a crash. - If the type of the destination view controller changes, this code will silently fail, likely causing another surprising crash.
However, it works! You move onto the next feature: sharing a status. You add a “Share” button to the cell, which presents a modal compose screen so the user can type a custom message before posting to their own feed.
@IBAction func share(sender: Any?) {
// `indexPath(for:in:)` is a helper to find the index path of a subview
// inside a table view cell
guard let indexPath = indexPath(for: sender, in: tableView) else { return }
viewModel.selectStatus(at: indexPath)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
// other segue handlers...
if let nav = segue.destinationViewController as? UINavigationController,
let share = nav.topViewController as? ShareStatusViewController
{
share.sharedStatus = viewModel.selectedStatus
viewModel.clearSelection()
}
}
This method is starting to get long, and now there are a couple of new problems:
- Because the modal compose view controller is inside a
UINavigationController
, we have to perform an intermediate cast just so we can access itstopViewController
. - We’ve reused
viewModel.selectedStatus
. What if the value is actually a stale selection that we forgot to clear? There’s not really any way to know, and that would be really challenging to debug.
How Perform solves these problems
Let’s refactor this code to use Perform. The first step is to declare all the
information about our segues in one place, using Perform’s Segue
type.
import Perform
extension Segue {
static var showConversation: Segue<ConversationViewController> {
return .init(identifier: "ShowConversation")
}
static var shareStatus: Segue<ShareStatusViewController> {
return .init(identifier: "ShareStatus")
}
}
Segue
is a very simple type — here’s the entire definition:
public struct Segue<Destination: UIViewController> {
public let identifier: String
public init(identifier: String) {
self.identifier = identifier
}
}
Its sole property is the segue identifier, and it lets us specify the expected
type of the Destination
view controller.
With our segues defined, we can now use the perform(_:prepare:)
method to
initiate the segue and prepare the destination view controller in one step —
by just passing in a function or closure:
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let status = viewModel.status(at: indexPath)
perform(.showConversation) { conversationVC in
// conversationVC has already been cast to the correct type
conversationVC.status = status
}
}
While we’re at it, let’s fix sharing, too:
@IBAction func share(sender: Any?) {
guard let indexPath = indexPath(for: sender, in: tableView) else { return }
let status = viewModel.status(at: indexPath)
perform(.shareStatus) { shareVC in
// Perform automatically extracted shareVC from the navigation controller!
shareVC.sharedStatus = status
}
}
That’s it! We can now delete our entire implementation of prepare(for:sender:)
.
Not to mention all the code to manage selections!
How does it work?
Perform uses the excellent Aspects library to dynamically implement
prepare(for:sender:)
; it then locates and casts the destination view
controller, and passes it along to your prepare
function.
Inject your dependencies with confidence
Storyboards and segues aren’t perfect, but they can be a really valuable tool for building your app’s UI. And with just a little help, we can remove most of the pain of passing data around between view controllers.
Check out Perform on GitHub.