NavigationStack with NavigationPath triggers multiple init/deinit of views in stack

Hi all 👋

I've stumbled upon what I think is a bug in SwiftUI, but there might be an explanation for it, which I cannot to figure out. So here's the "bug". Perhaps any of you know it.

In short I found out that .navigationDestination(for: ...) runs multiple times when you abstract the logic away from the scope of the closure, into a function that should return the desired view from the type of destination. But if you keep the logic inside the scope of the closure it only runs once per destination.

The longer explanation I've setup a small example of a setup, which I'm planning on building an entire app on. Basically it's MVVVM with a coordinator pattern. I've ditched the coordinator in this example to minimize code, but the router is present and is key to how the app should work: navigation happens from a central place at the root of the app.

You should be able to copy all the code into a single file and run the app, to see what happens. It's a simple app with a rootview that can navigate to either "Cookies" or "Milk". You can set an amount (to have some sort of state, that you can test is still present, when navigating back to the view) + navigate on to another view.

The bug in question happens in the RootView:

.navigationDestination(for: Destination.self) { destination in
                let _ = print("||| Destination: \(destination.rawValue)")
                // Method #1
//                anyViewFor(destination: destination)
                
                // Method #2
//                itemViewFor(destination: destination)
                
                // Method #3
//                cookieViewFor(destination: destination)
                
                // Method #4
                switch destination {
                case .cookie:
                    let vm = CookieViewModel()
                    CookieView(vm: vm)
                    
                case .milk:
                    let vm = MilkViewModel()
                    MilkView(vm: vm)
                }
            }

If you comment out Method 4 and comment in any of Method 1, 2, 3 you will see the issue in the console.

Say you navigate from RootView -> CookieView (Set 2 cookies) -> MilkView (Set 1 glass of milk) -> CookieView, and then navigate back to RootView.

Method 4 produces the following prints:

||| Router: add to navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 960, num: 0

||| Router: add to navPath: 2
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 254, num: 0

||| Router: add to navPath: 3
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 348, num: 0

||| Router: remove from navPath: 2
||| Deinit 🔥: CookieViewModel, id: 348, num: 0

||| Router: remove from navPath: 1
||| Deinit 🔥: MilkViewModel, id: 254, num: 1

||| Router: remove from navPath: 0
||| Deinit 🔥: CookieViewModel, id: 960, num: 2

This makes sense. The desired Views+ViewModels (we only have prints from VMs) are init'ed and deinit'ed.

Method 1, 2, 3 produces the following prints:

||| Router: add to navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 893, num: 0

||| Router: add to navPath: 2
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 747, num: 0
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 384, num: 0

||| Router: add to navPath: 3
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 578, num: 0
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 409, num: 0
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 468, num: 0
||| Deinit 🔥: CookieViewModel, id: 384, num: 0

||| Router: remove from navPath: 2
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 859, num: 0
||| Deinit 🔥: CookieViewModel, id: 468, num: 0
||| Destination: Milk 🥛
||| Init ☀️: MilkViewModel, id: 250, num: 0
||| Deinit 🔥: MilkViewModel, id: 409, num: 0
||| Deinit 🔥: CookieViewModel, id: 578, num: 0

||| Router: remove from navPath: 1
||| Destination: Cookie 🍪
||| Init ☀️: CookieViewModel, id: 211, num: 0
||| Deinit 🔥: CookieViewModel, id: 859, num: 0
||| Deinit 🔥: MilkViewModel, id: 250, num: 0
||| Deinit 🔥: MilkViewModel, id: 747, num: 1

||| Router: remove from navPath: 0
||| Deinit 🔥: CookieViewModel, id: 211, num: 0
||| Deinit 🔥: CookieViewModel, id: 893, num: 2

This is where it gets weird. When it's a function returning the desired view for the given destination to .navigationDestination(for: ...) then it appears to be running n * number of items in the NavigationPath-object. You can see on the num: x in the deinit-prints, that instances are inited and deinted that we're never in touch with.

Do any of you have a qualified guess why this is happening? To me it seems like a bug.

I'll provide the code for mig example in a comment...

Answered by DTS Engineer in 794143022

@Knyhuus an identical function similar to what you have in the scope of the .navigationDestination(for:) closure would be:


    @ViewBuilder
    func itemViewFor(destination: Destination) -> some View {
        switch destination {
        case .cookie:
            CookieView()
        case .milk:
            MilkView()
        }
    }

Secondly, StateObject property wrapper should be marked private and instantiated with the View.

TESTABLE CODE:

public enum Destination: String, Codable, Hashable {
    case cookie = "Cookie 🍪"
    case milk = "Milk 🥛"
}
final class Router: ObservableObject {
    
    @Published var navPath = NavigationPath()
    
    func navigate(to destination: Destination) {
        navPath.append(destination)
        print("||| Router: add to navPath: \(navPath.count)")
    }
    
    func navigateBack() {
        guard navPath.count > 0 else { return }
        navPath.removeLast()
        print("||| Router: remove from navPath: \(navPath.count)")
    }
    
    func navigateToRoot() {
        guard navPath.count > 1 else { return }
        navPath.removeLast(navPath.count)
    }
    
}
struct RootView: View {
    
    @ObservedObject var router = Router()
    
    var body: some View {
        NavigationStack(path: $router.navPath) {
            List {
                Button(action: {
                    router.navigate(to: .cookie)
                }, label: {
                    Text(Destination.cookie.rawValue)
                })
                
                Button(action: {
                    router.navigate(to: .milk)
                }, label: {
                    Text(Destination.milk.rawValue)
                })
            }
            .navigationBarBackButtonHidden()
            .navigationDestination(for: Destination.self) { destination in
                let _ = print("||| Destination: \(destination.rawValue)")
                // Method #1
//                anyViewFor(destination: destination)
                
                // Method #2
//                itemViewFor(destination: destination)
                
                // Method #3
//                cookieViewFor(destination: destination)
                
                // Method #4
                switch destination {
                case .cookie:
                    let vm = CookieViewModel()
                    CookieView(vm: vm)
                    
                case .milk:
                    let vm = MilkViewModel()
                    MilkView(vm: vm)
                }
            }
        }
        .environmentObject(router)
    }
    
    func anyViewFor(destination: Destination) -> AnyView {
        switch destination {
        case .cookie:
            let vm = CookieViewModel()
            return AnyView(CookieView(vm: vm))
            
        case .milk:
            let vm = MilkViewModel()
            return AnyView(MilkView(vm: vm))
        }
    }
    
    func itemViewFor(destination: Destination) -> ItemView {
        switch destination {
        case .cookie:
            let vm = CookieViewModel()
            let view = CookieView(vm: vm)
            let anyView = AnyView(view)
            return ItemView(childView: anyView)
            
        case .milk:
            let vm = MilkViewModel()
            let view = MilkView(vm: vm)
            let anyView = AnyView(view)
            return ItemView(childView: anyView)
        }
    }
    
    func cookieViewFor(destination: Destination) -> CookieView {
        switch destination {
        case .cookie:
            let vm = CookieViewModel()
            return CookieView(vm: vm)
        
        case .milk:
            let vm = CookieViewModel()
            return CookieView(vm: vm)
        }
    }
}
struct ItemView: View {
    
    var childView: AnyView
    
    var body: some View {
        childView
    }
}
struct CookieView: View {
    
    // MARK: Properties
    @EnvironmentObject var router: Router
    @StateObject var vm: CookieViewModel
    
    // MARK: - Views
    var body: some View {
        List {
            Stepper("Amount: \(vm.amount)") {
                vm.incrementAmount()
            } onDecrement: {
                vm.decrementAmount()
            }
            .minimumScaleFactor(0.2)
            .padding(.top, 12)
            
            Button(action: {
                router.navigate(to: .milk)
            }, label: {
                Text("Get \(Destination.milk.rawValue)")
            })
        }
        .navigationTitle(Destination.cookie.rawValue)
        .navigationBarBackButtonHidden()
        .toolbar(content: {
            ToolbarItem(placement: .topBarLeading) {
                Button(action: {
                    router.navigateBack()
                }, label: {
                    Text("Back")
                })
            }
        })
    }
}
class CookieViewModel: ObservableObject {
    
    @Published var amount: Int = 0
    
    let id: Int
    
    init() {
        self.id = Int.random(in: 1...1000)
        print("||| Init ☀️: CookieViewModel, id: \(id), num: \(amount)")
    }
    
    deinit {
        print("||| Deinit 🔥: CookieViewModel, id: \(id), num: \(amount)")
    }
    
    func incrementAmount() {
        amount += 1
    }
    
    func decrementAmount() {
        amount -= 1
    }
}
struct MilkView: View {
    
    // MARK: Properties
    @EnvironmentObject var router: Router
    @StateObject var vm: MilkViewModel
    
    // MARK: - Views
    var body: some View {
        List {
            Stepper("Amount: \(vm.amount)") {
                vm.incrementAmount()
            } onDecrement: {
                vm.decrementAmount()
            }
            .minimumScaleFactor(0.2)
            .padding(.top, 12)
            
            Button(action: {
                router.navigate(to: .cookie)
            }, label: {
                Text("Get \(Destination.cookie.rawValue)")
            })
        }
        .navigationTitle(Destination.milk.rawValue)
        .navigationBarBackButtonHidden()
        .toolbar(content: {
            ToolbarItem(placement: .topBarLeading) {
                Button(action: {
                    router.navigateBack()
                }, label: {
                    Text("Back")
                })
            }
        })
    }
}
class MilkViewModel: ObservableObject {
    
    @Published var amount: Int = 0
    
    let id: Int
    
    init() {
        self.id = Int.random(in: 1...1000)
        print("||| Init ☀️: MilkViewModel, id: \(id), num: \(amount)")
    }
    
    deinit {
        print("||| Deinit 🔥: MilkViewModel, id: \(id), num: \(amount)")
    }
    
    func incrementAmount() {
        amount += 1
    }
    
    func decrementAmount() {
        amount -= 1
    }
}

@Knyhuus an identical function similar to what you have in the scope of the .navigationDestination(for:) closure would be:


    @ViewBuilder
    func itemViewFor(destination: Destination) -> some View {
        switch destination {
        case .cookie:
            CookieView()
        case .milk:
            MilkView()
        }
    }

Secondly, StateObject property wrapper should be marked private and instantiated with the View.

NavigationStack with NavigationPath triggers multiple init/deinit of views in stack
 
 
Q