Skip to content

Swift Navigation — Pushing, Presenting, and Managing Screen Flow

DodaTech Updated 2026-06-28 9 min read

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.

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

  1. 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.

  2. Pushing when already pushing: Only one animated transition can happen at a time. Use navigationController?.topViewController to check the current state before pushing.

  3. 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.

  4. Losing the back button customization: The back button's title comes from the previous screen's title. Customize the back button with navigationItem.backBarButtonItem on the previous screen, not the current one.

  5. Not checking isMovingFromParent in viewWillDisappear: To distinguish between a pop and a push, check isMovingFromParent or isBeingDismissed in the disappear method.

Practice Questions

  1. How does UINavigationController manage its stack of view controllers?
  2. What is the difference between push/pop and present/dismiss?
  3. How do you pass data from a child view controller back to its parent?
  4. Why is the coordinator pattern useful for complex navigation?
  5. 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 is the maximum navigation stack depth?

There is no hard limit, but stacks deeper than 10-15 levels are confusing for users. Apple recommends a maximum depth of 3-4 levels for clarity.

How do I hide the navigation bar?

Call navigationController?.setNavigationBarHidden(true, animated: true) in viewWillAppear. Restore it in viewWillDisappear if needed.

Can I customize the back button?

Set navigationItem.backBarButtonTitle on the previous screen, or create a custom UIBarButtonItem for navigationItem.leftBarButtonItem on the current screen.

What is the difference between a modal and a push?

Push adds a screen to the existing navigation stack with a slide-from-right animation. Modal presents a screen over the entire context, often with a vertical slide animation, and requires explicit dismissal.

How do I handle deep links?

Use a coordinator that checks the deep link URL at app launch and constructs the appropriate navigation stack, pushing or presenting the required screens.

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