Using UINavigationController to build a multi-screen app
UINavigationController is UIKit’s container for a stack of screens. It supplies the navigation bar, back button, transitions, and behaviour that makes a collection of view controllers feel like one app. For a multi-screen iOS project, it is often the simplest and most reliable coordination layer.
Each view controller in that stack has a role: a dashboard might lead to a list, a list to a detail screen, and the detail screen to an editor. Pushing adds a destination, while popping removes it. Keeping that model clear prevents navigation from becoming an unstructured collection of callbacks and hidden presentation rules.
The key is to treat navigation as an explicit hierarchy rather than a set of unrelated calls. UIKit supports storyboard segues and programmatic pushes, allowing a project to keep data flow and screen ownership visible.
How UINavigationController manages a screen stack
A UINavigationController is usually the window’s root view controller. It owns an ordered stack, displays the navigation bar, and creates a new controller when you push one. Its back button normally returns to the controller immediately below the current screen, so stack order directly affects the user journey.
let home = HomeViewController()
let navigationController = UINavigationController(rootViewController: home)
window.rootViewController = navigationController
window.makeKeyAndVisible()
The first controller is the root and cannot be popped. Pushing TripDetailViewController places it above the home screen; popping it restores the previous screen and its state. Keeping the root stable gives the app a reliable place to begin again.
Configuring the navigation bar
Each controller should describe itself through its title property. UIKit uses that value for the navigation bar and, unless configured otherwise, derives the back button label from the previous controller’s title. Titles should be short enough to remain readable on smaller iPhone screens.
Large titles provide a useful hierarchy for apps containing lists or dashboards. They can be enabled for the whole navigation controller or selectively for particular screens.
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.largeTitleDisplayMode = .always
Large titles work best when the previous screen uses a compact title. Mixing the two styles without a deliberate reason can make navigation feel inconsistent. The same care applies to bar buttons, back titles, and Dynamic Type support.
Moving between screens with pushes and segues
Programmatic navigation is straightforward when the destination can be created with a simple initialiser. The current controller asks its navigation controller to push the new screen, and UIKit animates the transition.
@objc private func showTripDetails() {
let details = TripDetailViewController()
navigationController?.pushViewController(details, animated: true)
}
Storyboard-based projects use segues instead. Before a segue runs, prepare(for:sender:) can transfer information to its destination. This is where the selected item, rather than a broad screen reference, should be supplied.
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
guard let details = segue.destination as? TripDetailViewController else {
return
}
details.configure(with: selectedTrip)
}
A show segue usually pushes onto the navigation stack, while a modal presentation creates a separate presentation context. Choosing the correct behaviour matters: pushed screens have a back button, but modals need a deliberate close or cancel action.
Passing data between view controllers
A destination should receive the data it needs through its initialiser or a small configuration method. This keeps dependencies visible and avoids searching through the navigation stack for whichever controller happens to contain the information.
struct Trip {
let name: String
}
final class TripDetailViewController: UIViewController {
private let trip: Trip
init(trip: Trip) {
self.trip = trip
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Data travelling in the opposite direction can use a delegate or callback. A delegate is useful when several screens collaborate, while a closure works well for a single action such as selecting or cancelling. Any closure that captures another controller should capture it weakly to avoid an unnecessary retain cycle.
Designing navigation around user journeys
Navigation should reflect what people are trying to achieve, not simply how many screens a project contains. A useful hierarchy might contain an overview, a searchable list, and a detail screen. A settings flow can use a separate modal hierarchy when it should interrupt the main task rather than extend it.
In Sydney, Melbourne, and Brisbane, commuters often move between train, tram, and ferry services. A transit helper with a route list and a stop-detail screen should let users enter a specific journey while retaining a sensible route back to the network overview. Back navigation should restore the selected route, not reset the whole list.
Ordering coffee through a café app is routine across Australian cities, with Melbourne’s specialist coffee culture providing a familiar example. Such a flow may display prices in Australian dollars, with consumer prices generally including 10% GST. Dates are usually written day first, such as 18/04/2026, so navigation labels and booking screens should avoid ambiguous formats.
Australian products also need to reflect local privacy expectations. The Privacy Act 1988 and Australian Privacy Principles may apply depending on the organisation and the data being handled. Screens that request location, account details, or travel histories should explain the purpose, collect only what is needed, and provide a clear way to cancel or leave.
Preventing common navigation failures
Repeated taps can push the same screen more than once, especially on a slow connection or when a button remains enabled during an animation. Before pushing, a controller can check whether it is still the visible controller and avoid duplicating an existing destination.
guard navigationController?.topViewController === self else {
return
}
navigationController?.pushViewController(details, animated: true)
Stale information is another frequent problem. If a list item can be renamed or deleted elsewhere, the destination should receive an updated model when it is opened. viewWillAppear can refresh visible content, but it should not replace a clear data flow with repeated polling.
Modal and pushed navigation also need different dismissal rules. A modal screen should normally offer an explicit close button, while a pushed screen relies on the navigation bar’s back button. Testing both paths on different iPhone sizes catches controls hidden by the bar or awkward empty states left behind after dismissal.
Building a navigation structure that stays maintainable
As a project grows, one component should own navigation decisions. That can be a small coordinator, a navigation factory, or a method dedicated to each route. The important part is that creating and presenting a destination happens in a predictable place rather than being scattered across unrelated view controllers.
Screen construction and screen presentation should remain separate. A builder can configure labels, images, and actions without deciding where the screen belongs, while the navigation layer decides whether that screen is pushed or presented modally. This separation makes previews, testing, and later design changes easier.
The practical takeaway is straightforward: give every destination an explicit route, keep its data dependencies visible, and verify that each transition has the correct back or dismissal behaviour.