I’ve run into a puzzling behavior in SwiftUI related to sheet presentation. When dismissing a sheet, I noticed that associated instances (view models held by the sheet’s view) don’t seem to get deinitialized properly.
From my tests, the only scenario in which deinit gets called as expected is when using @StateObject. In contrast, both @ObservedObject and the new @Observable macro don’t seem to trigger the deinit call.
Below, I’ve provided a set of examples showcasing various scenarios. Each attempts to supply the view model in a different way. To test the dismissal, you can simply swipe down on the presented sheet:
import SwiftUI
// ============================================================================ //
// MARK: - App
// ============================================================================ //
@main
struct SwiftUISheetDeinitIssueApp: App {
var body: some Scene {
WindowGroup {
CaseA_ContentView()
}
}
}
// ============================================================================ //
// MARK: - Case A: @StateObject (Works!)
// ============================================================================ //
struct CaseA_ContentView: View {
@State var isPresented = false
var body: some View {
Button("Show Sheet") {
self.isPresented = true
}
.sheet(isPresented: $isPresented) {
CaseA_SheetView()
}
}
}
struct CaseA_SheetView: View {
@StateObject var model = CaseA_SheetViewModel()
var body: some View {
Text("Sheet")
}
}
final class CaseA_SheetViewModel: ObservableObject {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
// ============================================================================ //
// MARK: - Case B: @ObservedObject & Inline Model Creation (Doesn't Work!)
// ============================================================================ //
struct CaseB_ContentView: View {
@State var isPresented = false
var body: some View {
Button("Show Sheet") {
self.isPresented = true
}
.sheet(isPresented: $isPresented) {
CaseB_SheetView(model: CaseB_SheetViewModel())
}
}
}
struct CaseB_SheetView: View {
@ObservedObject var model: CaseB_SheetViewModel
init(model: CaseB_SheetViewModel) {
self.model = model
}
var body: some View {
Text("Sheet")
}
}
final class CaseB_SheetViewModel: ObservableObject {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
// ============================================================================ //
// MARK: - Case C: @ObservedObject + @State in Parent (Doesn't Work!)
// ============================================================================ //
struct CaseC_ContentView: View {
@State var sheetViewModel: CaseC_SheetViewModel?
var body: some View {
Button("Show Sheet") {
self.sheetViewModel = CaseC_SheetViewModel()
}
.sheet(item: self.$sheetViewModel) { model in
CaseC_SheetView(model: model)
}
}
}
struct CaseC_SheetView: View {
@ObservedObject var model: CaseC_SheetViewModel
init(model: CaseC_SheetViewModel) {
self.model = model
}
var body: some View {
Text("Sheet")
}
}
final class CaseC_SheetViewModel: ObservableObject, Identifiable {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
// ============================================================================ //
// MARK: - Case D: Content @StateObject + Sheet @ObservedObject (Doesn't Work!)
// ============================================================================ //
struct CaseD_ContentView: View {
@StateObject var model = CaseD_ContentViewModel()
var body: some View {
Button("Show Sheet") {
self.model.sheetViewModel = CaseD_SheetViewModel()
}
.sheet(item: self.$model.sheetViewModel) { model in
CaseD_SheetView(model: model)
}
}
}
final class CaseD_ContentViewModel: ObservableObject, Identifiable {
@Published
var sheetViewModel: CaseD_SheetViewModel?
}
struct CaseD_SheetView: View {
@ObservedObject var model: CaseD_SheetViewModel
init(model: CaseD_SheetViewModel) {
self.model = model
}
var body: some View {
Text("Sheet")
}
}
final class CaseD_SheetViewModel: ObservableObject, Identifiable {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
// ============================================================================ //
// MARK: - Case E: @Observable
// ============================================================================ //
struct CaseE_ContentView: View {
@State
var sheetViewModel: CaseE_SheetViewModel?
var body: some View {
Button("Show Sheet") {
self.sheetViewModel = CaseE_SheetViewModel()
}
.sheet(item: self.$sheetViewModel) { model in
CaseE_SheetView(model: model)
}
}
}
struct CaseE_SheetView: View {
let model: CaseE_SheetViewModel
init(model: CaseE_SheetViewModel) {
self.model = model
}
var body: some View {
Text("Sheet")
}
}
@Observable
final class CaseE_SheetViewModel: Identifiable {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
// ============================================================================ //
// MARK: - Case G
// ============================================================================ //
struct CaseG_ContentView: View {
@State var isPresented = false
var body: some View {
Button("Show Sheet") {
self.isPresented = true
}
.sheet(isPresented: $isPresented) {
CaseG_SheetView(model: CaseG_SheetViewModel())
}
}
}
struct CaseG_SheetView: View {
@StateObject var model: CaseG_SheetViewModel
init(model: CaseG_SheetViewModel) {
self._model = StateObject(wrappedValue: model)
}
var body: some View {
Text("Sheet")
}
}
final class CaseG_SheetViewModel: ObservableObject {
init() { print("\(Self.self).\(#function)") }
deinit { print("\(Self.self).\(#function)") }
}
When I fire up the Memory Graph, it shows living instances of the CaseX_SheetViewModel class with the following reference pointing to it:
<AnyViewStorage<ModifiedContent<SheetContent<CaseX_SheetView>, _EnvironmentKeyWritingModifier<Binding<PresentationMode>>>>: 0x281f60640>
All my tests were done with the latest Xcode 15 RC on real devices running the latest beta of iOS 17 as well as the iOS 17 simulator.
Is there anything I’m missing, or is this a bug in SwiftUI? If the latter, has anyone found a suitable workaround?
Edit after a few days
- The issue has been confirmed by the Point-Free team, the developers of swiftui-navigation (and The Composable Architecture).
- I filed a feedback with Apple (FB13194873). Feel free to dupe it!
- While searching for a workaround, I experimented with an older, no longer recommended pattern of populating the
@StateObjectin the view’s initializer (see amended Case G). Unfortunately, it didn’t resolve the issue. - The only viable workaround seems to be embracing the fact that the view model object is not deinitialized and rather inform it about the dismissal (
onDismissoronDisappear) to make it stop long-running tasks.
Comment from Apple
Finally, a month later, I discovered an official statement from Apple regarding this bug on their forums. This post confirms that the memory leakage in SwiftUI’s sheet and fullScreenCover presentation modifiers is a known issue and suggests a workaround using UIKit. They’ve also provided a comprehensive code snippet for reference.
Resolved on iOS 17.2
As @JinwooKim pointed out in their answer, the issue seems to be resolved on iOS 17.2. I was able to confirm that with all the cases from above!
Here is my solution without using UIKit Presentation style.
Caution: It's super unsafe. Don't use this in production code!!!
Sample
Usage: