How to Pass Data Between View Controllers in Swift
Passing information between view controllers is a basic UIKit skill that appears in nearly every iOS app. A screen may collect a customer’s name, display a selected product, show a profile image, or return a changed setting to the previous screen. The best approach depends on the direction of the data, the relationship between the screens, and how much responsibility each view controller should have.
For junior developers in Australia, these patterns are useful whether you are building a small app in Brisbane, preparing a portfolio project in Melbourne, or experimenting with an idea for the local App Store market. The key is to make data movement predictable, type-safe, and easy to test rather than relying on hidden global state.
Understand The Direction Of Data
The most common case is sending data from one view controller to another before the second screen appears. For example, a table view controller might open a details screen after the user taps a book. The selected book can be assigned to a property on the destination view controller.
final class DetailsViewController: UIViewController {
var book: Book!
override func viewDidLoad() {
super.viewDidLoad()
title = book.title
}
}
The source controller configures this property before performing the segue or pushing the screen:
let details = storyboard?
.instantiateViewController(withIdentifier: "DetailsViewController")
as! DetailsViewController
details.book = selectedBook
navigationController?.pushViewController(details, animated: true)
This is direct, readable, and appropriate when the destination needs data immediately. Prefer a required initialiser or a non-optional property where possible, because an implicitly unwrapped optional can crash if the value is forgotten. A good rule is that the destination should not need to guess whether its essential data exists.
Storyboards use prepare(for:sender:) for the same purpose. In that method, check the destination type and assign its values before navigation. With programmatic UIKit, dependency injection through an initialiser is often clearer because the compiler can require the data at construction time.
Send Values With Navigation
When using a storyboard segue, override prepare(for:sender:) in the source controller. The sender can identify which row or button triggered the transition, while the destination view controller receives the selected model object.
override func prepare(
for segue: UIStoryboardSegue,
sender: Any?
) {
guard
let details = segue.destination as? DetailsViewController,
let book = sender as? Book
else { return }
details.book = book
}
For a navigation controller embedded in the segue, the actual destination may be a UINavigationController. In that situation, retrieve its viewControllers.first and cast that child to the expected type. This small detail prevents a common “cast failed” problem when the screen looks correct in Interface Builder but is wrapped in navigation.
Data can also be passed while presenting a modal screen. A settings screen might receive a user profile, an editing form could receive an existing address, and a checkout screen might receive a cart. A modal presentation is still just a relationship between a source and destination, so the same property or initialiser approach applies.
Australia’s iOS market includes apps for public transport, local events, food delivery, and small businesses. In a Sydney events app, for instance, passing an event model into a detail controller is more reliable than asking the detail screen to search for the event again. Keeping the selected object explicit also makes behaviour easier to reproduce across different devices and network conditions.
Return Data With Closures And Delegates
Sending data forward is usually simple; returning data to the previous controller needs a callback. A closure is a compact option for one result. An editing screen can expose a completion property and call it when the user saves.
final class EditNameViewController: UIViewController {
var initialName = ""
var onSave: ((String) -> Void)?
@IBAction func saveTapped(_ sender: UIButton) {
let name = nameTextField.text ?? ""
onSave?(name)
dismiss(animated: true)
}
@IBOutlet private weak var nameTextField: UITextField!
}
The presenting controller sets the closure before showing the screen:
editController.onSave = { [weak self] newName in
self?.profileName = newName
self?.title = newName
}
The [weak self] capture avoids retaining the source controller through the closure. This is especially important when the callback is stored for a while or when an asynchronous operation is involved. A closure works well for one event, such as “the user selected this item” or “the form was saved”.
A delegate is a better fit when the destination has several events or when the communication forms a reusable contract. For example, a picker could notify its delegate when a city is selected, when the user cancels, and when an error occurs. Define the protocol near the feature and keep the delegate weak to avoid a retain cycle.
protocol CityPickerDelegate: AnyObject {
func cityPicker(
_ picker: CityPickerViewController,
didSelect city: String
)
}
final class CityPickerViewController: UIViewController {
weak var delegate: CityPickerDelegate?
}
The presenting controller adopts the protocol and becomes the destination’s delegate. This pattern remains common in UIKit components, including table views, collection views, and custom child controllers.
Share State Carefully
Notifications and shared models can communicate changes across controllers without a direct reference. NotificationCenter is useful for broad events, such as a user signing out or the app switching theme. It is less suitable for passing a single selected value between two screens because the connection becomes difficult to discover.
NotificationCenter.default.post(
name: .profileDidUpdate,
object: updatedProfile
)
A controller observing this notification must remove the observer appropriately or use the modern closure and lifecycle patterns carefully. Notifications should describe meaningful events rather than become a substitute for every property assignment.
A shared model or small store is often preferable when several screens need the same information. For example, an app might keep a Cart object in a coordinator or feature-level view model. Each controller receives only the data and actions it needs. This approach avoids sending a large collection of unrelated values through several navigation levels.
Choose A Suitable Communication Pattern
- Use a destination property or initialiser for forward navigation.
- Use a closure for one simple result or callback.
- Use a delegate for several related events.
- Use a shared model for state needed by multiple screens.
For a student building a portfolio app after school, start with the simplest pattern that expresses the relationship clearly. A small project made in Perth does not need a complex architecture merely because a larger commercial app might use one. As the feature grows, refactor repeated communication into a view model, coordinator, or dedicated store.
Work With Unwind Segues And Child Controllers
An unwind segue can return to an earlier screen in a storyboard-based app. The destination controller defines an action method with @IBAction, and another controller can trigger the unwind. This is convenient for returning from several screens to a particular point in the navigation flow.
@IBAction func unwindToProfile(
_ segue: UIStoryboardSegue
) {
guard let editor = segue.source as? EditProfileViewController else {
return
}
profile = editor.profile
updateView()
}
The source screen can expose its updated model before the unwind occurs. Although unwind segues reduce manual navigation code, they can feel indirect in a large storyboard. If the data relationship is important, a delegate or closure often reveals the dependency more clearly.
Child view controllers use the same communication principles. A parent controller may add a reusable form controller as a child and provide configuration through properties or an initialiser. The child can notify the parent with a delegate or closure. Always complete the containment lifecycle with addChild, adding the child’s view, calling didMove(toParent:), and reversing those steps when removing it.
A useful example is a dashboard used by a local sports club in Adelaide. Separate child controllers could display fixtures, membership details, and announcements while the parent coordinates the selected season. Each child owns its presentation logic, while the parent owns the shared navigation and state. This keeps one massive view controller from becoming responsible for every screen.
Debug Data Flow Before It Breaks
Many data-passing bugs come from timing. A value may be assigned after viewDidLoad, while the interface was configured before the assignment. If the destination needs the value to build its initial view, pass it before presentation and use viewDidLoad to render it. If the value changes later, update the interface in a separate method.
Type mismatches are another frequent issue. A segue destination may be wrapped in a navigation controller, a selected row may be nil, or an identifier may point to the wrong custom class. Replace forced casts with guard let during development so the failure is easier to understand. Set breakpoints in prepare(for:), the callback, and the destination’s lifecycle methods to follow the data’s path.
Use model types instead of passing loose dictionaries or many unrelated strings. A Product, Profile, or Booking struct communicates intent and reduces spelling mistakes. This matters when testing a prototype for the Australian market, where a booking may include a venue, time zone, accessibility information, and cancellation policy rather than just a title.
When building an interactive prototype or learning another Apple-friendly development path, game development tutorials can also demonstrate useful ideas about state, events, and object communication. The same principles apply even when the final product is a UIKit utility rather than a game.
Before releasing an app, test each path with empty input, repeated navigation, cancellation, and a fresh launch. Check that closures do not retain screens, delegates are weak, and shared state is updated on the main thread when it affects UIKit. For a practical habit, write down the owner, sender, receiver, and expected value for every important transition; if those four details are clear, the implementation usually becomes straightforward.