Problems with UITabBarController tab switching in iOS 18
I recently inherited a project - an iOS app that hadn't been worked on in a while. After I made all the necessary updates and finally launched it on a device with the most up-to-date version of the operating system (at that time, it was iOS 18.4), I noticed an interesting but not too pleasant situation: when switching tabs in the bottom menu (implemented using UITabBarController and Storyboard), the screen flickered noticeably: for a fraction of a second, an empty space was shown, and only then the content appeared. A simple check showed that on iOS 17 and below, all switching was smooth, and the user immediately saw the real content of the selected tab. So, we have a scenario and context for the bug - it's time to get to the bottom of it. Obviously, since the problem is only showing up on devices with iOS 18 and above, we need to figure out what changed in the major release. Some googling, reading documentation and forums confirmed the assumption that the incorrect behavior may be related to changes in the UITabBarController rendering system or in the UIViewController lifecycle order that Apple introduced in the new iOS version. That's when the first ideas to establish the exact cause, and therefore fix the problem, also appeared. There may have been some optimizations under the hood of TabBar. For example, it was suggested that View in tabs were loaded “lazily”, which caused a delay before the content was displayed; asynchrony and optimizations could have been applied at the level of the whole controller rather than at the level of individual View; the ViewController lifecycle and the sequence of calls to the main methods of the lifecycle (such as viewWillAppear / viewDidAppear) may have changed. In this case, newer versions of iOS would first delete old content and then add new content. In order to level out the problem of asynchronous loading of controllers, we had to check the force loading of tab controllers and their root View: override func viewDidLoad() { super.viewDidLoad() // force load all view controllers self.viewControllers?.forEach { viewController in _ = viewController.view // force load VC's view } } It was expected that this approach, although it would increase resource consumption when starting the root TabBarController, would still provide a more familiar and pleasant user experience. Reality: it didn't help, the tabs kept flickering. Let's now check out the lifecycle behavior of ViewControllers. Changes in the behavior of viewWillAppear and viewDidAppear in iOS 18 have not been officially documented by Apple yet, but according to developer observations and testing on beta and RC-versions of iOS 18 we can note the following New lifecycle call sequence when changing tabs. On iOS 17, when switching between tabs, the UITabBarController immediately called viewWillAppear / viewDidAppear on the new selectedViewController, even before the view was added to the window hierarchy. On iOS 18 - the view hierarchy is changed first, then the lifecycle methods are called. This makes visible a short delay if the view has not been loaded or layout is not yet calculated. Lazy (deferred) loading of View. Right now, UITabBarController defers the creation of view child controllers until the first display. In iOS 17, the behavior was less aggressive - views were created immediately after being added to viewControllers (we already checked this with the first point). In iOS 18, you may notice that layout happens a bit later (especially when the tab first appears). This can cause an empty view to be briefly displayed. To test these scenarios, all the controllers in the tabs and the UITabViewController itself were set to a background color that matches the general style of the application - and it turned out that now the screen flickers with this color. Certainly, getting closer to the general theme of the application was a nice improvement, but it didn't solve the flickering problem. Here's how it was achieved: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .appMainBackgroundColor // custom color, created with extension } By the way, installing backgroundView solved another problem: when switching from one of the controllers to another, the bottom menu itself flashed white before disappearing. Now during the transition the menu merged with the main content and the transition looked smoother. Well, apparently it's not about data or layout - it's about the moment when the system adds the new controller's view to the hierarchy when switching tabs. Since the view is already loaded (we force it at startup), but still flickers - it means that the problem is in the moment of displaying, not initialization. What is going on? Further debugging showed that in iOS 18, between clicking on the tab and the final insertion of the view into the UIWindow hierarchy, there is a brief interval in which the UITabBarController remove

I recently inherited a project - an iOS app that hadn't been worked on in a while. After I made all the necessary updates and finally launched it on a device with the most up-to-date version of the operating system (at that time, it was iOS 18.4), I noticed an interesting but not too pleasant situation: when switching tabs in the bottom menu (implemented using UITabBarController
and Storyboard
), the screen flickered noticeably: for a fraction of a second, an empty space was shown, and only then the content appeared. A simple check showed that on iOS 17 and below, all switching was smooth, and the user immediately saw the real content of the selected tab. So, we have a scenario and context for the bug - it's time to get to the bottom of it.
Obviously, since the problem is only showing up on devices with iOS 18 and above, we need to figure out what changed in the major release. Some googling, reading documentation and forums confirmed the assumption that the incorrect behavior may be related to changes in the UITabBarController
rendering system or in the UIViewController lifecycle order that Apple introduced in the new iOS version. That's when the first ideas to establish the exact cause, and therefore fix the problem, also appeared.
- There may have been some optimizations under the hood of TabBar. For example, it was suggested that View in tabs were loaded “lazily”, which caused a delay before the content was displayed;
- asynchrony and optimizations could have been applied at the level of the whole controller rather than at the level of individual View;
- the ViewController lifecycle and the sequence of calls to the main methods of the lifecycle (such as viewWillAppear / viewDidAppear) may have changed. In this case, newer versions of iOS would first delete old content and then add new content.
In order to level out the problem of asynchronous loading of controllers, we had to check the force loading of tab controllers and their root View:
override func viewDidLoad() {
super.viewDidLoad()
// force load all view controllers
self.viewControllers?.forEach { viewController in
_ = viewController.view // force load VC's view
}
}
It was expected that this approach, although it would increase resource consumption when starting the root TabBarController, would still provide a more familiar and pleasant user experience.
Reality: it didn't help, the tabs kept flickering.
Let's now check out the lifecycle behavior of ViewControllers. Changes in the behavior of viewWillAppear and viewDidAppear in iOS 18 have not been officially documented by Apple yet, but according to developer observations and testing on beta and RC-versions of iOS 18 we can note the following
- New lifecycle call sequence when changing tabs. On iOS 17, when switching between tabs, the UITabBarController immediately called viewWillAppear / viewDidAppear on the new selectedViewController, even before the view was added to the window hierarchy. On iOS 18 - the view hierarchy is changed first, then the lifecycle methods are called. This makes visible a short delay if the view has not been loaded or layout is not yet calculated.
- Lazy (deferred) loading of View. Right now, UITabBarController defers the creation of view child controllers until the first display. In iOS 17, the behavior was less aggressive - views were created immediately after being added to viewControllers (we already checked this with the first point).
- In iOS 18, you may notice that layout happens a bit later (especially when the tab first appears). This can cause an empty view to be briefly displayed.
To test these scenarios, all the controllers in the tabs and the UITabViewController itself were set to a background color that matches the general style of the application - and it turned out that now the screen flickers with this color. Certainly, getting closer to the general theme of the application was a nice improvement, but it didn't solve the flickering problem. Here's how it was achieved:
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .appMainBackgroundColor // custom color, created with extension
}
By the way, installing
backgroundView
solved another problem: when switching from one of the controllers to another, the bottom menu itself flashed white before disappearing. Now during the transition the menu merged with the main content and the transition looked smoother.
Well, apparently it's not about data or layout - it's about the moment when the system adds the new controller's view to the hierarchy when switching tabs. Since the view is already loaded (we force it at startup), but still flickers - it means that the problem is in the moment of displaying, not initialization.
What is going on? Further debugging showed that in iOS 18, between clicking on the tab and the final insertion of the view into the UIWindow hierarchy, there is a brief interval in which the UITabBarController removes the old controller, but has not yet inserted the new one (or layout has not happened yet). At this point, the background of the main controller is visible, which looks like “flickering”.
In order to avoid this, we can try to postpone the removal of the old viewController - for this purpose we will override the switching behavior in the custom UITabBarController (we already had it, created long ago and assigned to Storyboard):
class CustomTabBarController: UITabBarController, UITabBarControllerDelegate {
override func viewDidLoad() {
super.viewDidLoad()
self.delegate = self
}
// called before switching the tab
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if #available(iOS 18, *) {
guard let fromView = selectedViewController?.view,
let toView = viewController.view,
fromView != toView else {
return viewController != selectedViewController
}
// Custom transition, to "hide" blinking
UIView.transition(
from: fromView, to: toView,
duration: 0.01, // almost immediately
options: [.transitionCrossDissolve]
) { _ in }
}
return viewController != selectedViewController
}
}
Run it - it works! This is an easy way to “glue” views to each other so that there is no phase when the screen is empty. You don't even need to force View creation!
Thanks for reading! Hope it helps, and happy coding to you!
If you found this post usefult, feel free to clap and subscribe!