How to change UINavigationBarAppearance on an already rendered UINavigationBar?

In my app, users can select from a range of UI color-palettes to customize the look of the app to suite their preference.

The app's root viewcontroller is a UINavigationController (this app is not using SwiftUI, nor has it adopted UIScene yet). However, I noticed only the first time (to be specific: this is in the appDelegate's application(:didFinishLaunchingWithOptions:), so before anything is actually rendered to the screen) the appearance settings are applied, but subsequent changes do not take effect. However, the following code seems to 'fix' the issue:

self.window?.rootViewController = nil
self.updateAppearance()
self.window?.rootViewController = navigationController

This to me seems hacky/kludgy and unlikely to be a sustainable 'fix', and I think it indicates that the appearance information is only taken into account when a view(controller) gets inserted into the view(controller) hierarchy, in other words: when it gets drawn. So then the question would be: how to tell a UINavigationController / Bar to redraw using the updated appearance settings?

I'm sure I'm overlooking something relatively simple (as I imagine this kind of thing is something many apps do to support darkmode), but alas, I can't seem to find it.

I setup a small test-project to isolate this issue. It has only one custom viewcontroller (ContentViewController) which only sets the backgroundColor in viewDidLoad(), and an AppDelegate (code below). No storyboards, no UIScene related things.

So, here is the minimal code that reproduces my issue:

AppDelegate.swift:

import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate
{
    var window: UIWindow?
    var rootNavigationController: UINavigationController?
    let contentViewController = ViewController()
    
    let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.main)
    
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool
    {
        window = UIWindow()
        
        let navigationController = UINavigationController(rootViewController: contentViewController)
        rootNavigationController = navigationController
        
        window?.rootViewController = navigationController
        window?.makeKeyAndVisible()
        
        // initial setup of appearance, this first call will take effect as expected
        updateAppearance()
    
        // setup a timer to repeatedly change the appearance of the navigationbar
        timer.setEventHandler
        {
            /// ! uncommenting this line and the one after `self.updateAppearance()`
            /// will cause `self.updateAppearance()` to take effect as expected
            // self.window?.rootViewController = nil
            
            self.updateAppearance()
            
            //self.window?.rootViewController = navigationController
        }
        
        timer.schedule(deadline: .now(),
                       repeating: .seconds(2),
                       leeway: .milliseconds(50))
        timer.resume()
        return true
    }
    
    // some vars to automatically toggle between colors / textstyles
    var colorIndex = 0
    let colors = [ UIColor.blue, UIColor.yellow]
    let textStyles: [UIFont.TextStyle] = [.title3, .body]
    func updateAppearance()
    {
        let color = colors[colorIndex]
        let textStyle = textStyles[colorIndex]
        
        contentViewController.title = "bgColor: \(colorIndex) \(textStyle.rawValue)"
        
        colorIndex = (colorIndex == 0) ? 1 : 0
        let textColor = colors[colorIndex]
        
        self.rootNavigationController?.navigationBar.isTranslucent = false
            
        let navigationBarAppearance = UINavigationBarAppearance()
        navigationBarAppearance.configureWithOpaqueBackground()
        navigationBarAppearance.backgroundColor = color

        let textAttributes: [NSAttributedString.Key : Any] =
        [.font: UIFont.preferredFont(forTextStyle: textStyle),
         .foregroundColor: textColor ]        

        navigationBarAppearance.titleTextAttributes = textAttributes
        
        UINavigationBar.appearance().standardAppearance = navigationBarAppearance
        UINavigationBar.appearance().compactAppearance = navigationBarAppearance
        UINavigationBar.appearance().scrollEdgeAppearance = navigationBarAppearance
    }
}

ViewController:

import UIKit
class ViewController: UIViewController
{
    override func viewDidLoad()
    {
        super.viewDidLoad()
        view.backgroundColor = UIColor.lightGray
    }
}


Accepted Reply

I'm not recommending you move back to the old navigation bar appearance API (e.g. barTintColor and friends). I'm just recommending you not call +appearance to set the standardAppearance and scrollEdgeAppearance.

If you are running on iOS 13 or later, we absolutely recommend you only use the UINavigationBarAppearance based APIs to customize your navigation bar (tintColor is a UIView property, and so is fine to use as well). But you do not have to use them via the Appearance proxy (aka +[UIView appearance]).

So all you need to do is change this:

if (@available(iOS 13.0, *))
{
    [UINavigationBar appearance].standardAppearance.backgroundColor = color;
    [UINavigationBar appearance].compactAppearance = [UINavigationBar appearance].standardAppearance;
    [UINavigationBar appearance].scrollEdgeAppearance = [UINavigationBar appearance].standardAppearance;
}

to this:

if (@available(iOS 13.0, *))
{
    navigationBar.standardAppearance.backgroundColor = color;
    navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance;
}

(it is rarely necessary to set the compactAppearance because if it is not set we simply use standardAppearance in its place).

Replies

The appearance proxy is only evaluated when a view enters the view hierarchy (not necessarily related to drawing). Using the appearance proxy as an app customization scheme has its pitfalls in other ways however, as the more times you use the appearance proxy with a specific property the slower it gets (it actually has to apply each appearance customization you ever make, in order, when a view enters the view hierarchy).

So in practice your kludge is expectedly kludgy, but primarily because the appearance proxy isn't really meant to be used in this way (although I can't really argue against doing this for this particular feature either – its really hard to update everything in your app if you have a lot of things to update that are all over).

Thanks for a your answer Rincewind. I don't have a particularly large app, there are only 4 or 5 viewcontrollers in total. The reason I am working with the appearance api is because it seems I have no choice: in when building against iOS16, the navigationbar behaves differently (i.e. it is transparent in iOS16, whereas it is not when building against iOS15). I came across this thread in which you provided a solution using the appearance api, which makes it seem to me that the only way to solve my original issue is to use the appearance apis.

If there is a way to do update the colors and attributes of a navigationbar 'manually' in iOS16, without using the appearance apis, I would love to hear it. There is only one code-path for this feature, so it would be simple enough (famous last words...).

There are 2 levels of "appearance" going on here, and unfortunately its a bit confusing I think.

When I say the appearance proxy I mean code like UINavigationBar.appearance() – this code creates a UINavigationBar-like object that you can treat just like a UINavigationBar for the purpose of setting many values that alter the bar's appearance. The thread you linked references the class UINavigationBarAppearance which is a consolidated object that lets you customize many aspects of a UINavigationBar's appearance.

So yea, its a bit confusing when you have to write it all out together like that.

If you are only changing the appearance of the navigation bar, you can just adjust it directly with code like navigationController.navigationBar.standardAppearance... (ditto for the other properties). You don't have to go through .appearance() at all.

Thanks, that is helpful!

I think it is important to explain the VC hierarchy a bit (it involves a UIScrollView (well, UICollectionView), which seems to have some acquired 'behind-the-scenes' connections with UINavigationBar)

UIWindow
|-- UINavigationController
     |-- CustomContainerViewController (1)
           |-- CustomContainerViewController (2) +  DrawerViewController // from top side
                 |-- ContentViewController + DrawerViewController // from right side

CustomContainerViewController (1) contains two viewcontrollers: one filling the view of the container itself, the 'content' which is itself a CustomContainerViewController (the one called CustomContainerViewController (2). The 2nd viewcontroller in this CustomContainerViewController (1) is one that can be pulled down using a 'handle', drawn by the CustomContainerViewController (1), (making it seem as if it is pulled from behind the navigationbar).

CustomContainerViewController (2) is the 'content' of CustomContainerViewController (1). This container has the actual content in it's first viewController, and another viewController that can be pulled in from the right side of the screen, using another 'handle'. That last one (the one that can be pulled in from the right side of the screen), has a UICollectionView filling the view of that viewController (not sure if this is important).

Here is the code that is called in -application:didFinishLaunchingWithOptions: and in response to a button tapped by the user to change the navigationBar's color:

UIColor *color = ...;
UIColor *textColor = ...;
self.rootNavigationController.navigationBar.barTintColor = color;
self.rootNavigationController.navigationBar.tintColor = textColor;
self.rootNavigationController.navigationBar.translucent = NO;

When building this against iOS16, the app has a transparent UINavigationBar, not what it should be: an opaque color. When those three lines hit, the navigationbar stays transparent. With a small change (see below), the navigationbar now has the correct color (after this code gets called in application:didFinishLaunchingWithOptions:), but afterwards this code seems to have no effect.

UIColor *color = ...;
UIColor *textColor = ...;
if (@available(iOS 13.0, *))
{
    [UINavigationBar appearance].standardAppearance.backgroundColor = color;
    [UINavigationBar appearance].compactAppearance = [UINavigationBar appearance].standardAppearance;
    [UINavigationBar appearance].scrollEdgeAppearance = [UINavigationBar appearance].standardAppearance;
}
self.rootNavigationController.navigationBar.barTintColor = color;
self.rootNavigationController.navigationBar.tintColor = textColor;
self.rootNavigationController.navigationBar.translucent = NO;

Might this be a bug in the iOS16 sdk?

This is how this is supposed to work. If you call something on an appearance proxy, it only takes effect when a view gets added to the view hierarchy / the window.

It is meant for apps that have a very clear CI they have to meet. For example "our navigation bar should always have our company color as its background".

For dynamically changing styles you should instead update the navigation bar directly. As my colleague pointed out the biggest issue with appearance proxy here is that it gets slower every time you request a new proxy (i.e. call -appearance).

So in your case you should update everything directly. Unfortunately you have to do that for every navigation controller you want to do this with individually.

UINavigationBar *navigationBar = self.rootNavigationController.navigationBar;
UINavigationBarAppearance *appearance = navigationBar.standardAppearance;
appearance.backgroundColor = color;
navigationBar.standardAppearance = appearance;
navigationBar.compactAppearance = appearance;
navigationBar.scrollEdgeAppearance = appearance;

Thanks for taking the time and effort to bear with me, I appreciate it!

So in your case you should update everything directly. Unfortunately you have to do that for every navigation controller you want to do this with individually.

This is not a problem at all: there is only one navigationbar in my app, and so I am quite happy to update the navigation bar directly. In fact, this is how I wrote it originally, and worked great:

self.rootNavigationController.navigationBar.barTintColor = color;
self.rootNavigationController.navigationBar.tintColor = textColor;
self.rootNavigationController.navigationBar.translucent = NO;

The issue is that this code no longer works when building against iOS16: the navigation bar stays transparent no matter how many times this code is called.

For dynamically changing styles you should instead update the navigation bar directly. As my colleague pointed out the biggest issue with appearance proxy here is that it gets slower every time you request a new proxy (i.e. call -appearance).

So then my question would be: what is the proper way to update the navigation bar directly in iOS16?

I'm not recommending you move back to the old navigation bar appearance API (e.g. barTintColor and friends). I'm just recommending you not call +appearance to set the standardAppearance and scrollEdgeAppearance.

If you are running on iOS 13 or later, we absolutely recommend you only use the UINavigationBarAppearance based APIs to customize your navigation bar (tintColor is a UIView property, and so is fine to use as well). But you do not have to use them via the Appearance proxy (aka +[UIView appearance]).

So all you need to do is change this:

if (@available(iOS 13.0, *))
{
    [UINavigationBar appearance].standardAppearance.backgroundColor = color;
    [UINavigationBar appearance].compactAppearance = [UINavigationBar appearance].standardAppearance;
    [UINavigationBar appearance].scrollEdgeAppearance = [UINavigationBar appearance].standardAppearance;
}

to this:

if (@available(iOS 13.0, *))
{
    navigationBar.standardAppearance.backgroundColor = color;
    navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance;
}

(it is rarely necessary to set the compactAppearance because if it is not set we simply use standardAppearance in its place).

Thank you Rincewind, that is th answer that made it all clear to me. I guess I should have l led with my last post when opening this thread. I'm happy it now works, and more importantly, I understand why it works!