SwiftUI navigation router issues

267 views Asked by At

I have a simple navigation router:

@Observable class BaseRouter {
    var path = NavigationPath()
    var isEmpty: Bool {
        return path.isEmpty
    }
    
    func navigateBack() {
        path.removeLast()
    }
    
    func popToRoot() {
        path.removeLast(path.count)
    }
}

And a subclass:

class ProfileRouter: BaseRouter {
    enum ProfileViewDestination {
        case viewA
        case viewB
    }
    
    enum ViewADestination: FormNavigationItem {
        case viewC
    }

    func navigate(to destination: ProfileViewDestination) {
        path.append(destination)
    }
    
    func navigate(to destination: ViewADestination) {
        path.append(destination)
    }
}

When using navigate(to:) and navigateBack functions all works fine, but when when using NavigationLink and native navigation back button I see my path is not really updating, although the NavigationStack is navigating currently.
So when going back from navigation button I can see my path still containing the appended item, and when I'll navigate to the screen again it will present it twice and so on.
One solution for this issue is to override the native back button but not only it will cause a loss of native functionality (back stack, swipe back) it adds a lot of redundant boilerplate code, so it is a bad solution IMO.
As for NavigationLink I'm having the opposite issue, where it does not appending the proper item to the path and navigateBack will go 2 levels back.
I'm sure I am missing something stupid here, or that's just not the way 'NavigationPath' meant to be used. either way I can't seems to find any good example for this one.
Ideas?!

1

There are 1 answers

6
Open-Business On

Use ObservableObject to make views update on changes of your class BaseRouter. @Observable only update properties that are in the View's body.

Apple Documentation "If body doesn’t read any properties of an observable data model object, the view doesn’t track any dependencies."

Because NavigationStack's path is not read in the body of its view, @Observable doesn’t track any dependencies.

NavigationStack is:

@MainActor
init(
    path: Binding<NavigationPath>,
    @ViewBuilder root: () -> Root
) where Data == NavigationPath

Just add ObservableObject Like This:

@Observable class BaseRouter: ObservableObject  {
    var path = NavigationPath()
    var isEmpty: Bool {
        return path.isEmpty
    }
    
    func navigateBack() {
        path.removeLast()
    }
    
    func popToRoot() {
        path.removeLast(path.count)
    }
}

Note: You will then have to use @StateObject to initialize your class BaseRouter.

@main
struct YourApp: App {
    @StateObject private var router = BaseRouter()
 // MARK:  BODY
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(router)
        }
    } // Body
    ...
}

Add @EnviromentObject to views that need to use the router:

@EnvironmentObject var router: Router

Alternate options to BaseRouter class methods:

 var navigationPath = NavigationPath() {
        // MARK: SAVE WHEN CHANGES HAPPEN TO NAVIGATIONPATH
        didSet {
            save()
        } // didSet
    } // navigationPath

    // MARK: URL TO SAVE NAVIGATIONPATH TO.
    private let savePath = URL.documentsDirectory.appending(path: "SavedNavigationPath")

    // MARK: INIT
    init() {
        if let data = try? Data(contentsOf: savePath) {
            if let decoded = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) {
                navigationPath = NavigationPath(decoded)
                return
            } // decoded
        } // data
    } // init

    // MARK: METHODS
    func save() {
        guard let representation = navigationPath.codable else { return }
        do {
            let data = try JSONEncoder().encode(representation)
            try data.write(to: savePath)
        } catch {
            print("Failed to save navigation data")
        } // do|catch
    } // save
    
    func home() {
        navigationPath = NavigationPath()
    } // home
   
   func back(){
       if navigationPath.count > 0{
           navigationPath.removeLast()
       } // if
   }// back

    func pushView(route: any Routeable ){
       navigationPath.append( route )
   } // pushView

Where Routeable is:

protocol Routeable: Codable, Hashable {}

Then enum conforming to protocol Routeable:

enum YourAppsRoutes: Routeable{
    case home
    case userform
    case someGreatView
    case anotherGreatView
   
} // YourAppsRoutes

All that's left now is switching on YourAppsRoutes:

NavigationLink(...){ ... }
    .navigationDestination(for: YourAppsRoutes.self) { route in
                Group{
                    switch route{
                    case .home:
                        HomeView()
                    case .userform:
                        UserFormView()
                    case .someGreatView:
                        someGreatView()
                    case .anotherGreatView:
                        anotherGreatViewm()
                    } // switch
                }// Group
                .environmentObject(router)

@ViewBuilder might be needed. You typically use ViewBuilder as a parameter attribute for child view-producing closure parameters, allowing those closures to provide multiple child views.

Hope this helps,

Xcode Version 15.1 (15C65)

Deployment: iOS 17.2

Apple Feedbacks link

FeedBack#: FB12969309