4.4 — Navigation

Opening scenario

PM walks over: “We need a deep link from a push notification straight into Settings → Privacy → Block List, with the user already filtered to a specific contact.” Your nav stack is a UITabBarController with 5 tabs, each wrapped in a UINavigationController. The Settings tab has 4 levels of pushViewController already. You’re going to construct that path programmatically, possibly while the app is launching from a cold start, possibly while it’s resuming from background, and the user should be able to hit back and end up at the right place at every level.

This is navigation engineering — not “I added a push.” This chapter covers the controller types, how they nest, and how to wrangle them for production-grade flows.

ControllerMental model
UINavigationControllerStack: push and pop, back button automatic
UITabBarControllerSet of peers: switch via bottom tabs
UISplitViewControllerMaster/detail (iPad, Mac) — sidebar + content
UIPageViewControllerHorizontally swipeable pages (onboarding)
present(_:animated:)Modal: covers current screen, dismiss via swipe-down or button

Concept → Why → How → Code

UINavigationController — push and pop

The most common container. Holds a stack of view controllers; users push deeper and pop back.

let root = ListViewController()
let nav  = UINavigationController(rootViewController: root)
window.rootViewController = nav

// Push
nav.pushViewController(DetailViewController(item: item), animated: true)

// Pop one
nav.popViewController(animated: true)

// Pop to root
nav.popToRootViewController(animated: true)

// Pop to specific VC
nav.popToViewController(someVC, animated: true)

// Replace entire stack
nav.setViewControllers([root, level1, level2], animated: true)

The navigation bar at the top:

  • Auto-shows back button (when stack count > 1)
  • Title comes from each VC’s navigationItem.title (or title)
  • Bar buttons via navigationItem.leftBarButtonItem / rightBarButtonItem
  • Hide bar per VC with navigationController?.setNavigationBarHidden(true, animated: animated) in viewWillAppear
override func viewDidLoad() {
    super.viewDidLoad()
    navigationItem.title = "Profile"
    navigationItem.rightBarButtonItem = UIBarButtonItem(
        systemItem: .edit,
        primaryAction: UIAction { [weak self] _ in self?.startEditing() }
    )
}

UITabBarController — top-level peers

For “modes” of your app — feed, search, profile, etc. Each tab is typically its own UINavigationController so each has its own push stack.

let feed    = UINavigationController(rootViewController: FeedViewController())
feed.tabBarItem = UITabBarItem(title: "Feed", image: UIImage(systemName: "house"), tag: 0)

let search  = UINavigationController(rootViewController: SearchViewController())
search.tabBarItem = UITabBarItem(title: "Search", image: UIImage(systemName: "magnifyingglass"), tag: 1)

let profile = UINavigationController(rootViewController: ProfileViewController())
profile.tabBarItem = UITabBarItem(title: "Profile", image: UIImage(systemName: "person"), tag: 2)

let tabs = UITabBarController()
tabs.viewControllers = [feed, search, profile]
window.rootViewController = tabs

Programmatic switching:

tabBarController?.selectedIndex = 2

iOS 18+ added UITabBarController rich tab APIs with section grouping; for new code consider UITabBarController.tabs with UITab objects. Apple’s Health app uses this style.

UISplitViewController — iPad and Mac primary

The canonical iPad layout: sidebar + content. On iPhone, it collapses to a navigation stack automatically.

let split = UISplitViewController(style: .doubleColumn)
split.setViewController(SidebarVC(), for: .primary)
split.setViewController(UINavigationController(rootViewController: DetailVC()), for: .secondary)
split.preferredDisplayMode = .oneBesideSecondary
split.preferredSplitBehavior = .tile

For a 3-pane layout (Mail-style): UISplitViewController(style: .tripleColumn). Apple’s Files, Mail, Notes use this.

When the sidebar item changes, swap the detail:

final class SidebarVC: UITableViewController {
    override func tableView(_ tv: UITableView, didSelectRowAt indexPath: IndexPath) {
        let detail = makeDetailVC(for: indexPath)
        splitViewController?.setViewController(
            UINavigationController(rootViewController: detail),
            for: .secondary
        )
    }
}

present(_:animated:) covers the current view with a new one:

let editor = EditorViewController()
editor.modalPresentationStyle = .pageSheet   // default in iOS 13+, sheet with grabber
editor.sheetPresentationController?.detents = [.medium(), .large()]
editor.sheetPresentationController?.prefersGrabberVisible = true

present(editor, animated: true)

Presentation styles:

StyleUse
.automatic (default)iOS picks; usually .pageSheet
.pageSheetCard sheet, swipe-down dismiss, detents for height
.formSheetSmaller card, centered on iPad
.fullScreenCovers entire screen, no swipe-dismiss
.overFullScreenLike fullScreen but presenting VC stays in hierarchy
.popoveriPad only; anchored arrow popover

iOS 15+ sheet detents (.medium(), .large(), custom) give you Apple-Maps-style draggable sheets:

sheet.detents = [
    .custom { ctx in ctx.maximumDetentValue * 0.3 },
    .medium(),
    .large()
]
sheet.largestUndimmedDetentIdentifier = .medium
sheet.prefersScrollingExpandsWhenScrolledToEdge = false

Dismiss:

dismiss(animated: true)
// Or from the presenting VC:
presentedViewController?.dismiss(animated: true)

Programmatic navigation patterns

For anything beyond trivial apps, do not have view controllers call navigationController?.pushViewController directly. Use the Coordinator pattern:

protocol Coordinator: AnyObject {
    func start()
}

final class AppCoordinator: Coordinator {
    private let window: UIWindow
    private var children: [Coordinator] = []

    init(window: UIWindow) { self.window = window }

    func start() {
        let nav = UINavigationController()
        let main = MainCoordinator(navigation: nav)
        children.append(main)
        main.start()
        window.rootViewController = nav
        window.makeKeyAndVisible()
    }
}

final class MainCoordinator: Coordinator {
    private let navigation: UINavigationController
    init(navigation: UINavigationController) { self.navigation = navigation }

    func start() {
        let list = ListViewController()
        list.onSelect = { [weak self] item in self?.showDetail(item) }
        navigation.setViewControllers([list], animated: false)
    }

    private func showDetail(_ item: Item) {
        let detail = DetailViewController(item: item)
        detail.onEdit = { [weak self] in self?.showEditor(for: item) }
        navigation.pushViewController(detail, animated: true)
    }

    private func showEditor(for item: Item) {
        let editor = EditorViewController(item: item)
        editor.onDone = { [weak self] _ in self?.navigation.dismiss(animated: true) }
        let editorNav = UINavigationController(rootViewController: editor)
        navigation.present(editorNav, animated: true)
    }
}

Benefits:

  • VCs don’t know what comes next — only what they emit (closures)
  • Coordinators own navigation logic, are unit-testable
  • Deep linking becomes “navigate the coordinator tree”
  • Swapping flows (A/B test a new onboarding) means swapping coordinators

Deep linking

The deep-link problem: given a URL like myapp://settings/privacy/block?contact=42, navigate the user there cold-start or warm.

// SceneDelegate.swift
func scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>) {
    guard let url = contexts.first?.url else { return }
    coordinator.handle(url: url)
}

// AppCoordinator
func handle(url: URL) {
    let path = url.pathComponents.dropFirst()
    switch path.first {
    case "settings":
        switchToTab(.settings)
        settingsCoordinator?.handlePath(Array(path.dropFirst()), query: url.queryItems)
    case "feed":
        switchToTab(.feed)
        feedCoordinator?.handlePath(Array(path.dropFirst()), query: url.queryItems)
    default:
        return
    }
}

Universal Links work the same — Apple’s APIs (NSUserActivity) deliver the URL through scene(_:continue:).

Cold-start: the URL arrives in scene(_:willConnectTo:options:) via options.urlContexts. Cache it, complete UI setup, then apply.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
    // ...setup window, coordinator...
    if let url = options.urlContexts.first?.url {
        coordinator.handle(url: url)
    }
}

Back gestures & interactive pop

By default, UINavigationController supports swipe-from-left-edge to pop. Easy to break by setting a custom back button:

// ❌ Breaks the swipe gesture
navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Back", style: .plain, target: ...)

// ✅ Preserves swipe; just customizes the button visible
navigationItem.backBarButtonItem = UIBarButtonItem(title: "Items", style: .plain, target: nil, action: nil)

The backBarButtonItem is set on the previous VC and applies to its push children. Subtle but important.

If your VC overrides navigationItem.leftBarButtonItem, the interactive pop gesture is disabled. Re-enable explicitly:

navigationController?.interactivePopGestureRecognizer?.delegate = self
extension MyVC: UIGestureRecognizerDelegate {
    func gestureRecognizerShouldBegin(_ g: UIGestureRecognizer) -> Bool { true }
}

Transition coordinators

For custom push/pop animations:

let transition = CATransition()
transition.duration = 0.4
transition.type = .moveIn
transition.subtype = .fromRight
navigationController?.view.layer.add(transition, forKey: nil)
navigationController?.pushViewController(detail, animated: false)

For full custom transitions, conform to UIViewControllerAnimatedTransitioning and set yourself as the navigation controller’s delegate. Rarely needed; default push/pop is what users expect.

UIPageViewController — onboarding & swipeable pages

For 3-5 page horizontal swipe flows:

let pager = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
pager.dataSource = self  // returns pages before/after current
pager.delegate = self     // tracks current page index
pager.setViewControllers([page0], direction: .forward, animated: false)

For longer paged content, prefer UICollectionView with horizontal paging-enabled scrolling — less ceremony, better performance.

Memory: who retains whom

Navigation hierarchies are easy to leak:

window → tabBarController → [navController1, navController2]
navController1 → [vc1, vc2]
vc2 → closure → [weak self]  ✅
vc2 → closure → self          ❌ retain cycle if closure stored on vc2

Coordinators introduce another retention point. If coordinator holds children and children hold a back-reference, you’ve made a cycle. Children should weak the parent or use IDs.

When a presented modal is dismissed, the presented VC and any objects it owns deallocate — unless something else holds them. A common leak: dismissing a modal whose VC has a delegate set to itself indirectly, or holds a strong reference to a long-lived service that holds it back.

Validate with the Memory Graph Debugger: pause the app, click the graph icon. Expand MyApp > Coordinator. Anything you expected to be deallocated still there is a leak.

In the wild

  • Spotify iOS uses a tab bar (Home, Search, Library) with each tab as its own navigation controller. Now Playing is a sheet detent over the entire app.
  • Instagram is tabbed with a feed/search/reels/shopping/profile pattern. Story camera presents .overFullScreen. DMs push from the tab — not present — preserving back navigation.
  • Apple Maps uses split view on iPad (sidebar + map), tab-like card detents on iPhone. Search results are a detented sheet.
  • Lyft uses a single navigation controller with deep stacks; ride flow is .fullScreen modal so back gestures don’t accidentally exit a paid ride.
  • Mail.app is UISplitViewController(.tripleColumn) on iPad — mailboxes / messages / message. On iPhone it collapses to a nav stack automatically.

Common misconceptions

  1. “Just push from VC A to VC B directly.” Fine for prototypes; production codebases use coordinators or routers because flow logic must be testable and swappable.
  2. “Modal .fullScreen is the same as a push.” It’s not — modal isn’t in a navigation stack; no back button, no swipe-back gesture, no shared nav bar. Pick deliberately.
  3. present works from anywhere.” Only from the topmost presented VC. Presenting from a backgrounded VC silently does nothing or logs a warning.
  4. UINavigationController always shows a navigation bar.” It does by default, but you can hide it per VC. The container is the stack; the bar is decoration.
  5. “Deep links need a special framework.” No. Standard URL handling in SceneDelegate plus a coordinator that knows the routes is enough. Frameworks like XCoordinator help with complex apps.

Seasoned engineer’s take

Navigation is the single most under-architected area in junior iOS code. Every senior interview probes it because senior engineers know:

  • Flow logic belongs outside view controllers
  • Deep linking is a routing problem, not a presentation problem
  • iPad and Mac split views must be supported, not afterthoughts
  • The user always wants to be able to back out gracefully

A pragmatic recipe for new projects:

  1. AppCoordinator owned by SceneDelegate
  2. Per-feature coordinators created lazily as user enters the feature
  3. View controllers expose closures (onSelect, onDone) rather than navigation calls
  4. Deep linking goes through AppCoordinator.handle(url:) which dispatches to feature coordinators
  5. Modal vs push decided by user mental model: “is this a brief task they’ll finish or cancel” (modal) vs “is this part of an exploration journey” (push)

TIP: Run your app’s deep links from Terminal with xcrun simctl openurl booted "myapp://settings/privacy". Saves you from typing into Notes and tapping every time.

WARNING: Presenting a modal from a VC that’s inside another modal is a stack: dismissing only dismisses the top. Track your presentation depth or your users will be stuck two modals deep wondering where the back button is.

Interview corner

Junior-level: “When do you use a push vs a modal?”

Push for navigation through a hierarchy of related content (list → detail → sub-detail). Modal for a self-contained task the user will finish or cancel (compose a tweet, edit a setting, sign up). Modals interrupt; pushes continue.

Mid-level: “Describe the coordinator pattern and why you’d use it.”

A coordinator owns navigation between view controllers. VCs emit events (closures or delegates) saying “the user wants to go to the detail screen with this item”; the coordinator decides what comes next. Benefits: VCs are reusable across flows, navigation logic is testable in isolation, deep linking maps onto coordinator method calls, A/B testing a new flow is swapping a coordinator.

Senior-level: “Design deep linking for an app with 5 tabs, each with a 4-level navigation stack, that needs to support cold-start, warm-start, and Universal Links.”

URL scheme: myapp://<tab>/<level1>/<level2>?<query>. SceneDelegate captures URL in willConnectTo: (cold) or openURLContexts (warm) or continueUserActivity: (Universal). AppCoordinator.handle(url:) parses path, switches tab, calls into the tab’s coordinator with the rest of the path. Each level checks if it can construct that level’s VC with the given parameters; missing data triggers a fetch with a loading state. Edge cases: app is in onboarding flow (queue the deep link, replay after onboarding completes), user isn’t authenticated (queue, replay after auth). Tests: snapshot the resulting navigation stack for each known URL.

Red flag in candidates: “I’d just push from each VC directly.” Means they’ve never debugged a 6-level deep nav stack with back-button bugs.

Lab preview

Navigation patterns thread through every UIKit lab. Lab 4.1 uses a navigation controller with detail push; Lab 4.3 uses modal presentation for the signup flow.


Next: 4.5 — UITableView & UICollectionView