SwiftUI List with navigation links memory management

I was very surprised to know that SwiftUI initialises the NavigationLink destination view before I actually navigate inside that link! Is this a bug or by design and if so why?! Any way to have those subviews allocated only when user taps the link? It's the same behaviour with ScrollView + LazyVStack, and I need List anyway. The following app initialises MyView objects as their parent labels appear on the screen, and as I scroll the list down the more and more items are allocated and never deallocated!

class MyObject: ObservableObject {
    var id: Int = -1
    
    init(id: Int) {
        print("MyObject init for \(id)")
    }
    deinit {
        print("MyObject deinit for \(id)")
    }
    var text: String {
        "Here you are \(id)"
    }
}

struct MyView: View {
    @ObservedObject private var someObject: MyObject
    let id: Int
    
    init(id: Int) {
        self.id = id
        _someObject = ObservedObject(initialValue: MyObject(id: id))
    }
    
    var body: some View {
        Text(someObject.text)
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(0 ..< 10000) { id in
                    NavigationLink.init("Hello \(id)") {
                        MyView(id: id)
                    }
                }
            }
        }
    }
}
1 Like

This has been the behavior since SwiftUI 1. There are various solutions floating around, including a LazyNavigationLink. As to why, I'm not sure we've ever gotten a clear explanation from Apple.

3 Likes

Using @StateObject behave more closely to what you want.

class MyObject: ObservableObject {
    var id: Int = -1
    
    init(id: Int) {
        self.id = id
        print("MyObject init for \(id)")
    }
    deinit {
        print("MyObject deinit for \(id)")
    }
    var text: String {
        "Here you are \(id)"
    }
}

struct MyView: View {
    @StateObject private var someObject: MyObject
    
    init(id: Int) {
        _someObject = .init(wrappedValue: MyObject(id: id))
    }
    
    var body: some View {
        Text(someObject.text)
    }
}

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(0 ..< 10000) { id in
                    NavigationLink.init("Hello \(id)") {
                        MyView(id: id)
                    }
                }
            }
        }
    }
}

The initializer for StateObject uses @autoclosure @escaping to not actually instantiate MyObject until you follow the NavigationLink and it releases the old instance when you navigate out and back into a row again.

This is the complete output from scrolling down to row 123, tapping into and back out of row 123, and then tapping into row 124:

MyObject init for 123
MyObject init for 124
MyObject deinit for 123
1 Like