How to Intercept the Navigation Bar Back Button in UIKit

How to Intercept the Navigation Bar Back Button in UIKit

Written by

in

Quick answer: To run code when the user taps the back button without breaking native swipe gestures, check if isMovingFromParent inside viewWillDisappear(_:). If you must cancel or confirm the back navigation before popping, replace navigationItem.leftBarButtonItem with a custom UIBarButtonItem that executes your logic and calls navigationController?.popViewController(animated: true) manually.

In UIKit, UINavigationController manages the navigation bar back button automatically. By default, iOS does not expose a direct target-action handler on the system back button. When a user taps it or uses the interactive edge-swipe gesture, the controller pops the current view controller off the stack without a dedicated interception callback.

Developers frequently need to intercept this transition—whether to discard unsaved data, clean up observers, stop background audio, or display a confirmation alert before abandoning an active draft. Depending on whether you simply need to observe the pop event or actively prevent it, UIKit provides distinct approaches.

Why this happens

Apple designed UINavigationBar back buttons to provide consistent cross-app behavior. The system creates the back button item from the title of the preceding view controller, handles touch animations, and coordinates with the interactive pop gesture recognizer.

Because the system back button is wired internally to the navigation controller’s pop sequence, assigning a custom target or selector directly to navigationItem.backBarButtonItem does not intercept taps on the active screen. The backBarButtonItem belongs to the previous controller on the stack and only dictates the appearance of the button on pushed screens.

To run code upon leaving, you must either observe view controller lifecycle events that distinguish a backward pop from a forward push, or replace the navigation bar item entirely.

How to fix it

1. Check isMovingFromParent in viewWillDisappear (Recommended)

If you only need to run cleanup code (such as clearing state, cancelling network tasks, or resetting data) when the controller is popped, rely on the view controller lifecycle. In modern Swift, the isMovingFromParent property returns true specifically when the view controller is being removed from its parent container.

import UIKit

class DetailViewController: UIViewController {

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        if isMovingFromParent {
            // The view controller is being popped off the navigation stack
            performCleanupAction()
        }
    }

    private func performCleanupAction() {
        print("Back button tapped or interactive edge-swipe completed.")
        // Reset state, invalidate timers, or clean up arrays here
    }
}

This technique supports both the physical tap on the back button and the interactive swipe-to-go-back gesture without any UI bugs or layout shifting.

2. Replace the back button with a custom UIBarButtonItem

If you must conditionally prevent navigation (for instance, showing an alert asking “Discard unsaved changes?”), viewWillDisappear runs too late because the pop animation has already started. In this scenario, replace the left navigation item with a custom button.

import UIKit

class FormViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        setupCustomBackButton()
    }

    private func setupCustomBackButton() {
        // Hide the default system back button
        navigationItem.hidesBackButton = true

        // Add custom replacement button
        let backItem = UIBarButtonItem(
            image: UIImage(systemName: "chevron.backward"),
            style: .plain,
            target: self,
            action: #selector(handleCustomBack)
        )
        navigationItem.leftBarButtonItem = backItem
    }

    @objc private func handleCustomBack() {
        // Present confirmation dialog or validate inputs
        let alert = UIAlertController(
            title: "Unsaved Changes",
            message: "Do you want to discard your draft?",
            preferredStyle: .alert
        )

        alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
        alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in
            self?.navigationController?.popViewController(animated: true)
        })

        present(alert, animated: true)
    }
}

Setting navigationItem.leftBarButtonItem disables the interactive swipe-to-pop gesture by default. To preserve the gesture while using a custom button, reassign the interactive pop gesture delegate:

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    navigationController?.interactivePopGestureRecognizer?.delegate = self
}

Ensure your view controller conforms to UIGestureRecognizerDelegate and returns true from gestureRecognizerShouldBegin(_:) only when your stack depth is greater than 1.

3. Intercept transitions via UINavigationControllerDelegate

If your architecture decouples view controllers through Coordinators or Routers, handle navigation events centrally using UINavigationControllerDelegate.

import UIKit

class MainCoordinator: NSObject, UINavigationControllerDelegate {
    let navigationController: UINavigationController

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
        super.init()
        self.navigationController.delegate = self
    }

    func navigationController(
        _ navigationController: UINavigationController,
        didShow viewController: UIViewController,
        animated: Bool
    ) {
        guard let fromViewController = navigationController.transitionCoordinator?.viewController(forKey: .from) else {
            return
        }

        // Check if the source controller is no longer in the stack
        if !navigationController.viewControllers.contains(fromViewController) {
            // The view controller was successfully popped
            handleDidPop(fromViewController)
        }
    }

    private func handleDidPop(_ controller: UIViewController) {
        print("Successfully popped controller: \(controller)")
    }
}

This pattern keeps the view controller clean and delegates coordinator teardown logic cleanly.

How to prevent it

  • Use isMovingFromParent rather than checking isBeingDismissed, as isBeingDismissed applies to modal presentations, not navigation stacks.
  • Do not place pop-cancellation logic inside viewWillDisappear, because UIKit cannot cancel a programmatic or interactive pop once viewWillDisappear is triggered.
  • Always set navigationController?.interactivePopGestureRecognizer?.delegate = nil or restore default handling in viewWillDisappear to avoid edge-gesture lockups across subsequent screens.
  • Avoid manually modifying self.navigationItem.backBarButtonItem.action; UIKit ignores target-action overrides on system back items.

FAQ

Why does navigationItem.backBarButtonItem.target not fire my method?

The backBarButtonItem configuration belongs to the presenting screen, not the pushed screen. Furthermore, UIKit reserves navigation bar back button actions exclusively for its internal navigation controller implementation, ignoring custom targets assigned to it.

What is the difference between isMovingFromParent and isBeingDismissed?

isMovingFromParent is true when a view controller is being popped from a parent container like UINavigationController. isBeingDismissed is true when a view controller presented modally via present(_:animated:) is being dismissed.

Will viewWillDisappear fire if the interactive pop gesture is cancelled?

Yes, viewWillDisappear fires as soon as the user starts the swipe gesture. If the user cancels the swipe midway, viewWillAppear fires again on the same controller. To verify that the transition finished, check isMovingFromParent or inspect transitionCoordinator?.isCancelled.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 3.0; this article is shared under the same license.