0

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.

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
    }
}
7
  • Your wrappers are being used incorrectly developer.apple.com/documentation/swiftui/stateobject also AnyView is highly discouraged and you should Never create models in the body. It gets redrawn all the time. None of this is a big it is textbook misunderstanding how SwiftUI works. Commented Jul 4, 2024 at 10:15
  • #1 Is the problem, that the view model is created within the scope of the RootView's body? Commented Jul 4, 2024 at 10:24
  • #2 But if you look at method #3. AnyView isn't used here, but the problem remains the same. It somehow makes .navigationDestination(for: ...) run multiple times. If the same code was directly inside .navigationDestination(for: ...) instead of a function, it would work as expected. Commented Jul 4, 2024 at 10:28
  • #1 This would normally happen in the Coordinator. But I didn't include that for the sake of explaining the actual problem. Commented Jul 4, 2024 at 10:30
  • It is all a problem, This @ObservedObject var router = Router() is incorrect it will leak and get recreated. This @StateObject var vm: MilkViewModel is improper use the memberwise initializer is unsafe. These let vm = CookieViewModel() are wrong because no-one is managing their lifecycle and the value type nature of SwiftUI makes copies of views that cant be accessed from the parent/outside. Commented Jul 4, 2024 at 13:38

1 Answer 1

0

This isn't valid SwiftUI, you shouldn't have view model or router/coordinator objects and instead embrace the View structs, @State/@Binding and computed vars for your view data. View structs are lightweight "descriptions" created on the memory stack thus you should not worry about then being re-init the same way you wouldn't worry about ints, more on this below. You should however avoid initing objects because those use the heap which will slow down SwiftUI (limit the init of objects to actions). Furthermore, @ObservedObject var router = Router() is a memory leak because that property wrapper is weak so the object is lost after the View structs have been init after a state change, so remove that too and instead use multiple navigationDestination, e.g. one for navigationDestination(for: Milk.self) and navigationDestination(for: Cookie.self) and these can be anywhere in the hierarchy below the NavigationStack.

If your aim with the view model objects is for testability then you can learn to use @State with a custom struct with mutating func for logic.

It might help to explain how SwiftUI works - it creates the View struct hierarchy like a big tree of values, then when a UI-state or model change happens it creates a new tree, diffs it then use the difference to init/deinit/upgrade UIKit UIView objects. It's use of immutable values prevents consistency problems. If your view data is in objects this won't work correctly.

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

4 Comments

thanks for the explanation. I'm not sure I understand why @ObservedObject var router = Router() becomes a memory leak. It doesn't appear lost when running the app. The views that I navigate to, can use the Router to navigate further and back. Perhaps it's because I set it as an .environmentObject(router) at the bottom of the body in the RootView?
And in terms of now having view models, router/coordinator objects. Are you suggesting that everything just lives on the view? That seems to be the opposite of separating concerns. Plus you'll get massive view files with everything in it. That seems odd to me at least :-) But I'm also a used to clean architecture and separating responsibilities. I'm a bit confused about, how you would go about it then.
Btw. I'm not saying you're wrong. Just trying to understand.
Read my last 2 paragraphs. Logic can go in funcs. SwiftUI View data structs are not "the view", SwiftUI automatically creates "the view" depending on the platform from your data structs.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.