iosswiftswiftuiswiftui-navigationviewswiftui-tabview

Different Nav Bar Appearances for different tabs in TabView in SwiftUI


I've got a TabView which hosts 3 tabs (Liabilities, In/Out, and Assets). Each tab has a NavigationView in it. I want to have a different Nav Bar Appearance for each (red themed for Liabilities, white for In/Out, and green-themed for Assets).

I am able to set the background colors of the nav bars without any difficulty, using something like this:

.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarBackground(Color.liabilitiesnav, for: .navigationBar)

This only lets me set the color of the background though, but I need to be able to change the colors of the other elements in the nav bar. The buttons that I add to the toolbar I can control by explicitly setting their colors, so that's no problem. But the nav title and the back button text and icon are only controllable using the global UINavigationBar.appearance() functionality. But I don't want a global appearance, I want to configure the different tabs with different appearances. This is really important because my AccentColor is a dark green and while that green looks nice on the back button and toolbar items on the Assets tab... it is a horrible green on red on the Liabilities tab. That's why I need to be able to control them separately.

screenshots of the nav bar and tab bar for 3 screens

I've tried using an .onAppear { } mechanic to try to change the global appearance to match the current tab whenever that tab appears. For example, on the Liabilities tab, I have:

NavigationView {
    List {
        // stuff
    }
    .onAppear {
        // tried it here...
        NavHelper.useRedAppearance()
    }
} 
.onAppear {
    // and tried it here as well
    NavHelper.useRedAppearance()
}

However, it seems to get out of sync. It will start off correctly (Liabilities = red and Assets = green) but when I click back and forth between the Liabilities and Assets tabs, the updates seem to get out of sync and sometimes the Liabilities shows up green and sometimes the Assets shows up red. I added some print statements to the onAppear code and I could see that the useRedAppearance() was getting called when I clicked on the Liabilities tab and the useGreenAppearance() was getting called when I clicked on the Assets tab... but the colors wouldn't necessarily update every time... and thus, got out of sync.

Here is a partial paste of NavHelper just in case I'm doing something wrong in there:

class NavHelper {   

    static func useRedAppearance() {
        let textcolor = UIColor.moneyred
        let backgroundcolor = UIColor.liabilitiesnav
        
        let appearance = UINavigationBarAppearance()
        appearance.configureWithOpaqueBackground()
        appearance.backgroundColor = backgroundcolor
        appearance.titleTextAttributes = [.foregroundColor: textcolor]
        appearance.largeTitleTextAttributes = [.foregroundColor: textcolor]
        
        let buttonAppearance = UIBarButtonItemAppearance()
        buttonAppearance.normal.titleTextAttributes = [.foregroundColor: textcolor]
        
        let image = UIImage(systemName: "chevron.backward")!.withTintColor(textcolor, renderingMode: .alwaysOriginal)
        appearance.setBackIndicatorImage(image, transitionMaskImage: image)
        
        appearance.buttonAppearance = buttonAppearance
        appearance.backButtonAppearance = buttonAppearance
        
        UINavigationBar.appearance().standardAppearance = appearance
        UINavigationBar.appearance().scrollEdgeAppearance = appearance
        UINavigationBar.appearance().compactAppearance = appearance
        UINavigationBar.appearance().compactScrollEdgeAppearance = appearance
        
    }

}

How can I either (a) reliably switch the global appearance back and forth without getting out of sync, or (b) individually configure the views in the different tabs so they just have a fixed color theme?


Solution

  • The colors get out of sync because the UIAppearance API cannot change the appearance of existing views. Sometimes the navigation bar is moved to the window before useRedAppearance is called, causing it to not be affected.

    If you just want to control the back button color, you can use tint:

    NavigationStack {
        SomeView()
            // 'tint' on the NavigationStack also affects tints of other views in the navigation stack
            // so we apply another 'tint' here, to change the tint back
            .tint(Color.accent)
    }
    // this sets the back button color to red
    .tint(.red)
    

    If you also want to change the color of the title, place a coloured Text as the .principal tool bar item.

    .toolbar {
        ToolbarItem(placement: .principal) {
            Text("Liabilities")
                .foregroundStyle(.yellow)
        }
    }
    

    Alternatively, you can use a UIViewControllerRepresentable to get a hold of the UINavigationController and set the colors of that.

    struct NavigationAppearance: UIViewControllerRepresentable {
        var textColor: UIColor
        var backgroundColor: UIColor
        
        class VC: UIViewController {
            var textColor: UIColor = .clear
            var backgroundColor: UIColor = .clear
            
            override func viewWillAppear(_ animated: Bool) {
                super.viewWillAppear(animated)
                updateBarAppearance()
            }
            
            func updateBarAppearance() {
                let appearance = UINavigationBarAppearance()
                appearance.configureWithOpaqueBackground()
                appearance.backgroundColor = backgroundColor
                appearance.titleTextAttributes = [.foregroundColor: textColor]
                appearance.largeTitleTextAttributes = [.foregroundColor: textColor]
                
                let buttonAppearance = UIBarButtonItemAppearance()
                buttonAppearance.normal.titleTextAttributes = [.foregroundColor: textColor]
                
                let image = UIImage(systemName: "chevron.backward")!.withTintColor(textColor, renderingMode: .alwaysOriginal)
                appearance.setBackIndicatorImage(image, transitionMaskImage: image)
                
                appearance.buttonAppearance = buttonAppearance
                appearance.backButtonAppearance = buttonAppearance
                
                navigationController?.navigationBar.standardAppearance = appearance
                navigationController?.navigationBar.scrollEdgeAppearance = appearance
                navigationController?.navigationBar.compactAppearance = appearance
                navigationController?.navigationBar.compactScrollEdgeAppearance = appearance
                
            }
        }
        
        func makeUIViewController(context: Context) -> VC {
            VC()
        }
        
        func updateUIViewController(_ uiViewController: VC, context: Context) {
            uiViewController.textColor = textColor
            uiViewController.backgroundColor = backgroundColor
        }
    }
    

    All you need to do then is to put this in the view hierarchy somewhere, e.g. background

    NavigationStack {
        SomeView()
            .navigationTitle("Assets")
            .navigationBarTitleDisplayMode(.inline)
            .background {
                NavigationAppearance(textColor: .systemRed, backgroundColor: .systemGreen)
            }
    }