Introducing Perform: Easy dependency injection for storyboard segues

Adam Sharp

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 type Status!, and viewModel.selectedStatus is Status?. If selectStatus was unexpectedly nil, you won’t know until some code in ConversationViewController 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 its topViewController.
  • 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.