Bizar leaking ViewModel in SwiftUI

Hi all,

Recently I stumbled upon some, for me at least, bizar memory leak. I have a viewmodel, which provides some sharing functionality. To do this it exposes 2 variables:

class ViewModelOne: ObservableObject {
  let tracker = DeinitTracker("ViewModelOne") //this is to easily track lifetime of instances
  @Published var shareReady = false
  var sharedUrls = [URL]()
   
  func share() {
    sharedUrls = [URL(string: "www.google.com")!]
    shareReady = true
  }
}

Next I have a mainview, which provides 2 subviews, with some controls to switch between the 2 subviews:

enum ViewMode {
  case one
  case two
}

struct MainView: View {
  @State var viewMode: ViewMode = .one
  var body: some View {
    VStack {
      switch viewMode {
      case .one:
        ViewOne()
      case .two:
        ViewTwo()
      }
      HStack {
        Spacer()
        Button(action: {
          viewMode = .one
        }, label: { Image(systemName: "1.circle").resizable().frame(width: 80) })
        Spacer()
        Button(action: {
          viewMode = .two
        }, label: { Image(systemName: "2.circle").resizable().frame(width: 80) })
        Spacer()
      }.frame(height: 80)
    }
  }
}

struct ViewOne: View {
  let tracker = DeinitTracker("ViewOne")
  @StateObject var viewModel = ViewModelOne()
  var body: some View {
    VStack {
      Button(action: {viewModel.share()},
          label: { Image(systemName: "square.and.arrow.up").resizable() })
      .frame(width: 40, height: 50)
      Image(systemName: "1.circle")
        .resizable()
    }
    .sheet(isPresented: $viewModel.shareReady) {
      ActivityViewController(activityItems: viewModel.sharedUrls)
    }
  }
}

struct ViewTwo: View {
  var body: some View {
    Image(systemName: "2.circle")
      .resizable()
  }
}

ViewOne contains a button that will trigger its viewmodel to setup the urls to share and a published property to indicate that the urls are ready. That published property is then used to trigger the presence of a sheet. This sheet then shows the ActivityViewController wrapper for SwiftUI:

struct ActivityViewController: UIViewControllerRepresentable {
  let activityItems: [Any]
  let applicationActivities: [UIActivity]? = nil
  @Environment(\.presentationMode) var presentationMode
   
  func makeUIViewController(context: UIViewControllerRepresentableContext<ActivityViewController>) -> UIActivityViewController {
    let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: applicationActivities)
    controller.completionWithItemsHandler = { _, _, _, _ in
      self.presentationMode.wrappedValue.dismiss()
    }
    return controller
  }
   
  func updateUIViewController(_ uiViewController: UIActivityViewController, context: UIViewControllerRepresentableContext<ActivityViewController>) {
  }
}

And now for the bizar part. As long as the sheet hasn't been shown, all is well and switching between subview 1 and 2 behaves as expected, where ViewOne's ViewModel is deinitialized when the ViewOne instance is destroyed. However to once the sheet with ActivityViewController has been presented in ViewOne, and then switching to ViewTwo, ViewOne is still destroyed, but ViewOne's viewmodel isn't. To track this, I have helper struct DeinitTracker that prints when it gets initialized and deinitialized:

public class DeinitTracker {
  static var counter: [String: Int] = [:]
  public init(_ deinitText: String) {
    DeinitTracker.counter[deinitText] = (DeinitTracker.counter[deinitText] ?? -1) + 1
    self.deinitText = deinitText
    self.count = DeinitTracker.counter[deinitText] ?? -1
    print("DeinitTracker-lifetime: \(deinitText).init-\(count)")
  }
   
  let deinitText: String
  let count: Int
   
  deinit {
    print("DeinitTracker-lifetime: \(deinitText).deinit-\(count)")
  }
}

I can't figure out who is holding a reference to the ViewModel that prevents it from being deinitialized.

I know it's a rather complicated explanation, but I'm hoping the scenario is clear. I've prepared a Playgrounds app to demonstrate the problem -> https://www.icloud.com/iclouddrive/0629ZP6MXMrj7GJIWHpGum6Dw#LeakingViewModel

I'm hoping someone can explain what's going on. Is this a bug in SwiftUI? Or am I using it wrong by binding a viewmodel's published property to a sheet's isPresented property.

If you have any questions, don't hesitate to ask.

Replies

Bump Nobody have any idea why SwiftUI is misbehaving here?