iOS
rootViewController
memory leak
view transition
app development

Leaking views when changing rootViewController inside transitionWithView

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Animating a rootViewController swap is common for login transitions, onboarding completion, and account switching. When the old interface does not deallocate afterward, the problem is usually not UIView.transition itself but some other object still retaining the previous controller tree. The fix is to combine a clean root replacement with disciplined ownership cleanup and leak verification.

What Should Happen During a Root Swap

When a window gets a new rootViewController, the previous root and its view hierarchy should eventually be released. That only happens if nothing else still owns them.

Common retainers include:

  • coordinators storing old navigation stacks
  • timers or display links targeting the old controller
  • strong delegate references
  • closures capturing controllers strongly
  • observers or subscriptions that were never removed or cancelled

If any of those remain alive, the old root graph stays in memory even though it is no longer visible.

A Safe Transition Pattern

Keep the transition code small and do the root replacement directly on the window.

swift
1import UIKit
2
3func replaceRoot(
4    in window: UIWindow,
5    with viewController: UIViewController,
6    animated: Bool
7) {
8    let updateRoot = {
9        let oldState = UIView.areAnimationsEnabled
10        UIView.setAnimationsEnabled(false)
11        window.rootViewController = viewController
12        UIView.setAnimationsEnabled(oldState)
13        window.makeKeyAndVisible()
14    }
15
16    guard animated else {
17        updateRoot()
18        return
19    }
20
21    UIView.transition(
22        with: window,
23        duration: 0.30,
24        options: [.transitionCrossDissolve, .allowAnimatedContent],
25        animations: updateRoot,
26        completion: nil
27    )
28}

This code avoids holding unnecessary references to the previous root inside completion handlers or custom animation objects.

Coordinators Are a Frequent Leak Source

In coordinator-based apps, root transitions often leave old flows alive because the parent coordinator still retains them.

Example pattern to audit:

swift
1final class AppCoordinator {
2    private var childCoordinators: [AnyObject] = []
3    private let window: UIWindow
4
5    init(window: UIWindow) {
6        self.window = window
7    }
8
9    func showMainFlow() {
10        childCoordinators.removeAll()
11        let main = UINavigationController(rootViewController: UIViewController())
12        replaceRoot(in: window, with: main, animated: true)
13    }
14}

If the old login coordinator or navigation controller is still stored somewhere, the transition appears to leak even though the root swap is correct.

Closures and Async Work Can Keep Old UI Alive

Any async callback that captures the old controller strongly can delay or prevent deallocation.

swift
1final class LoginViewController: UIViewController {
2    var onLoginSuccess: (() -> Void)?
3
4    func login() {
5        DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
6            guard self != nil else { return }
7            self?.onLoginSuccess?()
8        }
9    }
10}

Use weak captures when a callback outlives the current screen. This is especially important around network requests, animations, and delayed transitions.

Timers, Observers, and Subscriptions

Hidden retain cycles often come from long-lived infrastructure objects. Check these first:

  • 'Timer.scheduledTimer'
  • 'CADisplayLink'
  • 'NotificationCenter observers'
  • Combine subscriptions
  • delegate properties that should be weak

Example cleanup:

swift
1final class DemoViewController: UIViewController {
2    private var timer: Timer?
3
4    override func viewDidAppear(_ animated: Bool) {
5        super.viewDidAppear(animated)
6        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in }
7    }
8
9    deinit {
10        timer?.invalidate()
11    }
12}

If deinit never runs, the timer or another retainer is still active.

Debug With the Memory Graph, Not Guesswork

The fastest way to diagnose the leak is with Xcode Memory Graph Debugger.

Suggested workflow:

  1. run the app and trigger the root swap
  2. wait until the new root is fully visible
  3. capture the memory graph
  4. search for the old controller type
  5. inspect the retain path back to the owner

This shows the exact object preventing release, which is more reliable than assuming the transition API is at fault.

Snapshot-Based Transitions Can Simplify Ownership

If you want more control, replace the root immediately and animate a snapshot instead of the old controller tree.

High-level flow:

  1. snapshot current window
  2. set new root without animation
  3. place snapshot above new root
  4. fade out snapshot and remove it

That approach decouples animation from controller ownership and can make leak debugging simpler.

Common Pitfalls

  • Blaming UIView.transition when the real leak is a retained coordinator or timer. Fix: inspect retain paths and clean ownership explicitly.
  • Capturing old controllers strongly in async closures. Fix: use weak captures for callbacks that can outlive the screen.
  • Forgetting to cancel observers, timers, or subscriptions. Fix: tear them down in lifecycle or deinit.
  • Keeping global references to old navigation stacks. Fix: clear stored coordinator and controller references during the flow switch.
  • Verifying only visually and never checking deallocation. Fix: use memory graph and temporary deinit logging during debugging.

Summary

  • Root swaps should release the previous controller tree once all strong references are gone.
  • Most leaks come from ownership issues, not from the transition call itself.
  • Keep the transition code minimal and replace the root directly on the window.
  • Audit coordinators, closures, timers, delegates, and subscriptions.
  • Use Xcode Memory Graph to identify the real retainer before changing architecture.

Course illustration
Course illustration

All Rights Reserved.