Write, review, or improve UIKit code following best practices for view controller lifecycle, Auto Layout, collection views, navigation, animation, memory management, and modern iOS 18–26 APIs. Use when building new UIKit features, refactoring existing views or view controllers, reviewing code quality, adopting modern UIKit patterns (diffable data sources, compositional layout, cell configuration), or bridging UIKit with SwiftUI. Does not cover SwiftUI-only code.
96
100%
Does it follow best practices?
Impact
96%
1.23xAverage score across 9 eval scenarios
Passed
No known issues
UINavigationController remains the backbone of iOS navigation in 2025, yet its API surface hides dozens of crash-inducing pitfalls and subtle behavioral changes across iOS versions. This guide covers eight critical areas — from preventing the infamous "Can't add self as subview" crash to adopting iOS 26's full-screen back gesture — with production-ready ✅ and anti-pattern ❌ code for each. Every pattern targets Swift apps shipping on iOS 15 through iOS 26.
The setViewControllers(_:animated:) method atomically replaces the entire navigation stack in a single call. For deep links, state restoration, and any scenario requiring multi-level navigation changes, it is strictly superior to sequential pushViewController calls.
When animated is true, UIKit diffs the new stack against the current one: if the new top view controller wasn't previously in the stack, it plays a push animation; if it was already present but buried, it plays a pop animation. Only one animation plays — the rest of the stack swaps silently underneath.
// ❌ INCORRECT: Sequential pushes for deep link — cascading animations, potential crash
func handleDeepLink(to productId: String) {
let homeVC = HomeViewController()
let categoryVC = CategoryViewController()
let productVC = ProductViewController(id: productId)
navigationController?.pushViewController(homeVC, animated: false)
navigationController?.pushViewController(categoryVC, animated: false)
navigationController?.pushViewController(productVC, animated: true) // 💥 risk
}// ✅ CORRECT: Atomic stack replacement — one animation, zero crash risk
func handleDeepLink(to productId: String) {
let homeVC = HomeViewController()
let categoryVC = CategoryViewController()
let productVC = ProductViewController(id: productId)
navigationController?.setViewControllers(
[homeVC, categoryVC, productVC],
animated: true // push animation to productVC; home & category set silently
)
}You can also insert a view controller behind the current top without any visible change — useful for injecting a "back destination" that wasn't originally in the stack:
// ✅ Insert VC behind current — no visible animation
func insertSettingsBehindCurrent() {
guard let nav = navigationController else { return }
var stack = nav.viewControllers
let settingsVC = SettingsViewController()
stack.insert(settingsVC, at: stack.count - 1)
nav.setViewControllers(stack, animated: false)
}Thread safety is non-negotiable. All UIKit navigation calls must execute on the main thread. Dispatching from a background queue causes undefined behavior — corrupted navigation bars, blank screens, or hard crashes.
// ❌ INCORRECT: Pushing from background thread
DispatchQueue.global().async {
let vc = ResultsViewController(data: data)
self.navigationController?.pushViewController(vc, animated: true) // 💥
}
// ✅ CORRECT: Dispatch to main
DispatchQueue.global().async {
let results = self.fetchResults()
DispatchQueue.main.async {
let vc = ResultsViewController(data: results)
self.navigationController?.setViewControllers([self, vc], animated: true)
}
}This crash occurs when pushViewController or popViewController fires while a previous transition animation is still in progress. The navigation controller tries to add a view to the transition container that is already mid-animation, triggering NSInvalidArgumentException. Common triggers include double-tapping a table view cell, deep links arriving during an active animation, and calling push inside viewWillAppear of a VC being pushed.
The transitionCoordinator property on UINavigationController is non-nil during active transitions. The simplest guard is to check it before every navigation call.
// ✅ Drops concurrent operations — simplest crash prevention
final class GuardedNavigationController: UINavigationController {
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
guard transitionCoordinator == nil else { return }
super.pushViewController(viewController, animated: animated)
}
@discardableResult
override func popViewController(animated: Bool) -> UIViewController? {
guard transitionCoordinator == nil else { return nil }
return super.popViewController(animated: animated)
}
@discardableResult
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
guard transitionCoordinator == nil else { return nil }
return super.popToRootViewController(animated: animated)
}
}When dropping operations is unacceptable (e.g., deep links that must complete), queue them and drain serially after each transition finishes:
// ✅ Queues operations and executes them serially after each transition
final class SafeNavigationController: UINavigationController,
UINavigationControllerDelegate {
private var isTransitionInProgress = false
private var pendingOperations: [() -> Void] = []
override func viewDidLoad() {
super.viewDidLoad()
delegate = self
}
override func pushViewController(_ vc: UIViewController, animated: Bool) {
guard !isTransitionInProgress else {
pendingOperations.append { [weak self] in
self?.pushViewController(vc, animated: animated)
}
return
}
isTransitionInProgress = animated
super.pushViewController(vc, animated: animated)
if !animated { isTransitionInProgress = false }
}
@discardableResult
override func popViewController(animated: Bool) -> UIViewController? {
guard !isTransitionInProgress else {
pendingOperations.append { [weak self] in
self?.popViewController(animated: animated)
}
return nil
}
isTransitionInProgress = animated
let vc = super.popViewController(animated: animated)
if !animated { isTransitionInProgress = false }
return vc
}
override func setViewControllers(_ vcs: [UIViewController], animated: Bool) {
guard !isTransitionInProgress else {
pendingOperations.append { [weak self] in
self?.setViewControllers(vcs, animated: animated)
}
return
}
isTransitionInProgress = animated
super.setViewControllers(vcs, animated: animated)
if !animated { isTransitionInProgress = false }
}
// UINavigationControllerDelegate — transition finished
func navigationController(_ nav: UINavigationController,
didShow vc: UIViewController,
animated: Bool) {
isTransitionInProgress = false
guard !pendingOperations.isEmpty else { return }
let next = pendingOperations.removeFirst()
next()
}
}For ad-hoc chaining without a subclass, attach a completion block to the transition coordinator:
// ✅ Chain a push after a pop completes
extension UINavigationController {
func pushAfterCurrentTransition(_ vc: UIViewController, animated: Bool) {
if let coordinator = transitionCoordinator {
coordinator.animate(alongsideTransition: nil) { [weak self] _ in
self?.pushViewController(vc, animated: animated)
}
} else {
pushViewController(vc, animated: animated)
}
}
}// ✅ Prevent double-tap push at the call site
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard navigationController?.topViewController === self else { return }
let detail = DetailViewController(item: items[indexPath.row])
navigationController?.pushViewController(detail, animated: true)
}iOS 13 introduced UINavigationBarAppearance with four slots resolved on two axes: bar height (standard vs. compact) and scroll position (scrolled vs. at edge).
| Content at scroll edge | Content scrolled | |
|---|---|---|
| Standard height | scrollEdgeAppearance | standardAppearance |
| Compact height | compactScrollEdgeAppearance (iOS 15+) | compactAppearance |
When a slot is nil, UIKit falls back: scrollEdgeAppearance → standardAppearance with a transparent background; compactAppearance → standardAppearance; compactScrollEdgeAppearance → compactAppearance with transparent background. Critically, views without a scroll view always use scrollEdgeAppearance because they are perpetually "at the edge."
// ✅ CORRECT: Configure all four slots globally
func application(_ app: UIApplication,
didFinishLaunchingWithOptions opts: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .systemBlue
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
let proxy = UINavigationBar.appearance()
proxy.standardAppearance = appearance
proxy.scrollEdgeAppearance = appearance
proxy.compactAppearance = appearance
if #available(iOS 15.0, *) {
proxy.compactScrollEdgeAppearance = appearance
}
proxy.tintColor = .white
return true
}UIKit resolves appearance per-slot in this order (highest priority first):
navigationItem.standardAppearance — per-view-controller, on the top itemnavigationBar.standardAppearance — bar-level default for all VCs in the stackSet per-VC overrides via navigationItem in viewDidLoad. This avoids timing issues with transitions and scopes the override to exactly one screen.
// ✅ CORRECT: Per-VC transparent bar via navigationItem
class ProfileViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
navigationItem.standardAppearance = appearance
navigationItem.scrollEdgeAppearance = appearance
navigationItem.compactAppearance = appearance
}
}// ❌ INCORRECT: Mutating the shared navigationBar in viewWillAppear
class ProfileViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Affects ALL VCs and causes glitches during interactive pop
navigationController?.navigationBar.standardAppearance = transparentAppearance
}
}Before iOS 15, scrollEdgeAppearance only applied to large-title bars. In iOS 15, Apple extended it to all navigation bars. If scrollEdgeAppearance is nil, UIKit derives it from standardAppearance with a transparent background — making bars appear invisible when content is at the top.
// ❌ WRONG: Only setting standardAppearance — bar goes transparent at scroll edge
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .systemRed
UINavigationBar.appearance().standardAppearance = appearance
// scrollEdgeAppearance is nil → transparent when content is at top!
// ✅ FIX: Set scrollEdgeAppearance to match
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance // ← critical lineThe system uses two properties in concert: prefersLargeTitles on the navigation bar (the master switch) and largeTitleDisplayMode on each view controller's navigationItem (per-screen control with values .automatic, .always, .never).
The critical rule: set prefersLargeTitles once, then use largeTitleDisplayMode per VC in viewDidLoad. Never toggle prefersLargeTitles in lifecycle methods.
// ❌ ANTI-PATTERN: Toggling prefersLargeTitles in viewWillAppear
class DetailViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.navigationBar.prefersLargeTitles = false
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
navigationController?.navigationBar.prefersLargeTitles = true
}
}
// Problems: breaks interactive pop gesture (cancelled swipe leaves bar in wrong state),
// animation glitches, race conditions between multiple VCs fighting over shared state.// ✅ CORRECT: Set prefersLargeTitles once, use largeTitleDisplayMode per VC
class AppCoordinator {
func makeNavigationController() -> UINavigationController {
let nav = UINavigationController(rootViewController: HomeViewController())
nav.navigationBar.prefersLargeTitles = true // set ONCE
return nav
}
}
class HomeViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "Home"
navigationItem.largeTitleDisplayMode = .always
}
}
class DetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "Details"
navigationItem.largeTitleDisplayMode = .never // small title, set in viewDidLoad
}
}For large titles to collapse correctly on scroll, the scroll view's top constraint must pin to view.topAnchor, not safeAreaLayoutGuide.topAnchor, and the scroll view must be the first subview in the view hierarchy:
// ✅ Correct constraint for large-title collapse
collectionView.topAnchor.constraint(equalTo: view.topAnchor)
// ❌ Causes snap-back glitches during scroll
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)The back button's appearance (title, style) is owned by the pushing (previous) view controller's navigationItem. Its action (iOS 16+) is owned by the current (pushed) view controller. Mixing these up is the single most common back-button bug.
// ❌ INCORRECT: Setting backBarButtonItem on the PUSHED VC — has no effect
class DetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.backBarButtonItem = UIBarButtonItem(
title: "Back", style: .plain, target: nil, action: nil
) // This does nothing — wrong VC
}
}
// ✅ CORRECT: Set on the PUSHING (previous) VC
class ListViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
title = "Items"
navigationItem.backBarButtonItem = UIBarButtonItem(
title: "List", style: .plain, target: nil, action: nil
)
}
}iOS 14 added two cleaner APIs, both set on the pushing VC:
// ✅ iOS 14+: Simple back button title string
navigationItem.backButtonTitle = "Items"
// ✅ iOS 14+: Display mode — .default, .generic ("Back"), .minimal (chevron only)
navigationItem.backButtonDisplayMode = .minimaliOS 16 introduced backAction for intercepting back navigation. Unlike the appearance APIs, backAction is set on the current (top/pushed) VC:
// ✅ iOS 16+: Intercept back — set on the CURRENT VC
class EditViewController: UIViewController {
var hasUnsavedChanges = false
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.backAction = UIAction { [weak self] _ in
guard let self else { return }
if hasUnsavedChanges {
showDiscardAlert()
} else {
navigationController?.popViewController(animated: true)
}
}
}
private func showDiscardAlert() {
let alert = UIAlertController(
title: "Unsaved Changes", message: "Discard?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in
self?.navigationController?.popViewController(animated: true)
})
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
present(alert, animated: true)
}
}Priority order for the back button title (highest to lowest): backBarButtonItem.title → backButtonTitle → viewController.title → system "Back" fallback. Note that backButtonDisplayMode does not override backBarButtonItem.title.
Starting with iOS 13, modalPresentationStyle defaults to .automatic (resolving to .pageSheet), not .fullScreen. The presenting VC remains in the hierarchy, and users can swipe down to dismiss. This broke many apps that relied on viewWillAppear of the presenting VC firing after dismissal — it no longer does, because the presenting VC was never removed.
// ❌ PROBLEM: Gets pageSheet by default — swipe dismiss may skip your cleanup
let vc = SettingsViewController()
present(vc, animated: true)
// ✅ Explicit fullScreen when you need the pre-iOS 13 behavior
let vc = SettingsViewController()
vc.modalPresentationStyle = .fullScreen
present(vc, animated: true)Use isModalInPresentation to prevent interactive dismissal. It can be toggled dynamically:
// ✅ Dynamic dismiss prevention
class EditFormViewController: UIViewController {
var hasUnsavedChanges = false {
didSet { isModalInPresentation = hasUnsavedChanges }
}
}For full control over pull-to-dismiss, adopt UIAdaptivePresentationControllerDelegate. The critical setup detail: set the delegate on the presented view controller's presentationController (the navigation controller if you wrapped one around it).
// ❌ INCORRECT: Delegate on the wrong object
editorVC.presentationController?.delegate = self // Wrong if nav wraps editorVC
// ✅ CORRECT: Delegate on the actually presented VC's presentationController
let nav = UINavigationController(rootViewController: editorVC)
nav.presentationController?.delegate = self
present(nav, animated: true)The four key delegate methods:
// ✅ Complete delegate implementation
extension ParentViewController: UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss(
_ pc: UIPresentationController) -> Bool {
guard let nav = pc.presentedViewController as? UINavigationController,
let editor = nav.topViewController as? EditorViewController else {
return true
}
return !editor.hasUnsavedChanges
}
func presentationControllerDidAttemptToDismiss(
_ pc: UIPresentationController) {
let alert = UIAlertController(
title: "Unsaved Changes", message: "Discard?", preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
pc.presentedViewController.dismiss(animated: true)
})
alert.addAction(UIAlertAction(title: "Keep Editing", style: .cancel))
pc.presentedViewController.present(alert, animated: true)
}
func presentationControllerDidDismiss(
_ pc: UIPresentationController) {
// Cleanup: this is NOT called for programmatic dismiss()
resumeParentState()
}
}Note that presentationControllerShouldDismiss and presentationControllerDidAttemptToDismiss are never called for programmatic dismiss(animated:) — only for user-initiated swipe dismissal.
iOS 26 introduces interactiveContentPopGestureRecognizer, a new read-only property on UINavigationController that enables swiping back from anywhere in the content area, not just the screen edge. It coexists with the legacy interactivePopGestureRecognizer (edge-only, iOS 7+). Both must be managed independently.
// ❌ INCORRECT: Only disabling the edge gesture — back swipe still works in iOS 26
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
// Content-area swipe still active!
// ✅ CORRECT: Disable BOTH gestures in iOS 26
navigationController?.interactivePopGestureRecognizer?.isEnabled = false
if #available(iOS 26, *) {
navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = false
}If your app has a custom UIPanGestureRecognizer (map, carousel, drawer) that conflicts with the new content back-swipe, use require(toFail:) to establish priority. Apple's WWDC 2025 session 284 states: "To gain priority over content backswipe, custom gestures need to set failure requirements on interactiveContentPopGestureRecognizer."
// ✅ Your custom gesture takes priority over content back-swipe
if #available(iOS 26, *) {
if let contentPop = navigationController?.interactiveContentPopGestureRecognizer {
contentPop.require(toFail: myCustomPanGesture)
}
}
// ✅ System back-swipe takes priority over your gesture
if #available(iOS 26, *) {
if let contentPop = navigationController?.interactiveContentPopGestureRecognizer {
myCustomPanGesture.require(toFail: contentPop)
}
}iOS 26's Liquid Glass design makes navigation bars transparent by default with bar button items receiving glass capsule backgrounds. Key new UINavigationItem APIs include subtitle, attributedTitle, and subtitleView for richer title areas. UIBarButtonItem gains an identifier property for matching bar buttons across transition animations and hidesSharedBackground to opt out of glass styling.
// ✅ iOS 26: Using new subtitle and attributed title APIs
if #available(iOS 26, *) {
navigationItem.subtitle = "3 unread"
navigationItem.attributedTitle = AttributedString("Inbox",
attributes: .init([.font: UIFont.boldSystemFont(ofSize: 17)]))
}// ✅ iOS 26: Hide glass background on a specific bar button
if #available(iOS 26, *) {
myBarButton.hidesSharedBackground = true
}Apple's updated guidance in TN3106: "Starting in iOS 26, reduce your use of custom backgrounds in navigation elements. Prefer to remove custom effects and let the system determine the navigation bar background appearance." To temporarily opt out of Liquid Glass entirely, set UIDesignRequiresCompatibility = YES in Info.plist (supported only as a transitional measure).
Transitions in iOS 26 are now fluid and interruptible — users can interact with content during animations, swipe back immediately after a wrong tap, or tap back multiple times rapidly without crashes. This reduces (but does not eliminate) the need for the SafeNavigationController pattern described in Section 2.
UITabBarAppearance mirrors the navigation bar's four-slot system. The iOS 15 scrollEdgeAppearance change applies identically: if left nil, the tab bar goes transparent when content is at the scroll edge.
// ✅ iOS 15–18: Properly configured tab bar appearance
let appearance = UITabBarAppearance()
appearance.configureWithDefaultBackground()
appearance.stackedLayoutAppearance.normal.iconColor = .secondaryLabel
appearance.stackedLayoutAppearance.selected.iconColor = .systemBlue
tabBar.standardAppearance = appearance
if #available(iOS 15.0, *) {
tabBar.scrollEdgeAppearance = appearance // prevents transparent tab bar
}// ❌ WRONG: Missing scrollEdgeAppearance on iOS 15+ — transparent tab bar
tabBar.standardAppearance = appearance
// scrollEdgeAppearance is nil → transparent when content is at top!In iOS 26, the tab bar adopts Liquid Glass automatically when compiled with Xcode 26. It floats over content, is centered horizontally, and minimizes on scroll. Apple explicitly recommends removing all custom UITabBarAppearance configuration for iOS 26 — the system handles everything.
// ✅ Cross-version pattern
func configureTabBarAppearance() {
if #available(iOS 26.0, *) {
// Let Liquid Glass handle appearance — do NOT set custom appearances
// Tab item colors auto-adapt based on content beneath the bar
} else if #available(iOS 15.0, *) {
let appearance = UITabBarAppearance()
appearance.configureWithDefaultBackground()
tabBar.standardAppearance = appearance
tabBar.scrollEdgeAppearance = appearance
}
}tabBarMinimizeBehavior controls collapse-on-scroll:
// ✅ iOS 26: Tab bar minimizes when user scrolls down
if #available(iOS 26, *) {
tabBarController.tabBarMinimizeBehavior = .onScrollDown
}Values are .automatic, .never, .onScrollDown, and .onScrollUp.
UITabAccessory adds an accessory view above the tab bar (like Music's mini player). When the tab bar minimizes, the accessory animates inline:
// ✅ iOS 26: Bottom accessory (mini player pattern)
if #available(iOS 26, *) {
let miniPlayer = MiniPlayerView()
let accessory = UITabAccessory(contentView: miniPlayer)
tabBarController.setBottomAccessory(accessory, animated: true)
}The accessory exposes its current state via the tabAccessoryEnvironment trait — .regular when expanded, .inline when compact — so you can adapt your layout accordingly.
UISearchTab gains automaticallyActivatesSearch: when true, switching to the search tab immediately activates the search field, and cancelling search returns the user to the previously selected tab. The search tab renders as a visually separated circular button at the trailing edge of the tab bar.
Adopting the new floating tab bar requires five steps: (1) recompile with Xcode 26 for automatic Liquid Glass; (2) remove custom UITabBarAppearance and backgroundColor overrides behind #available(iOS 26, *) checks; (3) ensure scroll views extend to the bottom of the screen so the glass effect renders correctly; (4) migrate from viewControllers to the UITab API (iOS 18+) for sidebar support; (5) replace custom floating toolbars above the tab bar with UITabAccessory. Set UIDesignRequiresCompatibility = YES as a temporary escape hatch if your app is severely broken.
The most impactful patterns in this guide address problems that are invisible during development but catastrophic in production. setViewControllers eliminates an entire class of deep-link crashes that sequential push calls introduce. The SafeNavigationController queuing pattern prevents the "Can't add self as subview" crash that affects every app with rapid navigation. Setting all four appearance slots on UINavigationBarAppearance — especially scrollEdgeAppearance — fixes the transparent-bar regression that has plagued apps since iOS 15.
interactiveContentPopGestureRecognizer. Apps with custom pan gestures (maps, carousels, drawers) will see gesture conflicts on day one unless they add require(toFail:) relationships. The Liquid Glass design rewards apps that remove customization rather than add it — the fewer appearance overrides you apply, the better iOS 26 navigation looks and behaves. Transition toward per-VC configuration via navigationItem properties and away from global bar-level mutations, and your navigation stack will be robust across the full iOS 15–26 range.UINavigationBarAppearance slots configured (standard, scrollEdge, compact, compactScrollEdge)navigationItem (per-VC, in viewDidLoad) — not on navigationBar in viewWillAppearscrollEdgeAppearance explicitly set — not left nil (causes transparent bar on iOS 15+)transitionCoordinator before push/pop, chain via completionsetViewControllers(_:animated:) used for deep links — not sequential push callsprefersLargeTitles set once on the bar; largeTitleDisplayMode set per VC in viewDidLoadbackBarButtonItem set on the pushing VC, not the displayed VCview.topAnchor, not safeAreaLayoutGuide.topAnchorUITabBarAppearance sets scrollEdgeAppearance to prevent transparent tab bar on iOS 15+require(toFail:) for interactiveContentPopGestureRecognizer