skip to Main Content

My goal is to conform to the Dependency Inversion principle. Meaning that SheetView should depend on Sheet ViewModel’s protocol.

The problem is when I pass ViewModel to a Sheet View’s constructor, when I dismiss the Sheet View, it will not deinit.

        .sheet(isPresented: $viewModel.isSheetPresented) {
            let params = SheetViewModelParams(
                initialCount: 0,
                initialViewType: .List
            )
            
            /**
             - Bug: ViewModel will not deinit. Use .init(params:)
             */
//            let viewModel = SheetViewModel(params: params)
//            SheetView(viewModel: viewModel)
            
            SheetView(params: params)
        }

Full code: swift-bloc-example

The code below test:

  1. eagerly load the Sheet View, will only initialize the Sheet ViewModel when the Sheet View is presented.
  2. deinit Sheet ViewModel when the Sheet View is dismissed.
  3. init Sheet List ViewModel when the Sheet List View is presented.
  4. deinit Sheet List ViewModel when the Sheet View or when it present Sheet New View.

=== Code that is necessary for this test.

ContentWithoutStateView.swift

import SwiftUI

struct ContentWithoutStateView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: ContentWithoutStateViewModel
    
    init(params: ContentWithoutStateViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: ContentWithoutStateViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Content Without State")
            
            switch viewModel.onSubmitStatus {
            case .initial:
                Button {
                    Task {
                        await viewModel.onSubmit()
                    }
                } label: {
                    Text("Submit")
                }
                .onAppear {
                    print("(type(of: self)) initial")
                }
            case .loading:
                ProgressView()
                    .onAppear {
                        print("(type(of: self)) loading")
                    }
            case .success:
                Text("Success")
                    .onAppear {
                        print("(type(of: self)) Success")
                    }
            case .failure:
                Text("Failure")
                    .onAppear {
                        print("(type(of: self)) Failure")
                    }
            }
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
            
            Button {
                viewModel.isSheetPresented = true
            } label: {
                Text("show the sheet")
            }
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("(type(of: self)) viewModel will change. count: (renderCount)")
        })
        .sheet(isPresented: $viewModel.isSheetPresented) {
            let params = SheetViewModelParams(
                initialCount: 0,
                initialViewType: .List
            )
            
            /**
             - Bug: ViewModel will not deinit. Use .init(params:)
             */
//            let viewModel = SheetViewModel(params: params)
//            SheetView(viewModel: viewModel)
            
            SheetView(params: params)
        }
    }
}

#Preview {
    let params = ContentWithoutStateViewModelParams()
    return ContentWithoutStateView(params: params)
}

OnSubmitStatus.swift

enum OnSubmitStatus {
    case initial
    case loading
    case success
    case failure
}

ContentWithoutStateViewModelParams

struct ContentWithoutStateViewModelParams {
    let initialCount: Int
    let initialOnSubmitStatus: OnSubmitStatus
    let initialIsSheetPresented: Bool
    
    init(
        initialCount: Int = 0,
        initialOnSubmitStatus: OnSubmitStatus = .initial,
        initialIsSheetPresented: Bool = false
    ) {
        self.initialCount = initialCount
        self.initialOnSubmitStatus = initialOnSubmitStatus
        self.initialIsSheetPresented = initialIsSheetPresented
    }
}

ContentWithoutStateViewModel.swift

final class ContentWithoutStateViewModel: ObservableObject {
    @Published var count: Int
    @Published var onSubmitStatus: OnSubmitStatus
    
    @Published var isSheetPresented: Bool
    
    init(params: ContentWithoutStateViewModelParams) {
        self.count = params.initialCount
        self.onSubmitStatus = params.initialOnSubmitStatus
        self.isSheetPresented = params.initialIsSheetPresented
        print("(type(of: self)) (#function)")
    }
    
    deinit {
        print("(type(of: self)) (#function)")
    }
    
    func fetchContent() async -> Result<Bool, Error> {
        sleep(1)
        return .success(true)
    }
    
    @MainActor
    func onSubmit() async {
        onSubmitStatus = .loading
        
        let result = await fetchContent()
        
        result.fold { success in
            count += 1
            onSubmitStatus = .success
        } errorTransform: { failure in
            count -= 1
            onSubmitStatus = .failure
        }

    }
}

ContentWithoutStateViewModel+Shared.swift

extension ContentWithoutStateViewModel {
    static func shared(params: ContentWithoutStateViewModelParams) -> ContentWithoutStateViewModel {
        var temp: ContentWithoutStateViewModel
        
        if _shared == nil {
            temp = ContentWithoutStateViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    static weak var _shared: ContentWithoutStateViewModel?
}

==== Sheet

SheetView.swift

struct SheetView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: SheetViewModel
    
    init(params: SheetViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetViewModel.shared(params: params)
        )
    }
    
    @available(
        *,
         deprecated,
         message: "Bug: ViewModel will not deinit when Sheet is dismissed. use .init(params:)")
    init(viewModel: SheetViewModel) {
        self._viewModel = StateObject(wrappedValue: viewModel)
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
            
            Button {
                viewModel.selectedViewType = .List
            } label: {
                Text("show the sheet list")
            }
            
            Button {
                viewModel.selectedViewType = .New
            } label: {
                Text("show the sheet new")
            }
            
            switch viewModel.selectedViewType {
            case .List:
                let params = SheetListViewModelParams(initialCount: 0)
                SheetListView(params: params)
            case .New:
                let params = SheetNewViewModelParams(initialCount: 0)
                SheetNewView(params: params)
            }
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("(type(of: self)) viewModel will change. count: (renderCount)")
        })
    }
}

#Preview {
    let params = SheetViewModelParams(
        initialCount: 0, 
        initialViewType: .List
    )
    return SheetView(params: params)
}

SheetViewType.swift

enum SheetViewType {
    case List
    case New
}

SheetViewModelParams.swift

struct SheetViewModelParams {
    let initialCount: Int
    let initialViewType: SheetViewType
}

SheetViewModel.swift

import Foundation

final class SheetViewModel: ObservableObject {
    let id: UUID = UUID()
    
    @Published var count: Int
    @Published var selectedViewType: SheetViewType
    
    init(
        params: SheetViewModelParams
    ) {
        self.count = params.initialCount
        self.selectedViewType = params.initialViewType
        print("(type(of: self)) (#function) (id)")
    }
    
    deinit {
        print("(type(of: self)) (#function) (id)")
    }
}

SheetViewModel.swift

extension SheetViewModel {
    static func shared(params: SheetViewModelParams) -> SheetViewModel {
        var temp: SheetViewModel
        
        if _shared == nil {
            temp = SheetViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    private static weak var _shared: SheetViewModel?
}

=== Sheet List

struct SheetListView: View {
    @State var renderCount: Int = 0
    
    @StateObject var viewModel: SheetListViewModel
    
    init(params: SheetListViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetListViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet List")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("(type(of: self)) viewModel will change. count: (renderCount)")
        })
    }
}

#Preview {
    let params = SheetListViewModelParams(initialCount: 0)
    return SheetListView(params: params)
}

SheetListViewModelParams.swift

import Foundation

struct SheetListViewModelParams {
    let initialCount: Int
}

SheetListViewModel.swift

import Foundation

final class SheetListViewModel: ObservableObject {
    let id = UUID()
    
    @Published var count: Int
    
    init(params: SheetListViewModelParams) {
        self.count = params.initialCount
        
        print("(type(of: self)) (#function) (id)")
    }
    
    deinit {
        print("(type(of: self)) (#function) (id)")
    }
}

SheetListViewModel.swift

extension SheetListViewModel {
    static func shared(params: SheetListViewModelParams) -> SheetListViewModel {
        var temp: SheetListViewModel
        
        if _shared == nil {
            temp = SheetListViewModel(params: params)
            _shared = temp
        }
        
        return _shared!
    }
    
    private static weak var _shared: SheetListViewModel?
}

=== Sheet New

SheetNew.swift

import SwiftUI

struct SheetNewView: View {
    @State var renderCount = 0
    
    @StateObject var viewModel: SheetNewViewModel
    
    init(params: SheetNewViewModelParams) {
        self._viewModel = StateObject(
            wrappedValue: SheetNewViewModel.shared(params: params)
        )
    }
    
    var body: some View {
        VStack(spacing: 8) {
            Text("Sheet New")
            
            CountComponent(
                count: $viewModel.count,
                onDecrement: {
                    viewModel.count -= 1
                },
                onIncrement: {
                    viewModel.count += 1
                }
            )
        }
        .padding(.all, 16)
        .border(.secondary)
        .onReceive(viewModel.objectWillChange, perform: { _ in
            renderCount += 1
            print("(type(of: self)) viewModel will change. count: (renderCount)")
        })
    }
}

#Preview {
    let params = SheetNewViewModelParams(initialCount: 0)
    return SheetNewView(params: params)
}

SheetNewViewModelParams.swift

struct SheetNewViewModelParams {
    let initialCount: Int
}

SheetNewViewModel.swift

import Foundation

final class SheetNewViewModel: ObservableObject {
    let id: UUID = UUID()
    
    @Published var count: Int
    
    init(params: SheetNewViewModelParams) {
        self.count = params.initialCount
        print("(type(of: self)) (#function) (id)")
    }
    
    deinit {
        print("(type(of: self)) (#function) (id)")
    }
}

SheetNewViewModel.swift

extension SheetNewViewModel {
    static func shared(params: SheetNewViewModelParams) -> SheetNewViewModel {
        var temp: SheetNewViewModel
    
        if _shared == nil {
            temp = SheetNewViewModel(params: params)
            _shared = temp
        }
    
        return _shared!
    }
    
    private static weak var _shared: SheetNewViewModel!
}

2

Answers


  1. It seems that this is a legitimate bug on apple’s end. You passing the viewModel to the View’s constructor doesn’t really “cause” the memory leak but it allows it to happen. Otherwise what you are doing should be fine:

    • SheetViewModel is created in a closure, so it shouldn’t get computed as part of the ContentWithoutStateView’s body.
    • A View is a value type and should not increase the reference count regardless.

    See the related issue on Apple dev forums:
    https://developer.apple.com/forums/thread/737967?answerId=767599022#767599022

    This appears to be a known issue (r. 115856582) on iOS 17 affecting sheet and fullScreenCover presentation

    Also, there is a very similar question here:
    SwiftUI Sheet never releases object from memory

    Login or Signup to reply.
  2. @James is right.

    You should end up with something like this:

    .sheet(isPresented: $viewModel.isSheetPresented) {
        // Instantiate the ViewModel for the sheet here!
        let sheetViewModel = SheetViewModel(params: params)
        SheetView(viewModel: sheetViewModel)
    }
    

    In this case, the view model is created inside the sheet block, and its lifecycle is tied to the sheet view. When dismissed, sheetViewModel should be deallocated, assuming there are no other strong references to it.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search