Building apps that adapt gracefully across size classes and devices has for some time relied on UISplitViewController
. However, in certain configurations on iOS 26, this can lead to undesirable animations when views are initially laid out when a view controller is pushed on to a navigation stack. In this article, we will discuss the root cause of the issue and how to resolve it.
UISplitViewController
has the concept of a compact
column - this is the view controller that displays when the split view is collapsed, either because the app is running on an iPhone or on an iPad within a narrow multitasking window. In many apps, this scenario requires the use of a UITabBarController
to manage the top level of the application's UX hierarchy.
Within each tab in the tab bar, it is not uncommon to have a UINavigationController
to manage the flow in the particular tab. As such, our view controller hierarchy might look like this:
let navigationController = UINavigationController(rootViewController: MyViewController())
let tabBarController = UITabBarController()
tabBarController.viewControllers = [navigationController]
let splitViewController = UISplitViewController(style: .doubleColumn)
splitViewController.setViewController(tabBarController, for: .compact)
In this example, the split view controller has a tab bar controller in its compact column, and the tab bar controller has a single tab, containing a navigation controller. So far, so good. Let's see how this might look if the MyViewController
at the root of the navigation controller shows a button at the top of the view:
So far, so simple. Now let's see what happens if tapping that button pushes another one of our view controllers containing the button on to the navigation stack:
Did you see it? Let's slow things down to get a better look:
Here we can clearly see the problem. The initial layout of our view is being caught in the navigation push animation, causing it to animate from its original frame (at an origin of 0,0
with a width and height of 0
) to its final frame size that fills the available space. Now that we've identified the problem, we can try to come up with a solution.
The most obvious place to start is where the animation is triggered, which is in -[UINavigationController pushViewController:animated:]
. We can subclass UINavigationController
and override this method to try out some solutions:
final class LayoutForcingNavigationController: UINavigationController {
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
viewController.view.layoutIfNeeded()
super.pushViewController(viewController, animated: true)
}
}
Here we force a layout of the view controller's view that is about to be pushed before the push is initiated. Let's see if this helps:
This is better, but we're not quite there. The view being pushed is now correctly sized, but we're still seeing it's content animating down from the top of the screen alongside the push animation. This is because when we tell the view to layout it is not part of the view hierarchy, and so does not have the layout margins set that cause it's content not to underlap the navigation bar. Instead, let's lay out the view after the push has been triggered:
final class LayoutForcingNavigationController: UINavigationController {
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
super.pushViewController(viewController, animated: true)
viewController.view.layoutIfNeeded()
}
}
This is no better, because the layout still occurs before the layout margins have been set. We need to find a way to force a layout once the view has been installed in the view hierarchy but ignoring the animation context of the push transition.
Once our call to the super class implementation of -[UINavigationController pushViewController:animated:]
has completed, we find that the navigation controller's transitionCoordinator
property is populated:
(lldb) po transitionCoordinator
▿ Optional<UIViewControllerTransitionCoordinator>
- some : <_UIViewControllerTransitionCoordinator: 0x600001701140>
We can use -[UIViewControllerTransitionCoordinator animateAlongsideTransition:completion:]
to manipulate the view being pushed as the transition beings:
final class LayoutForcingNavigationController: UINavigationController {
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
super.pushViewController(viewController, animated: true)
transitionCoordinator?.animate { _ in
UIView.performWithoutAnimation {
viewController.view.layoutIfNeeded()
}
}
}
}
Here we are triggering a layout of the view being pushed, but do so within a call to +[UIView performWithoutAnimation:]
's closure, which escapes the transition's animation context. Let's see how this looks:
Hurrah! Our view now appears how we expect - it is pushed on to the navigation stack already laid out with the correct frame and correct content insets. All we need to do is replace our creation of UINavigationController
with an instance of LayoutForcingNavigationController
, and the layout animation problem is solved.
Of course, this behaviour seen in iOS 26 is a bug and should be fixed. Please duplicate FB19766298
in Feedback Assistant to help get this prioritised.