Search code examples
swiftuiuinavigationbaruitabbaritemuifont

Re-apply styling to UITabBarItems without reloading app


I have logic that lets the user change Font.Design, which works fine for SwiftUI views, and also the navigation bar (logic included below). But it does not reload or re-apply to existing TabView toolbars, a reload is required. What is the best way to update the views live?

@MainActor
private func configureTabBars(with fontDesign: Font.Design) {
    UINavigationBar.appearance().largeTitleTextAttributes = [
        .font : UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1).withDesign(fontDesign.systemDesign)!, size: 0)
    ]
    UITabBarItem.appearance().setTitleTextAttributes([
        .font : UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1).withDesign(fontDesign.systemDesign)!, size: 0)
    ], for: [])
}

private extension Font.Design {
    var systemDesign: UIFontDescriptor.SystemDesign {
        switch self {
        case .serif: .serif
        case .rounded: .rounded
        case .monospaced: .monospaced
        default: .default
        }
    }
}


Solution

  • Similar to this question, the reason why appearance() won't work is that it won't affect views that are already added to the window's view hierarchy.

    Similar to the linked question, this can be done by using a UIViewControllerRepresentable and finding the UINavigationController and UITabBarItem from the view controller we control.

    struct BarAppearances: UIViewControllerRepresentable {
        let fontDesign: Font.Design
        // instead of passing a Font.Design through the initialiser like above,
        // also consider injecting it into the environment, so you can read it directly like
        // @Environment(\.fontDesign) var fontDesign
        
        class VC: UIViewController {
            var fontDesign: Font.Design = .default
            
            override func viewWillAppear(_ animated: Bool) {
                super.viewWillAppear(animated)
                updateBarAppearances()
            }
            
            func updateBarAppearances() {
                let font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .caption1).withDesign(fontDesign.systemDesign)!, size: 0)
                
                // here I am using the new UIBarAppearance APIs, but the old 'largeTitleTextAttributes' should work too
                let navAppearance = UINavigationBarAppearance()
                navAppearance.configureWithOpaqueBackground()
                navAppearance.largeTitleTextAttributes = [.font: font]
                
                for item in tabBarController?.tabBar.items ?? [] {
                    item.setTitleTextAttributes([.font: font], for: [])
                }
                
                navigationController?.navigationBar.standardAppearance = navAppearance
                navigationController?.navigationBar.scrollEdgeAppearance = navAppearance
                navigationController?.navigationBar.compactAppearance = navAppearance
                navigationController?.navigationBar.compactScrollEdgeAppearance = navAppearance
                
            }
        }
        
        func makeUIViewController(context: Context) -> VC {
            VC()
        }
        
        func updateUIViewController(_ uiViewController: VC, context: Context) {
            uiViewController.fontDesign = fontDesign
            uiViewController.updateBarAppearances()
        }
    }
    

    To use this, put it as the background of the root view in the NavigationStack:

    struct ContentView: View {
        @State var id = UUID()
        @State var design = Font.Design.default
        
        var body: some View {
            TabView {
                NavigationStack {
                    VStack {
                        Button("Set to monospace") {
                            design = .monospaced
                        }
                        Button("Set to rounded") {
                            design = .rounded
                        }
                    }
                    .navigationTitle("Some Nav Title")
                    .background { BarAppearances(fontDesign: design) }
                }
                .tabItem {
                    Text("Some Tab Title")
                }
            }
        }
    }
    

    Another way to solve this is to simply recreate the whole view hierarchy by changing the id of the TabView.

    Here is a very simple example

    @State var id = UUID()
    var body: some View {
        TabView {
            NavigationStack {
                VStack {
                    Button("Set to monospace") {
                        configureTabBars(with: .monospaced)
                        id = UUID()
                    }
                    Button("Set to rounded") {
                        configureTabBars(with: .rounded)
                        id = UUID()
                    }
                }
                .navigationTitle("Some Nav Title")
            }
            .tabItem {
                Text("Some Tab Title")
            }
        }
        .id(id)
    }
    

    The problem with this is that all the @States of the individual tabs will be lost. Though, since this is changing the app's theme, I personally wouldn't mind, as a user.