Search code examples
iosswiftmemory-leaksswiftuicombine

SwiftUI - memory leak in NavigationView


I am trying to add a close button to the modally presented View's navigation bar. However, after dismiss, my view models deinit method is never called. I've found that the problem is where it captures the self in navigationBarItem's. I can't just pass a weak self in navigationBarItem's action, because View is a struct, not a class. Is this a valid issue or just a lack of knowledge?

struct ModalView: View {

    @Environment(\.presentationMode) private var presentation: Binding<PresentationMode>
    @ObservedObject var viewModel: ViewModel

    var body: some View {

        NavigationView {
            Text("Modal is presented")
            .navigationBarItems(leading:
                Button(action: {
                    // works after commenting this line
                    self.presentation.wrappedValue.dismiss()
                }) {
                    Text("close")
                }

            )
        }
    }
}

Solution

  • You don't need to split the close button out in its own view. You can solve this memory leak by adding a capture list to the NavigationView's closure: this will break the reference cycle that retains your viewModel.

    You can copy/paste this sample code in a playground to see that it solves the issue (Xcode 11.4.1, iOS playground).

    import SwiftUI
    import PlaygroundSupport
    
    struct ModalView: View {
        @Environment(\.presentationMode) private var presentation
        @ObservedObject var viewModel: ViewModel
    
        var body: some View {
            // Capturing only the `presentation` property to avoid retaining `self`, since `self` would also retain `viewModel`.
            // Without this capture list (`->` means `retains`):
            // self -> body -> NavigationView -> Button -> action -> self
            // this is a retain cycle, and since `self` also retains `viewModel`, it's never deallocated.
            NavigationView { [presentation] in
                Text("Modal is presented")
                    .navigationBarItems(leading: Button(
                        action: {
                            // Using `presentation` without `self`
                            presentation.wrappedValue.dismiss()
                    },
                        label: { Text("close") }))
            }
        }
    }
    
    class ViewModel: ObservableObject { // << tested view model
        init() {
            print(">> inited")
        }
    
        deinit {
            print("[x] destroyed")
        }
    }
    
    struct TestNavigationMemoryLeak: View {
        @State private var showModal = false
        var body: some View {
            Button("Show") { self.showModal.toggle() }
                .sheet(isPresented: $showModal) { ModalView(viewModel: ViewModel()) }
        }
    }
    
    PlaygroundPage.current.needsIndefiniteExecution = true
    PlaygroundPage.current.setLiveView(TestNavigationMemoryLeak())