Swift Navigation — Pushing, Presenting, and Managing Screen Flow
In this tutorial, you will learn about Swift Navigation. We cover key concepts, practical examples, and best practices to help you master this topic.
Swift navigation manages the flow between screens in an iOS app using navigation controllers, modal presentations, and tab bar controllers to create a coherent user experience that feels natural on Apple platforms.
What You'll Learn
- UINavigationController and navigation stacks
- Push and pop transitions
- Modal presentation styles
- UITabBarController for tab-based navigation
- Passing data between screens
- The coordinator pattern for complex navigation
Why It Matters
Navigation is the backbone of multi-screen iOS apps. Every production app has at least one navigation controller managing screen transitions. Getting navigation right — including data passing, back button behavior, and memory management — is critical for a polished user experience.
Real-World Use
The Settings app uses a UINavigationController where each row tap pushes a new view controller onto the stack. The root screen shows the top-level categories, tapping "General" pushes the General settings screen, and tapping "About" pushes deeper. The navigation bar automatically shows a back button and the title of the previous screen.
Learning Path
flowchart LR A[Table and Collection Views
Lesson 18] --> B[Navigation
You are here] B --> C[Networking
Lesson 20] B --> D[Data Persistence
Lesson 21] style B fill:#f90,color:#fff
UINavigationController
UINavigationController manages a stack of view controllers. Pushing adds a new screen on top; popping removes it. The navigation bar updates automatically.
import UIKit
class RootViewController: UIViewController {
private let pushButton: UIButton = {
let button = UIButton(type: .system)
button.setTitle("Push Detail Screen", for: .normal)
button.configuration = .filled()
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Home"
setupButton()
}
private func setupButton() {
view.addSubview(pushButton)
NSLayoutConstraint.activate([
pushButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
pushButton.centerYAnchor.constraint(equalTo: view.centerYAnchor),
pushButton.widthAnchor.constraint(equalToConstant: 200),
pushButton.heightAnchor.constraint(equalToConstant: 50)
])
pushButton.addTarget(self, action: #selector(pushDetail), for: .touchUpInside)
}
@objc private func pushDetail() {
let detailVC = DetailViewController()
navigationController?.pushViewController(detailVC, animated: true)
}
}
class DetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemGreen
title = "Detail"
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isMovingFromParent {
print("Back button was tapped — Detail will pop")
}
}
}
// Setup in SceneDelegate:
// let navigationController = UINavigationController(rootViewController: RootViewController())
// window.rootViewController = navigationController
The navigationController? property is available on any view controller that is embedded in a navigation stack. pushViewController adds the new controller, and the back button pops it automatically.
Passing Data Forward
Data is typically passed to a new view controller by setting properties before pushing.
class ProductListViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
private let tableView = UITableView()
let products = [
Product(name: "Laptop", price: 999.99),
Product(name: "Mouse", price: 29.99),
Product(name: "Keyboard", price: 89.99)
]
override func viewDidLoad() {
super.viewDidLoad()
title = "Products"
tableView.frame = view.bounds
tableView.dataSource = self
tableView.delegate = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
view.addSubview(tableView)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return products.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
cell.textLabel?.text = products[indexPath.row].name
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let product = products[indexPath.row]
let detailVC = ProductDetailViewController()
detailVC.product = product
navigationController?.pushViewController(detailVC, animated: true)
}
}
struct Product {
let name: String
let price: Double
}
class ProductDetailViewController: UIViewController {
var product: Product?
private let nameLabel = UILabel()
private let priceLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = product?.name ?? "Product"
nameLabel.text = product?.name
nameLabel.font = UIFont.boldSystemFont(ofSize: 24)
nameLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(nameLabel)
let priceFormatter = NumberFormatter()
priceFormatter.numberStyle = .currency
priceLabel.text = priceFormatter.string(from: NSNumber(value: product?.price ?? 0))
priceLabel.font = UIFont.systemFont(ofSize: 18)
priceLabel.textColor = .secondaryLabel
priceLabel.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(priceLabel)
NSLayoutConstraint.activate([
nameLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
nameLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -20),
priceLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
priceLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 8)
])
}
}
Data flows forward by setting properties before the push. The product is set on ProductDetailViewController before the navigation controller adds it to the stack.
Passing Data Back
Data flows back to the previous screen using delegation, closures, or notifications.
protocol ColorSelectionDelegate: AnyObject {
func didSelectColor(_ color: UIColor)
}
class ColorPickerViewController: UIViewController {
weak var delegate: ColorSelectionDelegate?
private let colors: [UIColor] = [.systemRed, .systemGreen, .systemBlue, .systemOrange, .systemPurple]
private let stackView = UIStackView()
override func viewDidLoad() {
super.viewDidLoad()
title = "Pick a Color"
view.backgroundColor = .systemBackground
setupStackView()
}
private func setupStackView() {
stackView.axis = .horizontal
stackView.spacing = 16
stackView.distribution = .fillEqually
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stackView.heightAnchor.constraint(equalToConstant: 60),
stackView.widthAnchor.constraint(equalToConstant: 340)
])
for color in colors {
let button = UIButton()
button.backgroundColor = color
button.layer.cornerRadius = 8
button.addTarget(self, action: #selector(colorTapped(_:)), for: .touchUpInside)
stackView.addArrangedSubview(button)
}
}
@objc private func colorTapped(_ sender: UIButton) {
guard let color = sender.backgroundColor else { return }
delegate?.didSelectColor(color)
navigationController?.popViewController(animated: true)
}
}
class SettingsViewController: UIViewController, ColorSelectionDelegate {
private let accentColorView = UIView()
private let changeColorButton = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
title = "Settings"
view.backgroundColor = .systemBackground
accentColorView.backgroundColor = .systemBlue
accentColorView.layer.cornerRadius = 8
accentColorView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(accentColorView)
changeColorButton.setTitle("Change Accent Color", for: .normal)
changeColorButton.addTarget(self, action: #selector(showColorPicker), for: .touchUpInside)
changeColorButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(changeColorButton)
NSLayoutConstraint.activate([
accentColorView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
accentColorView.centerYAnchor.constraint(equalTo: view.centerYAnchor, constant: -40),
accentColorView.widthAnchor.constraint(equalToConstant: 100),
accentColorView.heightAnchor.constraint(equalToConstant: 100),
changeColorButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
changeColorButton.topAnchor.constraint(equalTo: accentColorView.bottomAnchor, constant: 20)
])
}
@objc private func showColorPicker() {
let picker = ColorPickerViewController()
picker.delegate = self
navigationController?.pushViewController(picker, animated: true)
}
func didSelectColor(_ color: UIColor) {
accentColorView.backgroundColor = color
}
}
The delegate pattern lets the child communicate back to the parent without knowing the parent's type. This clean separation is the recommended Apple pattern.
Modal Presentation
Use modal presentation for screens that require user completion before returning, like forms, login screens, or alerts.
class LoginViewController: UIViewController {
private let usernameField = UITextField()
private let passwordField = UITextField()
private let loginButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
title = "Log In"
setupFields()
}
private func setupFields() {
usernameField.placeholder = "Username"
usernameField.borderStyle = .roundedRect
usernameField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(usernameField)
passwordField.placeholder = "Password"
passwordField.borderStyle = .roundedRect
passwordField.isSecureTextEntry = true
passwordField.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(passwordField)
loginButton.setTitle("Log In", for: .normal)
loginButton.configuration = .filled()
loginButton.addTarget(self, action: #selector(loginTapped), for: .touchUpInside)
loginButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loginButton)
cancelButton.setTitle("Cancel", for: .normal)
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(cancelButton)
NSLayoutConstraint.activate([
usernameField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
usernameField.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40),
usernameField.widthAnchor.constraint(equalToConstant: 280),
passwordField.centerXAnchor.constraint(equalTo: view.centerXAnchor),
passwordField.topAnchor.constraint(equalTo: usernameField.bottomAnchor, constant: 12),
passwordField.widthAnchor.constraint(equalTo: usernameField.widthAnchor),
loginButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loginButton.topAnchor.constraint(equalTo: passwordField.bottomAnchor, constant: 20),
loginButton.widthAnchor.constraint(equalTo: usernameField.widthAnchor),
cancelButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
cancelButton.topAnchor.constraint(equalTo: loginButton.bottomAnchor, constant: 8)
])
}
@objc private func loginTapped() {
dismiss(animated: true) {
print("Login attempted")
}
}
@objc private func cancelTapped() {
dismiss(animated: true)
}
}
// Presenting:
// let loginVC = LoginViewController()
// let navController = UINavigationController(rootViewController: loginVC)
// present(navController, animated: true)
Modally presented view controllers are dismissed with dismiss(animated:). If presented from a navigation controller, the navigation asks the presenting controller to dismiss.
UITabBarController
Tab bar controllers allow switching between multiple sections of an app, each with its own navigation stack.
class TabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
let homeVC = HomeViewController()
homeVC.tabBarItem = UITabBarItem(
title: "Home",
image: UIImage(systemName: "house"),
selectedImage: UIImage(systemName: "house.fill")
)
let searchVC = SearchViewController()
searchVC.tabBarItem = UITabBarItem(
title: "Search",
image: UIImage(systemName: "magnifyingglass"),
selectedImage: UIImage(systemName: "magnifyingglass")
)
let settingsVC = SettingsViewController()
settingsVC.tabBarItem = UITabBarItem(
title: "Settings",
image: UIImage(systemName: "gear"),
selectedImage: UIImage(systemName: "gear")
)
let homeNav = UINavigationController(rootViewController: homeVC)
let searchNav = UINavigationController(rootViewController: searchVC)
let settingsNav = UINavigationController(rootViewController: settingsVC)
viewControllers = [homeNav, searchNav, settingsNav]
}
}
Each tab has its own UINavigationController, so navigation state is preserved independently per tab.
Coordinator Pattern
For complex navigation, the coordinator pattern extracts navigation logic from view controllers into dedicated coordinator objects.
protocol Coordinator {
var navigationController: UINavigationController { get }
func start()
}
class AppCoordinator: Coordinator {
let navigationController: UINavigationController
init(navigationController: UINavigationController) {
self.navigationController = navigationController
}
func start() {
let mainVC = MainViewController()
mainVC.coordinator = self
navigationController.pushViewController(mainVC, animated: false)
}
func showProductDetail(productID: Int) {
let productVC = ProductDetailViewController()
productVC.productID = productID
productVC.coordinator = self
navigationController.pushViewController(productVC, animated: true)
}
func showCheckout() {
let checkoutVC = CheckoutViewController()
checkoutVC.coordinator = self
navigationController.pushViewController(checkoutVC, animated: true)
}
}
class MainViewController: UIViewController {
weak var coordinator: AppCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
title = "Store"
view.backgroundColor = .systemBackground
let button = UIButton(type: .system)
button.setTitle("View Product", for: .normal)
button.frame = CGRect(x: 100, y: 200, width: 200, height: 50)
button.addTarget(self, action: #selector(showProduct), for: .touchUpInside)
view.addSubview(button)
}
@objc private func showProduct() {
coordinator?.showProductDetail(productID: 42)
}
}
class ProductDetailViewController: UIViewController {
var productID: Int?
weak var coordinator: AppCoordinator?
override func viewDidLoad() {
super.viewDidLoad()
title = "Product #\(productID ?? 0)"
view.backgroundColor = .systemBackground
let button = UIButton(type: .system)
button.setTitle("Buy Now", for: .normal)
button.frame = CGRect(x: 100, y: 200, width: 200, height: 50)
button.addTarget(self, action: #selector(checkout), for: .touchUpInside)
view.addSubview(button)
}
@objc private func checkout() {
coordinator?.showCheckout()
}
}
The coordinator owns the navigation controller and decides which screen to show next. View controllers know nothing about other screens — they only ask their coordinator to navigate.
Common Mistakes
Strong reference cycles in navigation stack: View controllers hold strong references to child views, and the navigation controller holds strong references to all view controllers in its stack. Be careful with delegate patterns — use weak references.
Pushing when already pushing: Only one animated transition can happen at a time. Use
navigationController?.topViewControllerto check the current state before pushing.Modal presentation without a navigation controller: A modally presented view controller cannot push unless it is embedded in a navigation controller. Wrap modal screens in
UINavigationController.Losing the back button customization: The back button's title comes from the previous screen's title. Customize the back button with
navigationItem.backBarButtonItemon the previous screen, not the current one.Not checking isMovingFromParent in viewWillDisappear: To distinguish between a pop and a push, check
isMovingFromParentorisBeingDismissedin the disappear method.
Practice Questions
- How does UINavigationController manage its stack of view controllers?
- What is the difference between push/pop and present/dismiss?
- How do you pass data from a child view controller back to its parent?
- Why is the coordinator pattern useful for complex navigation?
- Challenge: Build a three-tab app with UITabBarController. Each tab has its own UINavigationController. The first tab shows a list of items; tapping an item pushes a detail screen. The second tab shows a form; submitting it switches to the first tab. The third tab is a settings screen where the user can log out.
Mini Project
Create a RecipeNavigationApp with:
- A UITabBarController with "Recipes" and "Favorites" tabs
- Each tab has its own UINavigationController
- Recipes tab: UITableView of recipe names. Tapping a recipe pushes a detail screen showing the recipe name, ingredients list, and a "Add to Favorites" button
- Favorites tab: UITableView showing favorited recipes (stored in a shared array)
- The detail screen uses a delegate to notify the favorites tab when a recipe is favorited
- The coordinator pattern manages navigation (show recipe, show favorites, show detail)
FAQ
What's Next
With navigation mastered, learn how to fetch and send data over the network in Networking, or persist data locally in Data Persistence.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro