2

Can someone help me understand the behavior of 'NavigationStack.count'. I don't understand how/when it's updated. In this example, I use the count to disable back navigation. But as you can see, initially(as expected), the back button is disabled on screen 1. But when I navigate to screen 2, and then back to screen 1, the back button flashes for less than a second even though the print statement shows that count is 1 and should disable back navigation.

import SwiftUI

struct ExploreDelayedNavigationPathCount: View {
    @State private var navigationPath = NavigationPathWrapper()
    init() {
        navigationPath.path.append(0)
    }
    var body: some View {
        NavigationStack(path: $navigationPath.path) {
            Text("View to register navigationDestination only")
                .navigationDestination(for: Int.self) { number in
                    SubView(number: number, navigationPath: navigationPath)
                }
                .navigationTitle("Hidden Root")
        }
    }


}

struct SubView: View {
    let number: Int
    var navigationPath: NavigationPathWrapper
    var body: some View {
        Text("Go to screen \(number + 1)")
            .onTapGesture {
                navigationPath.append(number + 1)
            }
            .navigationTitle("Screen \(number)")
            .navigationBarBackButtonHidden(navigationPath.disableBackNavigation)
    }


}

#Preview {
    ExploreDelayedNavigationPathCount()
}

@Observable
class NavigationPathWrapper {
    var path = NavigationPath()
    var disableBackNavigation: Bool {
        print("path.count \(path.count)")
        return path.count == 1
    }
    func append(_ value: any Hashable) {
        path.append(value)
    }
}
1
  • This is likely just an artefact of how SwiftUI interacts with the underlying UINavigationController. You probably need to control the back button from the UIKit side yourself if you want to avoid this. Commented Aug 29, 2024 at 5:09

1 Answer 1

0

SwiftUI seems to simply update the view too late, probably at around viewDidAppear in terms of the UIKit lifecycle.

As a workaround, I would use a special type as the "first" destination, s that you can write another navigationDestination(for:) then only hide the back button in there.

enum NoBackButtonDestination {
    case unit
}

struct ContentView: View {
    @State private var navigationPath = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $navigationPath) {
            Text("View to register navigationDestination only")
                .navigationDestination(for: Int.self) { number in
                    SubView(number: number, navigationPath: $navigationPath)
                }
                .navigationDestination(for: NoBackButtonDestination.self) { _ in
                    SubView(number: 0, navigationPath: $navigationPath)
                        .navigationBarBackButtonHidden(true)
                }
                .navigationTitle("Hidden Root")
        }
        .onAppear {
            // append the first value in onAppear!
            // don't do this in init, because init is called every time the parent updates
            navigationPath.append(NoBackButtonDestination.unit)
        }
    }
}

struct SubView: View {
    let number: Int
    @Binding var navigationPath: NavigationPath
    var body: some View {
        Text("Go to screen \(number + 1)")
            .onTapGesture {
                navigationPath.append(number + 1)
            }
            .navigationTitle("Screen \(number)")
    }
}

If "polluting" the navigation path like this is undesirable for some reason, it is also possible to set the back button to hidden on the UIKit side:

struct BackButtonHider: UIViewControllerRepresentable {
    
    class VC: UIViewController {
        
        override func viewWillAppear(_ animated: Bool) {
            super.viewWillAppear(animated)
            // '== 2' instead of '== 1' here because the root view controller is also included in the 'viewControllers'
            parent?.navigationItem.hidesBackButton = navigationController?.viewControllers.count == 2
        }
    }
    
    func makeUIViewController(context: Context) -> VC { .init() }
    
    func updateUIViewController(_ uiViewController: VC, context: Context) { }
}

struct SubView: View {
    let number: Int
    @Binding var navigationPath: NavigationPath
    var body: some View {
        Text("Go to screen \(number + 1)")
            .onTapGesture {
                navigationPath.append(number + 1)
            }
            .navigationTitle("Screen \(number)")
             // add this as the background
            .background { BackButtonHider() }
    }
}

This depends on the fact that the parent of the UIViewControllerRepresentable is the hosting controller that SwiftUI uses to host the navigation destination. In the unlikely event that SwiftUI stops using a UINavigationController to implement its NavigationStack, this will no longer work. But then navigationBarBackButtonHidden might start to work as you expect too, so who knows...

Sign up to request clarification or add additional context in comments.

4 Comments

It works. Thanks for helping me understand! @Sweeper
Would a similar approach be useful if I have a custom NavBarView? My post attempted to narrow the issue, but the actual use case is a custom navbar.
@WorldNeedsRefactoring What do you mean by "the navbar UI is custom"? Do you hide the default navbar and build it with your own views? I assume in that case you will make your own version of navigationBarBackButtonHidden too, and so the NoBackButtonDestination approach can be used. However, you cannot set navigationItem.hidesBackButton like in the second approach. That won't control your own views.
Exactly hiding the default navbar and building my own views is what I'm doing. And, as you said, the second approach doesn't work in that scenario. But your suggestions led me to this solution: Use '@State var isRootInNavigationStack,' which I pass to init so that the back button's visibility is fixed when you navigate back again.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.