Why this happens
In UIKit storyboard architectures, storyboard transitions trigger the prepare(for:sender:) lifecycle method on the presenting UIViewController. The UIKit framework fixes the method signature to accept only two parameters: the UIStoryboardSegue object and an optional Any? sender. Because you cannot change this method signature to accept custom arguments, passing arbitrary data models—such as selected items, database records, or coordinate annotations—requires specific patterns.
Developers frequently encounter two primary obstacles when handling segues:
- Dynamic context: When a user taps an accessory button (like a
MKAnnotationViewcallout) or an arbitrary button on screen, the source view controller knows which model triggered the action, butprepare(for:sender:)runs separately from the tap handler. - Type erasure: The
destinationproperty onUIStoryboardSegueis typed as a genericUIViewController. If the destination view controller is embedded in aUINavigationController, accessing custom properties on the target controller causes a runtime failure unless the navigation container is unwrapped first.
How to fix it
1. Pass data directly by downcasting the destination view controller
The most standard approach checks the segue identifier, downcasts segue.destination to your custom view controller type, and directly assigns the required data to a public property.
First, define a receiving property on your destination view controller:
import UIKit
class DetailViewController: UIViewController {
// Public property to receive data
var selectedCity: String?
override func viewDidLoad() {
super.viewDidLoad()
if let city = selectedCity {
print("Loaded details for: \(city)")
}
}
}
In your presenting view controller, override prepare(for:sender:) and assign the data:
import UIKit
class MasterViewController: UIViewController {
private let destinationSegueID = "ShowDetailSegue"
private var currentCity = "Austin"
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == destinationSegueID else { return }
if let detailVC = segue.destination as? DetailViewController {
detailVC.selectedCity = currentCity
}
}
}
2. Forward the object through performSegue(withIdentifier:sender:)
Storing dynamic items in intermediate instance variables on the source view controller creates mutable state that can easily get out of sync. A cleaner solution passes the model object directly into the sender parameter when initiating the segue programmatically.
This pattern works especially well when handling multiple buttons, table cells, or map annotations:
import UIKit
import MapKit
struct LocationModel {
let name: String
let coordinate: CLLocationCoordinate2D
}
class MapViewController: UIViewController, MKMapViewDelegate {
@IBOutlet weak var mapView: MKMapView!
private let detailSegueID = "ShowLocationDetail"
// Triggered when a user taps a callout accessory button
func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
guard let annotation = view.annotation else {
return
}
let selectedLocation = LocationModel(
name: annotation.title ?? "Unknown",
coordinate: annotation.coordinate
)
// Pass the model directly into the sender argument
performSegue(withIdentifier: detailSegueID, sender: selectedLocation)
}
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == detailSegueID else { return }
// Cast both the destination controller and the sender object
if let detailVC = segue.destination as? DetailViewController,
let location = sender as? LocationModel {
detailVC.selectedCity = location.name
}
}
}
Using sender ensures that whatever object triggered the segue is forwarded directly to the preparation step without holding temporary instance properties.
3. Handle destinations embedded inside a UINavigationController
When a segue presents modally, storyboard configurations often embed the target view controller inside a UINavigationController. In this scenario, segue.destination is the UINavigationController, not your custom subclass. Attempting a direct cast like segue.destination as? DetailViewController fails and yields nil.
Resolve this by unwrapping topViewController:
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard segue.identifier == "ShowModalDetail" else { return }
// Handle direct presentation or embedded navigation controller
let targetVC: DetailViewController?
if let navController = segue.destination as? UINavigationController {
targetVC = navController.topViewController as? DetailViewController
} else {
targetVC = segue.destination as? DetailViewController
}
if let detailVC = targetVC, let location = sender as? LocationModel {
detailVC.selectedCity = location.name
}
}
4. Alternative: Use storyboard creator blocks (iOS 13+)
If your deployment target supports iOS 13 or later, you can bypass prepare(for:sender:) entirely by using instantiateViewController(creator:). This pattern allows true dependency injection through custom initializers, eliminating the need for optional or implicitly unwrapped properties on the destination.
Define a custom initializer on the destination view controller:
import UIKit
class DetailViewController: UIViewController {
let selectedCity: String
init?(coder: NSCoder, selectedCity: String) {
self.selectedCity = selectedCity
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented. Use init(coder:selectedCity:).")
}
}
Instantiate and present it programmatically without an outlet-based segue:
func showDetail(for cityName: String) {
guard let detailVC = storyboard?.instantiateViewController(identifier: "DetailViewController", creator: { coder in
DetailViewController(coder: coder, selectedCity: cityName)
}) else {
return
}
navigationController?.pushViewController(detailVC, animated: true)
}
How to prevent it
- Avoid intermediate state: Do not store transient tap data in class-level instance variables solely to read it in
prepare(for:sender:). Pass it via thesenderparameter inperformSegue(withIdentifier:sender:). - Unwrap navigation controllers: Always check if
segue.destinationis aUINavigationControllerbefore attempting to cast to your target view controller. - Use string enums for segue IDs: Replace raw strings with strongly typed
enum SegueIdentifier: Stringvalues to catch typos at compile time. - Adopt dependency injection: Where possible, replace storyboard segues with
IBSegueActionorinstantiateViewController(creator:)to enforce non-optional parameters on destination controllers.
FAQ
Why is segue.destination nil or failing the downcast?
The destination view controller is likely wrapped in a UINavigationController or UITabBarController. Cast segue.destination to UINavigationController first, then inspect its topViewController or viewControllers.first property to access your custom class.
Can I pass multiple items through the sender parameter?
Yes. Because sender is of type Any?, you can pass a custom struct, a Dictionary, a tuple, or an array of objects. In prepare(for:sender:), cast sender to that specific tuple or struct type.
How can the destination view controller send data back to the presenter?
Define a delegate protocol on the destination view controller. In prepare(for:sender:), set destinationVC.delegate = self. When the destination finishes its work, it calls the delegate method on the presenting controller before dismissing itself.
Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.
