Search code examples
iosswiftuiuinavigationcontrolleruikituitabbarcontroller

Navigation bar briefly flickering as large title before changing to inline


I'm using a UITabController in my SwiftUI app and it works well, except for one issue.

When I have a NavigationView that uses a large title, it changes to inline as I scroll, as expected. So far so good.

When I switch tabs, then switch back again, it should remember the scroll position and therefore be inline. Instead, it very briefly flickers as a large title before moving to inline.

This only seems to happen on device.

Screen recording of the issue

Is anyone able to help me with a workaround?

struct ContentView: View {
    var body: some View {
        CustomTabView([
            Tab(view: FirstView(),
                barItem: UITabBarItem(title: "First", image: UIImage(systemName: "1.circle"), selectedImage: nil)),
            Tab(view: SecondView(),
                barItem: UITabBarItem(title: "Second", image: UIImage(systemName: "2.circle"), selectedImage: nil))
        ])
    }
}

struct FirstView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach( 0...40, id: \.self ) {
                    Text("\($0)")
                        .padding()
                }
            }
            .navigationTitle("First View")
        }
    }
}

struct SecondView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach( 41...80, id: \.self ) {
                    Text("\($0)")
                        .padding()
                }
            }
            .navigationTitle("Second View")
        }
    }
}

struct TabBarController: UIViewControllerRepresentable {
    var controllers: [UIViewController]

    func makeUIViewController(context: Context) -> UITabBarController {
        let tabBarController = UITabBarController()
        tabBarController.viewControllers = controllers
        tabBarController.delegate = context.coordinator
        return tabBarController
    }

    func updateUIViewController(_ uiViewController: UITabBarController, context: Context) {}
    
    func makeCoordinator() -> Coordinator {
        Coordinator()
    }
    
    class Coordinator: NSObject, UITabBarControllerDelegate {
        func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
            guard let fromView = tabBarController.selectedViewController?.view, let toView = viewController.view else {
                return false
            }
            if fromView != toView {
                fromView.superview!.addSubview(toView)
            }
            return true
        }
    }
}

struct CustomTabView: View {
    var viewControllers: [UIHostingController<AnyView>]

    init(_ tabs: [Tab]) {
        self.viewControllers = tabs.map {
            let host = UIHostingController(rootView: $0.view)
            host.tabBarItem = $0.barItem
            return host
        }
    }

    var body: some View {
        TabBarController(controllers: viewControllers)
            .edgesIgnoringSafeArea(.all)
    }
}

struct Tab {
    var view: AnyView
    var barItem: UITabBarItem

    init<V: View>(view: V, barItem: UITabBarItem) {
        self.view = AnyView(view)
        self.barItem = barItem
    }
}

Solution

  • I have found a workaround / fix.

    I needed to use a custom UIHostingController, find the navigation controller, and add a line to the viewWillAppear function:

    public class MyHostingController: UIHostingController<AnyView> {
        override open func viewWillAppear(_ animated: Bool) {
            if let navigationController = navigationController() {
                navigationController.view.setNeedsUpdateConstraints()
            }
            super.viewWillAppear(animated)
        }
    }
    
    private extension UIViewController {
        func navigationController() -> UINavigationController? {
            var controller: UINavigationController?
            
            if let navigationController = self as? UINavigationController {
                return navigationController
            }
            
            children.forEach {
                if let navigationController = $0 as? UINavigationController {
                    controller = navigationController
                } else {
                    controller = $0.navigationController()
                }
            }
            
            return controller
        }
    }
    

    And then obvious replaced the two usages of UIHostingController in my original code with MyHostingController.

    The most important line is this though:

    navigationController.view.setNeedsUpdateConstraints()
    

    Hope this helps anyone else that finds a similar issue.