Issue Description
Hi, I would like to share an issue with UIViewController's appearance callbacks inside UITabBarController + UINavigationController on iOS 27.
When a tab containing a UINavigationController with two view controllers is re-tapped, UIKit starts its built-in animated pop-to-root. On iOS 27, if the user switches to another tab while that pop animation is still in flight, the popped view controller receives viewWillDisappear: (with isMovingFromParent == true), but viewDidDisappear: is never called on it. The appearance callbacks stay unbalanced.
This breaks code that relies on viewDidDisappear: + isMovingFromParent to detect that a view controller was popped.
Steps to Reproduce
- Create a
UITabBarControllerwith two tabs. The first tab is aUINavigationController. - In the first tab, push a second view controller ("Nested").
- Tap the first tab item again. UIKit starts the animated pop-to-root.
- Immediately (while the pop animation is still running), tap the second tab item.
Expected vs. Actual Behavior
- Expected: On iOS 26, "Nested" receives
viewWillDisappear:and thenviewDidDisappear:, both withisMovingFromParent == true. - Actual: On iOS 27, "Nested" receives only
viewWillDisappear:.viewDidDisappear:is NOT called, even though "Nested" is no longer innavigationController.viewControllers.
Note: On iOS 27, if the second tab is tapped after the pop animation has finished, viewDidDisappear: is delivered correctly. The issue only occurs when the tab switch happens during the pop transition.
Environment
- Xcode Version 27.0
- iOS 27.0 iPhone 18 Pro simulator: reproduces
- iOS 26.0 iPhone 17 Pro simulator: does not reproduce
- Feedback Assistant Report ID: FB24934469
Minimal Reproduction Example
// SceneDelegate.swift
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = TabBarController()
window.makeKeyAndVisible()
self.window = window
}
}
// TabBarController.swift
import UIKit
final class TabBarController: UITabBarController {
override func viewDidLoad() {
super.viewDidLoad()
let firstNav = UINavigationController(rootViewController: HomeViewController())
firstNav.tabBarItem = UITabBarItem(title: "First", image: UIImage(systemName: "house"), tag: 0)
let second = LoggingViewController(name: "Second")
second.tabBarItem = UITabBarItem(title: "Second", image: UIImage(systemName: "star"), tag: 1)
viewControllers = [firstNav, second]
}
}
class LoggingViewController: UIViewController {
let name: String
init(name: String) {
self.name = name
super.init(nibName: nil, bundle: nil)
title = name
}
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
print("[\(name)] viewWillDisappear isMovingFromParent=\(isMovingFromParent)")
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
print("[\(name)] viewDidDisappear isMovingFromParent=\(isMovingFromParent)")
}
}
final class HomeViewController: LoggingViewController {
init() { super.init(name: "Home") }
required init?(coder: NSCoder) { fatalError() }
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "Push",
primaryAction: UIAction { [weak self] _ in
self?.navigationController?.pushViewController(LoggingViewController(name: "Nested"), animated: true)
}
)
}
}